2009/9/22

[DataTable] .NET DataTable 詳論 (含相關主題)

DataTable 是 ADO.NET 的核心物件。如果你想學好 ADO.NET, 你絕對避免不了接觸到 DataTable。如果你過去曾經透過網路上的片段知識學到了你以為足夠的 ADO.NET 知識, 卻對以 DataTable 為始的基礎概念沒有深刻的認識, 那麼我建議你下一些苦工, 就從這一篇文章開始, 重新把底子建好。

在這裡, 我必須假設你對資料庫的基本知識已經有了清楚的認識, 否則你應該回頭先去讀讀這方面的書籍。相反的, 如果你已經對資料庫原理瞭若指掌, 那麼你一定對以下的內容感覺到異常的容易。因為 DataTable 本身幾乎就是資料庫裡面 Data Table 在記憶體中的翻版, 如果你懂資料庫, 你對 DataTable 絕對沒有任何難以理解之處。

離線式資料操作 (Disconnected Data Operation)

DataTable 物件是 .NET 離線式資料操作方法的核心。什麼叫做離線式? 我們在操作資料庫時, 我們首先需要與資料庫建立連線 (Connection), 然後進行操作 (CRUD 及管理), 最後再關閉連線。在以往, 程式設計師可能不習慣採用離線式的資料操作, 因為他們一旦建立與資料庫的連線之後, 就一直保持連線的開啟, 必須一直等到應用程式結束時才關閉連線。後者通常稱為連線式 (Connected) 的資料操作方式。

與離線式的資料操作方式比較起來, 連線式資料操作可以提供更佳的效能。但是缺點呢? 我們必須了解, 資料連線其實會佔用許多資源 (頻寬、記憶體等等); 在單人或少數使用者的環境中, 我們可能感覺不出問題, 但是如果在多人多工的使用環境下 (網站伺服器就是個絕佳的例子 - 同時間存取人數可以幾千、幾萬倍於單人的環境), 隨著訪客的增加, 資料連線所耗用的巨大資源就會變成相當大的問題。

在這種情況下, 我們就必須摒棄連線式資料操作方法, 並考慮離線式資料操作方法。不過話說回來, 如果你正在規畫的是預計將有龐大上線人潮 (例如同一時間有三千個 Session 以上) 的商業網站, 那麼你恐怕得考慮採用比單獨使用離線式資料操作再進階的做法, 包括完全採用 DataReader、架設 Web Farm 等等 - 這些屬於較為進階的議題, 在此不再討論。

DataTable 與子元素

一個 DataTable 物件包含資料列 (DataRow)、資料欄 (DataColumn) 與條件約束 (contraints) 三種子元素。其中 DataRow 是真正包含資料的物件。DataColumn 雖然也是「物件」, 但它其實只是用來定義資料欄位及關聯性的抽象元素, 你沒辦法從 DataColumn 取出任何包含實際資料的東西。所以如果你拿 HTML 中的 Table 物件來與 DataTable 做類比的話, TableCell 物件和 DataColumn 物件在使用上幾乎是完全不一樣的。

請記得, 雖然 DataTable 是 ADO.NET 的核心, DataTable 卻不需要連上資料庫才能使用。DataTable 可以單獨存在, 可以單純的拿來儲存資料 (坦白說, 我個人還蠻愛用的), 就像是一個超好用的二維陣列一樣。可惜它既不是繼承自 Collections 類別, 也沒有實作 IDictionary 和 ICollection 等等, 所以沒有什麼集合物件的好處。但是它實作了 ISourceList, 可以直接拿來作為許多控制項的 DataSource。更重要的是, 它提供了許多幾乎等同於資料庫的功能, 你可以把它拿來當作一個記憶體中超快速資料庫, 而且它提供了接近 Transaction 的功能, 和以類似 SQL 指令進行搜尋與排序和一些關聯式資料才有的功能等等, 在某些特定場合中非常的好用。

在記憶體中建立 DataTable 物件

我們可以採用多種方法來建立一個內含資料的 DataTable 物件。以下我先示範一種以動態方式建立 DataTable 的方法:

private DataTable createContactsTable()
{
    DataTable dt = new DataTable("Contacts");

    DataColumn dcName = new DataColumn("Name", typeof(string));
    dcName.MaxLength = 60;
    dcName.Unique = false;
    dcName.AllowDBNull = true;
    dcName.Caption = "姓名";
    dt.Columns.Add(dcName);

    DataColumn dcPhone = new DataColumn("Phone", typeof(string));
    dcPhone.MaxLength = 25;
    dcPhone.Unique = false;
    dcPhone.AllowDBNull = false;
    dcPhone.Caption = "電話";
    dt.Columns.Add(dcPhone);

dt.PrimaryKey = new DataColumn[] { dcName, dcPhone };
    return dt;
}

在以上程式中, 我建立了一個只有兩個資料欄位的 DataTable 物件, 而且這兩個欄位共同組成這個 DataTable 的 Primary Key。

接著, 我們再來看看一個內含衍生資料行 (Derived Data Column) 的範例:

private DataTable createContactsTable()
{
    DataTable dt = new DataTable("Contacts");

    DataColumn dcLastName = new DataColumn("LastName", typeof(string));
    dcLastName.MaxLength = 30;
    dcLastName.Unique = false;
    dcLastName.AllowDBNull = true;
    dt.Columns.Add(dcLastName);

    DataColumn dcFirstName = new DataColumn("FirstName", typeof(string));
    dcFirstName.MaxLength = 30;
    dcFirstName.Unique = false;
    dcFirstName.AllowDBNull = true;
    dt.Columns.Add(dcFirstName);

    DataColumn dcFullName = new DataColumn("FullName", typeof(string));
    dcFullName.MaxLength = 60;
    dcFullName.Unique = false;
    dcFullName.AllowDBNull = true;
    dcFullName.Expression = "LastName + ', ' + FirstName";
    dt.Columns.Add(dcFullName);

    DataColumn dcPhone = new DataColumn("Phone", typeof(string));
    dcPhone.MaxLength = 25;
    dcPhone.Unique = false;
    dcPhone.AllowDBNull = false;
    dt.Columns.Add(dcPhone);

    dt.PrimaryKey = new DataColumn[] { dcLastName, dcFirstname, dcPhone };
    return dt;
}

在這個範例中, 我們可以看到 dcFullName 這個資料欄位被加上了一個 Expression 屬性, 代表這個欄位是一個衍生出來的運算式資料行 (Expression Column)。運算式資料行是一種邏輯資料行, 它的值是透過運算方式得到, 所以它的值無法被直接賦予。

在 DataTable 中加入資料

前面講過, 在 DataTable 中包含資料的單位是 DataRow。我們剛才已經建立好 DataTable 的結構描述 (Schema), 現在就可以透過建立 DataRow 物件, 把資料一筆一筆加上去:

    private DataTable genContacts()
    {
        DataTable dt = createContactsTable();
        DataRow dr = dt.NewRow();
        dr["LastName"] = "李";
        dr["FirstName"] = "大同";
        dr["Phone"] = "+886-2-1234-5678";
        dt.Rows.Add(dr);

        dt.Rows.Add("鹿以", "三都里瓦", null, "+886-2-4234-5678");

        dr = dt.NewRow();
        dr["LastName"] = "小倉";
        dr["FirstName"] = "健次郎";
        dr["Phone"] = "+886-2-5234-5678";
        dt.Rows.Add(dr);

        dr = dt.NewRow();
        dr["LastName"] = "Fitzgeralson";
        dr["FirstName"] = "Judith";
        dr["Phone"] = "+886-2-6234-5678";
        dt.Rows.Add(dr);

        return dt;
    }

在範例中我示範了兩種加上 DataRow 的方法, 其原理事實上是一樣的。

在這裡我們要注意一點, 那就是因為 DataRow 的建構函式 (Constructor) 的存取層級為 Protected 而非 Public, 所以 DataRow 雖然也是「物件」, 你卻無法透過使用 DataRow dr = New DataRow() 這種指令來建立一個 DataRow 物件, 而必須使用 DataTable.NewRow() 指令來建立。

建立好 DataRow 之後, 你可以透過 DataRow["欄位名稱"] 的方法來賦予或取得 DataRow 某一欄位的值, 然後再使用 DataTable.Rows.Add() 方法把這個 DataRow 加入 DataTable 裡面。

在這個範例中, 我並沒有加上 dr["FullName"] 的值, 原因在上一段曾經提過, 表示式資料行是從既有資料中自動衍生出來的, 無法賦予值給它 (即使你真的指定值給它, 也沒有任何作用)。我們雖然沒有賦予它的值, 但是在使用時它確實是有值的 (除非原始資料剛好是空值)。

這裡有一點值得你特別留意。DataRow["欄位名稱"] 這個物件本身是一個 Object 型別。千萬別因為你在 DataTable 的結構描述中已經指定該欄位的資料型別而以為它的型別是確定的。如果你不相信的話, 你可以在上面範例中把 dr["LastName"] = "李"; 改成 dr["LastName"] = 100; 結果照樣可以執行而不會發生錯誤。為什麼呢? 就是因為 DataRow["欄位名稱"] 根本是個 Object 型別的物件, 程式會在 Runtime 時才去評估這裡的「欄位名稱」對應到第幾個資料欄位, 並且會暗地裡 (implicitly) 幫你轉換型別。因此, 你最好能特別留意有這個型別轉換的過程, 以免在 Runtime 時才發現問題。

如上述程式, 當你建立好 DataTable 物件之後, 就可以把它當作資料繫結控制項的 DataSource 了, 如下例:

GridView1.DataSource = genContacts();
GridView1.DataBind();

可以反悔的編輯功能與 DataRow 狀態

DataRow 物件隨時都在 DataRow.RowState 中保持一個 DataRowState 列舉值(詳細內容請參考 MSDN) 以記錄它目前處於何種狀態 -

  • Detached - 已經建立資料列,但不是任何 DataRowCollection 的一部分。DataRow 在已經建立後、加入至集合前,或如果已經從集合移除後,會立即處在這個狀態中。
  • Unchanged - 自從上次呼叫 AcceptChanges 之後,資料列尚未變更。
  • Added - 資料列已經加入至 DataRowCollection,並且尚未呼叫 AcceptChanges。
  • Deleted - 使用 DataRow 的 Delete 方法來刪除資料列。
  • Modified - 已經修改資料列,並且尚未呼叫 AcceptChanges。

此外, 你可以透過 DataRow 的 AcceptChanges() 和 RejectChanges() 方法以決定是否接受或拒絕變更。拿以下程式來做例子, 我把 dr 這個 DataRow 的 RowState 變化和逐步執行的結果標注如下:

dr = dt.NewRow(); // 建立 DataRow 後, dr.RowState 變成 DataRowState.Detached
dr["LastName"] = "Fitzgeralson";
dt.Rows.Add(dr); // 將 DataRow 加入 DataTable, dr.RowState 變成 DataRowState.Added
dr.RejectChanges(); // 拒絕變更, 現在  dr.RowState 變回 DataRowState.Detached
dr["LastName"] = "Fitzgeralson"; // 上面 RejectChanges() 已將 dr 重設, 所以必須重設其值
dt.Rows.Add(dr); // 重新加回 DataRow, dr.RowState 變回 DataRowState.Added
dr.AcceptChanges(); // dr 接受變更, 現在  dr.RowState 變成 DataRowState.Unchanged
dr["LastName"] = "McShane"; // 變更 dr 的值, 現在  dr.RowState 變成 DataRowState.Modified
dr.AcceptChanges(); // dr 接受變更, 現在  dr.RowState 又變成 DataRowState.Unchanged
dr.Delete(); //現在 dr.RowState 變成 DataRowState.Deleted
dr.RejectChanges(); // 拒絕變更, 現在 dr.RowState 變回 DataRowState.Unchanged
dr.Delete(); //再次刪除 dr, 現在 dr.RowState 又變成 DataRowState.Deleted
dr.AcceptChanges(); // 接受變更, 現在 dr.RowState 變回 DataRowState.Detached, 而且內容值被清除

如果你想要更清楚的研究 DataRowState 在不同情況下的變化, 你可以使用上面的程式, 在 VS 中以逐步執行的方式, 透過 Watch 視窗去觀察 dr.RowState 和 dt.Rows.Count 兩個變數值。不過請留意一下, DataRow 的 RejectChanges() 方法在執行過後, 時常會順便把原來的內含資料清掉, 所以當這種情況發生時, 你要記得把值重新填入。

除了可以使用 AcceptChanges() 和 RejectChanges() 方法來達成類似 Transaction 的功能之外, DataRow 其實也可以自動保存同一筆資料的三個版本, 讓你能夠選擇採用哪一個版本。這三種版本分別稱為 Original, Current 和 Proposed 版本。

同一筆資料中可保留三種版本

DataRow 的內容在進行變更時, .NET 會自動幫你保留最多三個版本 (細節可參考 MSDN 文件)。

  1. Original - 如果 DataRow 曾經執行過 AcceptChanges 指令, 那麼它當時的值就會變成 Original 版本。換句話說, 除非執行過 AcceptChanges, 否則 Original 版本中記錄的值並不會被新值取代。不過, 除非這個 DataRow 的 RowState 曾經變成 Modified, Unchanged 或 Deleted 等其中的任何一種, 否則這個 DataRow 根本就不存在什麼 Original 版本。例如, 當 DataRow 剛被建立、被賦予值, 並剛被加入 DataTable 時, 它並沒有 Original 版本。如果你此時企圖去取出 Original 版本的值, 將引發一個 VersionNotFoundException 例外狀況。但是如果你使用 AcceptChanges 接受了此次資料變更, 那麼這個 DataRow 就終於有了一個 Original 版本的值, 也就是剛才被賦予的初值。
  2. Current - DataRow 中目前的值。不管在什麼情況下 (除非這個 DataRow 被刪除), DataRow 都一直有這個版本的值。萬一這個 DataRow 已被刪除, 如果你還企圖去取出 Current 版本的值, 將引發例外。
  3. Proposed - 當你透過 BeginEdit 方法進行 DataRow 的編輯動作時, 你賦予 DataRow 的值。如果你沒有先呼叫 BeginEdit 方法就企圖去取得 Proposed 版本的值, 你會獲得一個 VersionNotFoundException 例外狀況。
  4. Default - DataRowState 的預設版本。如果 DataRow 的 RowState 為 Added 或 Modified 或 Deleted, 那麼預設的版本是 Current。如果 RowState 是 Detached,那麼預設版本是 Proposed。 (Note: MSDN 英文版上面確實是這麼寫的 (中文版則顯然是翻錯了), 但我個人總覺得這段描述似乎有點怪)

要如何取得各版本的值呢? 很簡單, 使用 DataRow["欄位名稱", DataRowVersion.XXX] 就可以了。不過, 有鑒於在許多情況之下取得某一版本的動作會引發例外, 我們應該在取用之前, 使用 DataRow.HasVersion(DataRowVersion) 來判斷該版本是否存在:

if (dr.HasVersion(DataRowVersion.Original))
     lname = dr["LastName", DataRowVersion.Original];
else
     lname = string.Empty;

透過這種方式, 你隨時可以存取 DataRow 中的不同版本 - 尤其是 Original 版本, 你等於隨時都可以取回修改前的原值。

BeginEdit() 和 EndEdit() 方法是成對出現的:

dr.BeginEdit();
dr["FirstName"] = "Brokeleg";
dr["LastName"] = "Armstrong";
dr.EndEdit();

你不需要加上 BeginEdit() .. EndEdit() 也可以直接去修改 DataRow 裡面的值。不過如果你加上 BeginEdit() .. EndEdit() 再來執行修改動件的話, 程式中最多可以為你保留三個版本, 讓你可以視情況決定最後要保留哪個版本。

在 ASP.NET 的使用環境中, 你其實並不會有太多的機會用到 BeginEdit() .. EndEdit(), 因為當每次使用者有任何回應, 包括編輯的資料或是傳回任何指令, 都會引發一個 PostBack 動作; 而在每次 PostBack 之間, 網站應用程式事實上早已將 Connection 開啟又關閉了, 甚至 DataTable 本身也被建立然後又消滅了。換句話說, 我們很難透過這個機制去復原使用者對 DataTable 所做的任何變更, 除非你使用 Session 或類似的物件把 DataTable 保留起來。但是如果你真的這麼做, 那恐怕是一個有點愚蠢的舉動, 因為如果硬要把網頁寫成單機程式, 你還不如一開始就使用 Windows Form 來寫。

要在 ASP.NET 中透過 DataRowVersion 功能來處理不同的版本, 其最好的時機, 就是無需讓使用者決定保留哪一個版本, 而是動態地依據邏輯條件來判斷的時候。例如在 Try.. Catch 迴圈中如果發生錯誤, 可以自動把三個版本的值記錄在 Log 裡面作為存證, 而不是在網頁中做一個「復原變更」按鈕, 企圖讓使用者自己決定是否要復原變更。

如果你一定要提供這種功能, 你可以採用其它方法 (在 .NET 中至少可以找到 20 個替代方案)。例如把 DataRow 暫時存放在 Session 物件裡, 如果使用者真的要復原變更的話, 就先從 Session 裡還原這個 DataRow, 再使用 DataTable.ImportRow(DataRow) 的方法匯入這筆資料。不過要記得網站永遠有無法預期的情形 (包括電力中斷、連線中斷、當機、使用者隔了兩天才回來處理這一頁等等); 如果我有選擇的話, 我根本不會讓使用者有機會在 Commit 變更之後還想復原變更。

DataTable 的行與不行

如同我在本文一開就提到的, DataTable 既然是離線式資料處理的核心工具, 而且其本質就像是個以記憶體為儲存空間的超小型資料庫 (也超快), 所以你應該認清楚它的本質, 才不會誤用它的能力。DataTable (其實附著其上的 DataSet 也是一樣) 可以單獨存在, 也可以直接讀取及寫入資料庫中的資料 (這也是它之所以能夠比 .NET 裡面的其它資料容器更強大的原因), 但是它完全依附於伺服器上的 (揮發性) 記憶體, 這就是它最重大的缺陷。

當你的網站伺服器的同時上線人數到達幾千人的規模時, 我想你絕對會慎重考慮是否應該繼續使用 DataTable。其實, 如果你真的拿 DataTable 來讀入一個龐大資料庫的話, 或許僅區區五個同時上線的使用者, 就會讓你感受到網頁被拖慢了, 還用不了幾千人。

那麼, 到底在什麼地方才應該使用方便好用的 DataTable 呢? 如果是我的話, 我可能會先把網站應用程式區分成 Performance-Critical 和 Performance-Non-Critical 兩塊。對廣大使用者公開的部份當然算是 Critical (愈多人存取的網頁愈是 Critical); 內部人員才能存取的部份 (例如 Management Console) 則算是 Non-Critical。只有 Non-Critical 的區塊才允許採用耗費資料的工具, 就像是 DataSet/DataTable/LINQ 等 (當然還有個重要的前題, 就是這些工具必須有它的優點, 例如容易開發、功能強大等等)。

如果不能使用 DataTable/DataSet, 要使用什麼呢? 我個人在大部份情況下都使用 DataReader 讀取分頁過的資料, 而且使用 Connection Pool。我也一定使用資料庫的 Transaction 機制而非 ASP.NET 所提供的。如此, 我的資料讀取總能夠很快的讀到要求的少量資料後, 再很快速的結束連線, 而且可靠性也夠。唯有在僅供內部使用的 Management Console 中負荷較輕、較不重要的程式裡, 或在 Windows 應用程式中, 我才有可能使用 DataSet/DataTable。

Technorati 的標籤:,,,,

沒有留言:

張貼留言