*
   原稿 (英文)

撰寫可移植的資料存取層

作者:Silvano Coriani
Microsoft Corporation

2004 年 4 月

適用於:
   Microsoft®Visual Studio®.NET 2003
   Microsoft®.NET Framework 1.1
   ADO.NET
   各種 RDBMS

摘要:了解如何撰寫智慧型應用程式,以與不同的資料來源 (從 Microsoft Access 到 SQL Server 到 Oracle RDBMS) 毫無障礙地運作。

(請注意,本文具有連至英文網站的連結)

(列印共 15 頁)

目錄

簡介
採用通用資料存取方法
使用基本介面
撰寫專門化的資料存取層
從其他階層使用資料存取類別
一些可能的改進
結論

簡介

從過去六年來的諮詢經驗,有關資料存取和操作的問題,我已經聽過太多次,因而開始著魔:「我要怎麼寫應用程式,讓它只需要一點點或根本不用變更就可以同時用於資料庫伺服器 x、y 和 z 呢?」了解資料存取層仍然是現代應用程式最重要的一部份,而且通常是沒什麼經驗的開發人員的第一大敵,我第一個反應向來是:沒辦法!

大家嚇壞的表情還有「那麼 Microsoft 利用 ADO 提議的通用資料存取方法呢?」的疑問,提醒我提供這個問題更詳細的解釋,以及一個解決方案。

問題在於當您的應用程式是小型的原型,或有幾個同時使用者及簡單的資料存取邏輯時一切都安然沒事,即使是您選擇最簡單的方法:使用 RAD 工具,像是 Microsoft®Visual Basic®6.0 中的資料環境或一些如 ActiveX®Data Control 的「多合一」整合式解決方案,及其他協力廠商元件,而這通常會隱藏應用程式和特定資料來源間互動的複雜性。但隨著使用者數量的增長,而同步存取逐漸成為問題時,很可能光因動態資料錄集、伺服器端指標和不必要的鎖定原則這些基本的使用就發生了許多效能問題。為了滿足使用者的目標而需對系統進行的設計和程式碼變更,將因為您剛開始的時候並沒有將此問題列入考慮而使您付出更多代價。

採用通用資料存取方法

當 ADO 進入 MDAC (Microsoft Data Access Component 2.1 版) 的成熟期時,Microsoft 推出了「通用資料存取」活動。這個構想是要向開發人員展示單純利用一個簡單的物件模型 (ConnectionCommandRecordset),他們就可以撰寫一個能與各種不同的資料來源相連接的應用程式,包括關聯性和非關聯性的形式。當時的說明文件 — 以及絕大多數的文章和範例 — 都忘了說明即使是使用相同的資料存取技術,不同的資料來源彼此的程式設計性和特性是大不相同。

最後的結果是如果應用程式需要數個來源的資料,最簡單的方法是使用所有資料來源所提供的功能「共同要素」,不過這就失去了使用可提供最佳化方法存取和操作不同 RDBMS 內的資訊的資料來源特定選項所帶來的效益。

常讓我對此方法質疑的是,經過我與客戶一番更詳細的分析之後,我們通常同意應用程式中與資料來源互動的部份,在與其餘的展示和商業邏輯比較起來,相當地小。藉由做好模組化設計,就有可能可以將 RDBMS 特定的程式碼隔離到一些可輕鬆互換的模組內,藉此避免「大小通吃」的方法存取我們的資料。我們可以改用相當特定的資料存取程式碼 (使用預存程序、指令批次和其他功能,視資料來源而定),而不需要碰到絕大部份其他應用程式程式碼。這有助於提醒正確的設計是撰寫可移植及有效程式碼的關鍵。

ADO.NET 將一些重要的變更帶入了資料存取的編碼競技場,就像專門化 .NET 資料提供者的概念一樣。使用特定的提供者,您就有最佳化的方法可到達資料來源,規避介入資料存取程式碼和資料庫伺服器之間一連串 OLE 和 ODBC 層「非常」豐富但有時候不必要的軟體介面和服務。然而,每個資料來源還是有不同的特性和功能、有不同的 SQL 對話,而且若要撰寫有效的應用程式,您還是必須使用這些特定的特性來取代「共同要素」。從可移植的觀點看來,Managed 和 Unmanaged 資料存取技術仍相當類似。

除「運用資料來源的獨特特性」之外,撰寫良好的資料存取層所需的其他規則通常跟撰寫每個資料來源沒什麼兩樣:

  • 盡可能使用連線集區機制。
  • 處理資料庫伺服器的有限資源。
  • 注意網路往返。
  • 適當的時候,提倡執行計劃的重複使用並避免重複編譯。
  • 採用適當的鎖定模型來管理並行存取。

根據我個人使用模組化設計方法的經驗,完整應用程式中專門用於處理特定資料來源的程式碼量不會超過總數的 10%。很明顯地,這比變更設定檔中的連接字串還要複雜,不過我認為您會發現這是增進效能可以接受的妥協。

使用基本介面

我們在這裡的目標是使用抽象,以及將特定於某資料來源的程式碼封裝到類別層中,以便讓應用程式其餘的部份與後端的資料庫伺服器相互獨立或分開。

.NET Framework 的物件導向特性會在此過程中提供協助,讓我們有機會可以選擇要使用的抽象層。其中一個選項是使用每個 .NET 資料提供者必須實作的基本介面 (IDbConnectionIDbCommandIDataReader 等等)。另一個選項是建立一組類別 — 資料存取層 — 來管理應用程式的所有資料存取邏輯 (例如,使用 CRUD 模型)。我們將分析這兩種可能的選項,從簡單的訂單輸入應用程式開始,並以 Northwind 資料庫為基礎插入和擷取不同的資料來源中的資訊。

資料提供者基本介面會識別應用程式與資料來源互動所需的傳統行為:

  • 定義一個連接字串。
  • 開啟和關閉資料來源的實體連線。
  • 定義命令及相關參數。
  • 執行您可建立的不同命令類型。
    • 傳回一組資料。
    • 傳回數值類值。
    • 不傳回任何東西便在資料上執行動作。
  • 對已傳回的資料集提供順向 (Forward-only) 及唯讀存取。
  • 定義一組操作以保持資料集與資料來源內容 (資料配接器) 的同步。

不過這也就是說,如果我們將在不同的資料來源 (使用不同的資料提供者) 中擷取、插入、更新和刪除資料所需的不同操作,封裝在我們的資料存取層中,而且只公開基本介面的成員,就可以到達第一個抽象層 — 至少從資料提供者的觀點來看。讓我們看看一些闡明此構想的程式碼:

using System;
using System.Data;
using System.Data.Common;
using System.Data.SqlClient;
using System.Data.OleDb;
using System.Data.OracleClient;


namespace DAL
{
   public enum DatabaseType
   {
      Access,
      SQLServer,
      Oracle
      // any other data source type
   }

   public enum ParameterType
   {
      Integer,
      Char,
      VarChar
      // define a common parameter type set
   }

   public class DataFactory
   {
      private DataFactory(){}

      public static IDbConnection CreateConnection
         (string ConnectionString, 
         DatabaseType dbtype)
      {
         IDbConnection cnn;

         switch(dbtype)
         {
            case DatabaseType.Access:
               cnn = new OleDbConnection
                  (ConnectionString); 
               break;
            case DatabaseType.SQLServer:
               cnn = new SqlConnection
                  (ConnectionString); 
               break;
            case DatabaseType.Oracle:
               cnn = new OracleConnection
                  (ConnectionString);
               break;
            default:
               cnn = new SqlConnection
                  (ConnectionString); 
               break;               
         }

         return cnn;
      }


      public static IDbCommand CreateCommand
         (string CommandText, DatabaseType dbtype,
         IDbConnection cnn)
      {
         IDbCommand cmd;
         switch(dbtype)
         {
            case DatabaseType.Access:
               cmd = new OleDbCommand
                  (CommandText,
                  (OleDbConnection)cnn);
               break;

            case DatabaseType.SQLServer:
               cmd = new SqlCommand
                  (CommandText,
                  (SqlConnection)cnn); 
               break;

            case DatabaseType.Oracle:
               cmd = new OracleCommand
                  (CommandText,
                  (OracleConnection)cnn);
               break;
            default:
               cmd = new SqlCommand
                  (CommandText,
                  (SqlConnection)cnn); 
               break;
         }

         return cmd;
      }


      public static DbDataAdapter CreateAdapter
         (IDbCommand cmd, DatabaseType dbtype)
      {
         DbDataAdapter da;
         switch(dbtype)
         {
            case DatabaseType.Access:
               da = new OleDbDataAdapter
                  ((OleDbCommand)cmd); 
               break;

            case DatabaseType.SQLServer:
               da = new SqlDataAdapter
                  ((SqlCommand)cmd); 
               break;

            case DatabaseType.Oracle:
               da = new OracleDataAdapter
                  ((OracleCommand)cmd); 
               break;

            default:
               da = new SqlDataAdapter
                  ((SqlCommand)cmd); 
               break;
         }

         return da;
      }
   }
}

此類別的重點是要從應用程式的上層隱藏有關建立特定資料提供者某類別的執行個體細節,應用程式現在就可以使用透過基本介面公開的泛用行為與資料來源進行互動。

讓我們看看從應用程式的其餘部份使用此類別:

using System;
using System.Data;
using System.Data.Common;
using System.Configuration;    

namespace DAL
{
   public class CustomersData
   {
      public DataTable GetCustomers()
      {
         string ConnectionString = 
            ConfigurationSettings.AppSettings
            ["ConnectionString"];
         DatabaseType dbtype = 
            (DatabaseType)Enum.Parse
            (typeof(DatabaseType),
            ConfigurationSettings.AppSettings
            ["DatabaseType"]);

         IDbConnection cnn = 
            DataFactory.CreateConnection
            (ConnectionString,dbtype);

         string cmdString = "SELECT CustomerID" +
            ",CompanyName,ContactName FROM Customers";

         IDbCommand cmd = 
            DataFactory.CreateCommand(
            cmdString, dbtype,cnn);

            DbDataAdapter da = 
               DataFactory.CreateAdapter(cmd,dbtype); 

         DataTable dt = new DataTable("Customers");

         da.Fill(dt);

         return dt;
      }
        
      public CustomersDS GetCustomerOrders(string CustomerID)
      {
         // TBD
         return null;
      }
      public CustomersList GetCustomersByCountry
         (string CountryCode)
      {
         // TBD
         return null;
      }
      public bool InsertCustomer()
      {
         // TBD 
         return false;
      }
   }
}

在我們 CustomerData 類別的 GetCustomers() 方法中,我們可以看出如何透過讀取設定檔的資訊,而使得使用 DataFactory 類別以特定的連接字串來建立 XxxConnection 執行個體成為可能,並且在基礎資料來源上不需要特定相依性來撰寫其餘的程式碼。

與我們的資料層互動的商業層類別範例,看起來應該像:

using System;
using System.Data; 
using DAL;

namespace BLL
{
    public class Customers
    {
        public DataTable GetAllCustomers()
        {
            CustomersData cd = new CustomersData();  
            DataTable dt = cd.GetCustomers();
            return dt;
        }
        public DataSet GetCustomerOrders()
        {
            // TBD
            return null;
        }
    }
}

那麼這個方法有什麼不妥?此處的問題在於只有一個重要的細節將程式碼連接到特定資料來源:即命令字串的 SQL 語法!事實上,以這種方式來寫應用程式,唯一能夠讓它變成可移植的方式是採用任何資料來源能解譯的基本 SQL 語法,然而這會失去從特定資料來源的特定功能獲益的機會。如果您的應用程式只需要在資料上進行非常簡單及標準的操作,而且您不希望使用特定資料來源中的進階功能 (例如,XML 支援),可能只會出點小問題。不過這個方法可能會因您無法使用每個資料來源的最佳化功能而經常造成效能低落。

撰寫專門化的資料存取層

因此,光使用基本介面並不足以從不同的資料來源提供可接受的抽象層。在這種情況下,增加此抽象層的障礙可能一個不錯的解決方案,建立一組類別 (例如,CustomerOrder 等等) 來封裝特定資料提供者的使用,並透過與特定資料來源無關的資料結構與其他應用程式層交換資訊;型別化 DataSet、物件集合等等。

此層專門化的類別可在特定組件中建立,每個支援的資料來源各一個,並可遵循設定檔中的指示依要求從應用程式載入。這樣一來,若您想要新增全新的資料來源到應用程式時,唯一要做只是實作一組新的類別,尊重在通用介面集中定義的「合約」。

讓我們看看實際的例子:如果我們想要同時支援 Microsoft®SQL Server™ 和 Microsoft®Access 作為資料來源,要在 Microsoft®Visual Studio®.NET 中建立兩個不同的專案,每個資料來源各一個。

用於 SQL Server 的專案看起來像:

using System;
using System.Data;
using System.Data.Common;
using System.Data.SqlClient;  
using System.Configuration;    
using Common;

namespace DAL
{
   public class CustomersData : IDbCustomers
   {
      public DataTable GetCustomers()
      {
         string ConnectionString = 
            ConfigurationSettings.AppSettings
            ["ConnectionString"];

         using (SqlConnection cnn = new SqlConnection
                  (ConnectionString))
         {
            string cmdString = "SELECT CustomerID," +
               "CompanyName,ContactName " +
               "FROM Customers";
            SqlCommand cmd = 
               new SqlCommand (cmdString, cnn);

            SqlDataAdapter da = new SqlDataAdapter(cmd); 

            DataTable dt = new DataTable("Customers");

            da.Fill(dt); 

            return dt;
         }
      }
      public DataTable GetCustomerOrders(string CustomerID)
      {
         // TBD
         return null;
      }
      public DataTable GetCustomersByCountry
         (string CountryCode)
      {
         // TBD
         return null;
      }
      public bool InsertCustomer()
      {
         // TBD
         return false;
      }
   }
}

用於擷取 Microsoft®Access 資料的程式碼看起來像:

using System;
using System.Data;
using System.Data.Common;
using System.Data.OleDb;  
using System.Configuration;    
using Common;

namespace DAL
{
   public class CustomersData : IDbCustomers
   {
      public DataTable GetCustomers()
      {
         string ConnectionString = 
            ConfigurationSettings.AppSettings
            ["ConnectionString"];

         using (OleDbConnection cnn = new OleDbConnection
                  (ConnectionString))
         {
            string cmdString = "SELECT CustomerID," +
               "CompanyName,ContactName " +
               "FROM Customers";

            OleDbCommand cmd = 
               new OleDbCommand (cmdString, cnn);

            OleDbDataAdapter da = new 
               OleDbDataAdapter(cmd); 

            DataTable dt = new DataTable("Customers");

            da.Fill(dt); 

            return dt;
         }
      }
      public DataTable GetCustomerOrders(string CustomerID)
      {
         // TBD
         return null;
      }
      public DataTable GetCustomersByCountry
         (string CountryCode)
      {
         // TBD
         return null;
      }
      public bool InsertCustomer()
      {
         // TBD
         return false;
      }
   }
}

CustomersData 類別會實作 IdbCustomers 介面。當我們需要支援新資料來源時,只需要建立實作此介面的新類別就可以了。

此類型的介面看起來像:

using System;
using System.Data; 

namespace Common
{
    public interface IDbCustomers
    {
        DataTable GetCustomers();
        DataTable GetCustomerOrders(string CustomerID);
        DataTable GetCustomersByCountry(string CountryCode);
        bool InsertCustomer();
    }
}

我可以建立力私用或共用組件來封裝這些資料存取類別;在第一個案例中,組件載入器會搜尋我們在 AppBase 資料夾內的設定檔中所指定的類別,或使用傳統探測規則在子目錄中搜尋。如果我們必須與其他應用程式共用這些類別,可以將組件放到全域組件快取中。

從其他階層使用資料存取類別

這兩個幾乎一模一樣的 CustomersData 類別是包含在應用程式的其餘部份將使用的兩個不同的組件中。透過以下設定檔,我們現在可以指定要載入哪個組件,以及要對準哪個資料來源目標。

可能的設定檔範例看起來應該像:

<?xml version="1.0" encoding="utf-8" ?>
    <configuration>
    <appSettings>
        <add key="ConnectionString" 
            value="Server=(local);Database=Northwind;
            User ID=UserDemo;Pwd=UserDemo" />
        <add key="DALAssembly" value="DALAccess, 
                  version=1.0.0.0, PublicKeyToken=F5CD5666253D6082" />
<!--   <add key="ConnectionString" 
            value="Provider=Microsoft.Jet.OLEDB.4.0; 
            Data Source=..\..\..\Northwind.mdb" />
-->                
    </appSettings>
</configuration>

我們必須在此檔案內指定兩種資訊。第一種是正式連接字串;以便有機會變更,例如伺服器的名稱或一些其他連線參數。第二種是組件的完整名稱,該組件是應用程式的上層會動態載入以找出要用於特定資料來源的類別:

讓我們也看看這部份的程式碼:

using System;
using System.Data; 
using System.Configuration;
using System.Reflection;
using Common;

namespace BLL
{
   public class Customers
   {
      public DataTable GetAllCustomers()
      {
         string AssemblyName = 
            ConfigurationSettings.AppSettings
            ["DALAssembly"];
         string TypeName = "DAL.CustomersData";

         IDbCustomers cd = 
//            (IDbCustomers)= 
            Assembly.Load(AssemblyName).
            CreateInstance(mytype); 

         DataTable dt = cd.GetCustomers();
         return dt;
      }
      public DataSet GetCustomerOrders()
      {
         // TBD
         return null;
      }
   }
}

如您所見,該組件會使用從設定檔讀取的名稱載入,並建立和使用 CustomersData 類別的執行個體。

一些可能的改進

若要查看我所建議的方法的圖例說明,請參閱 NET Pet Shop v3.0 範例應用程式。我建議您下載該範例並深入探勘一番 — 當中不僅針對可移植性問題,還有其他有趣的部份,像是快取和效能最佳化。

在設計可移植應用程式的資料存取層過程中一個要注意的地方,是如何將資訊在其他層之間來回傳遞。在我的例子當中,我僅僅使用泛用的 DataTable 執行個體,在生產環境的案例下,您可以需要根據必須呈現的資料種類 (是否要處理階層等) 而考慮不同的解決方案。我不想在這裡重造,所以建議參考一下《Designing Data Tier Components and Passing Data Through Tiers》指南,當中詳細說明了不同的案例和建議解決方案的各項優點。

如我在簡介中所說的,您的目標資料來源所公開的特定功能 — 以及全體資料存取 — 應該在設計階段就列入考量。這應該涵蓋如預存程序、XML 序列化等等的事情。關於 Microsoft®SQL Server™2000,您可以在《.NET Data Access Architecture Guide》中找到如何充分運用這些功能的討論,強烈建議您閱讀。

我一向收到許多有關資料存取應用程式區,以及它與在本文中說明的引數如何相關的要求。這些 .NET 類別就像 SQL Server .NET 資料提供者的抽象層,而且可讓您撰寫更優雅的程式碼與資料庫伺服器互動。這是個您可以怎麼做的構想:

DataSet ds = SqlHelper.ExecuteDataset( 
      connectionString,
      CommandType.StoredProcedure,
      "getProductsByCategory",
      new SqlParameter("@CategoryID", categoryID));

此方法還有另一種推測,提供於開放原始 Data Access Block 3.0 (Abstract Factory Implementation) 範例中,您可在 GotDotNet 上找到。這個版本會實作相同的抽象原廠模式,而且讓您根據可用的 .NET 資料提供者使用不同的資料來源。

結論

您現在應該可以根據特定資料來源的選擇,建置不需要修改的商業邏輯類別,並讓您利用特定資料來源的獨特功能以獲得更完善的結果。不過這是有代價的;我們必須實作多組類別來封裝特定資料來源的低階操作,以及針對每個特定資料來源 (預存程序、函式等) 建置而成的所有可程式設計的物件。然而,如果您想要有效能及可移植性,這就是您必須付出的代價。根據我的實際經驗,這是值得的!