應用程式的分層設計 (1) - 入門範例

這篇文章提供了一個簡單的入門範例,以展示分層設計的一種實作方式,以及幾個常見的基礎概念,如領域物件、資料存取物件等。有句老話還是要說一下:這裡示範的作法不見得很恰當,總是拋磚引玉;如果您願意分享自己的想法和經驗,都非常歡迎在此留言。

如何分層

關於為什麼要分層(layers),以及如何切分層次,已經有很多現成的參考資料(例如這篇文章),這裡就不多談。直接以範例程式來說明吧。

先把專案結構建立起來:開啟 Visual Studio 2010 或 2012,建立一個新 solution,命名為 LayeredAppDemo1。

接著在此 solution 中建立四個專案:
  • NorthwindApp.ConsoleClient:Console Application 專案,角色屬於展現層(presentation layer)。
  • NorthwindApp.Service:Class Library 專案。服務層,或應用程式層。撰寫應用程式邏輯的地方。這應該是薄薄的一層,不包含商業邏輯,而只是利用下一層的領域物件來提供展現層所需的服務。
  • NorthwindApp.Domain :Class Library 專案。領域層或商業邏輯層,撰寫領域物件或商業物件的地方。Dino Esposito 在《Architecting Microsoft .NET Solutions for the Enterprise》一書中這麼說:「Most of the time, a business object (BO) is just the implementation of a domain entity.」
  • NorthwindApp.DataAccess:Class Library 專案。資料存取層

我打算使用 Northwind 資料庫,所以專案的名稱就以 NorthwindApp.* 來命名。底下是 Visual Studio 2012 的 Solution Explorer 截圖:



每個專案代表應用程式中的某一層,負責提供特定服務。各層的相依關係如下圖所示:



採用此切割方式的主要原則:上層用戶端會存取(依賴)下層元件,但原則上不逆向參考。比如說,最上層的用戶端應用程式不直接使用資料存取層,而只會使用領域層(*.Domain.dll)和服務層(*.Service.dll);服務層和領域層也不會直接使用底層的資料存取元件,如 System.Data.dll。

當然,這樣切分難免有爭議:服務層不是應該使用領域層,然後領域層才會使用資料存取層嗎?怎麼服務層對資料存取層的依賴程度(相依箭頭的線段粗細)大於對領域層的依賴程度?

嗯,由於這裡的範例比較簡化,未涉及商業邏輯的部分,所以目前是打算將領域物件設計成比較接近單純的 DTO(Data Transfer Objects),僅作為資料存取層和服務層之間傳遞物件之用。

由上圖亦可看出,這裡有用到 Enterprise Library。我會用 EL 的 DAAB 來處理關聯式資料與領域物件之間的對應。這也意味著資料存取層會跟 Enterprise Library 緊密相依,無法任意抽換成別的 ORM 或資料存取框架(這是另一個缺點,如果你在設計時傾向什麼東西都要可以任意抽換的話)。

領域層

前面提過,這裡僅包含單純的 domain entities,性質如同 DTO。基本上,就是一個類別對應至關聯式資料庫的一個資料表。

這些類別,我們可以自己手工刻,也可以用工具來節省時間。例如 Code Project 網站上有個 Simple C# Class Generator,此工具會先讓你指定欲連接的資料庫,選取資料表,然後按個鈕就可以產生個資料表所對應的類別定義。由此工具產生的類別,除了屬性的宣告,還包含了空的(尚未提供實作的)資料存取方法,包括 Load、LoadAll、Insert、Update、Delete 等。我把這些資料存取方法都刪了,因為我打算由 Northwind.App.DataAccess 組件來提供這些資料存取方法。

類別的命名可能也需要改一下,因為資料表可能是以複數來命名,例如 Customers、Categories 等,其對應的 entity 類別應採用單數來命名比較適合。底下是 Order 類別的範例:

namespace NorthwindApp.Domain
{  
  [Serializable]
  public class Order
  {
    public  Int32 OrderID { get; set; }
    public  String CustomerID { get; set; }
    public  Int32 EmployeeID { get; set; }
    public  DateTime OrderDate { get; set; }
    public  DateTime RequiredDate { get; set; }
    public  DateTime ShippedDate { get; set; }
    public  Int32 ShipVia { get; set; }
    public  Decimal Freight { get; set; }
    public  String ShipName { get; set; }
    public  String ShipAddress { get; set; }
    public  String ShipCity { get; set; }
    public  String ShipRegion { get; set; }
    public  String ShipPostalCode { get; set; }
    public  String ShipCountry { get; set; }
  }
}

資料存取層

這裡的資料存取層會負責存取關聯式資料庫,並將資料轉換至對應的領域物件,以供服務層使用。

常見的設計模式有 Table Module(類似 DataSet)、Active RecordRepository 等。這裡是採用 Repository 模式。每個領域物件有各自的 reporitory,例如 CustomerRepository、OrderRepository 等。以 Order 為例,先定義其介面,即 IOrderRepository:

using NorthwindApp.Domain;

namespace NorthwindApp.DataAccess
{
    public interface IOrderRepository
    {
        Order GetByID(int id);
        IEnumerable<Order> GetAll();
        void Insert();
        void Delete();
        void Update();
        void Save();
    }
}

然後是實作該介面的類別 OrderRepository:

using System.Data;
using Microsoft.Practices.EnterpriseLibrary.Common.Configuration;
using Microsoft.Practices.EnterpriseLibrary.Data;

namespace NorthwindApp.DataAccess
{
    public class OrderRepository : IOrderRepository
    {
        private Database database;

        public OrderRepository()
        {
            // Use default connection string defined in app config.
            database = EnterpriseLibraryContainer.Current.GetInstance<Database>();
        }

        public Domain.Order GetByID(int id)
        {
            string sql = String.Format("select * from Orders where OrderID={0}", id);
            IDataReader rdr = database.ExecuteReader(CommandType.Text, sql);
            if (rdr.Read())
            {
                IRowMapper<Domain.Order> mapper = MapBuilder<Domain.Order>.BuildAllProperties();
                Domain.Order order = mapper.MapRow(rdr);
                return order;
            }
            return null;
        }

        public IEnumerable<Domain.Order> GetAll()
        {
            throw new NotImplementedException();
        }

        public void Insert()
        {
            throw new NotImplementedException();
        }

        public void Delete()
        {
            throw new NotImplementedException();
        }

        public void Update()
        {
            throw new NotImplementedException();
        }

        public void Save()
        {
            throw new NotImplementedException();
        }
    }
}

在處理資料關聯模型與領域物件模型之間的對應時,這裡不是使用現成的重量級 ORM,而是採取比輕盈的作法,亦即使用 Enterprise Library 的 DAAB 來處理資料對應。這個部分亦可參考先前的文章:Data Access Application Block 入門

再繼續看服務層之前,稍微補充一下 Active Record 和 Repository 模式。

Active Record

Martin Fowler 對 Active Record 的定義如下:
An object that wraps a row in a database table or view, encapsulates the data access, and adds domain logic on that data. 
意思是說,一個 Active Record 物件即是一個封裝了一筆資料的物件,而且還提供了資料存取和領域邏輯的操作。以客戶資料為例,我們可能會設計一個名為 Customer 的 Active Record 類別,裡面包含對應至客戶資料表的各個欄位,例如 ID、Name、Address 等等,另外再加上一些增刪改查等方法,以及商業邏輯的操作,例如:Insert、Delete、Update、GetAll、CalcSalary、GetOrders 等等。

Active Record 的一個缺點是在實作時把商業邏輯和資料存取的程式碼全都放在同一個類別裡。即使另外設計了一層領域物件,也很容易產生商業邏輯散在各層的情形。

另一個缺點是,Active Record 不利於單元測試。為什麼呢?因為領域物件或服務層會直接使用 Active Record 物件來操作資料和處理一些商業邏輯,它和資料庫是緊密綁在一起的,以至於不容易在抽掉資料庫的情況下直接對領域物件做單元測試。

Repository

還是先看 Fowler 對 Repository 模式的定義
Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.
相較於 Active Record,Repository 模式是把資料欄位和資料存取的操作分開。Repository 物件只提供較高層次的資料存取操作,成為領域物件和資料存取層之間的一個接縫(seam)。

如果應用程式的需求大多只涉及簡單的資料存取,而沒有複雜商業邏輯的話,Active Record 是很合理的選擇。相反地,包含複雜商業邏輯的應用程式則可以考慮 Repository 模式。

Repository 與 DAO

有此一說:「到了 Domain-Driven Design 的世界,人們不再說 DAO,而改稱之為 Repository。」嗯,這兩個東西是蠻像的,差異如下:

  • DAO 所處理的物件在概念上比較接近二維式表格(例如 ADO.NET 中的 DataSet、DataTable 等物件),也就是比較偏向關聯式資料庫的結構。Repository 則是以物件集合的方式來處理資料,概念上比較偏向領域模型。
  • 跟 DAO 比起來,依 Repository 模式所設計出來的介面會輕盈些。除了 Get、Find 等用來查詢資料的方法,Repository 也可以提供 Insert、Delete、Update 等方法,同時搭配 Unit of Work 模式來管理交易。

Repository 也可以和 DAO 搭配運用,比如說,在 Repository 的實作類別中使用 DAO 來存取底層資料庫。

呼~模式很多,術語也不少。沒能夠講得很清楚的部分,也許將來回頭潤飾,也許在續集中補充吧。

接下來是相對簡單的服務層。

服務層

服務層的物件只是單純使用 repository 物件提供用戶端所需的服務。底下是 OrderService 類別的實作:

using NorthwindApp.Domain;
using NorthwindApp.DataAccess;

namespace NorthwindApp.Service
{
    public class OrderService
    {
        private IOrderRepository orderRepository;

        public OrderService()
        {
            orderRepository = new OrderRepository();
        }

        public Order GetByID(int id)
        {
            return orderRepository.GetByID(id);
        }

        public IEnumerable<Order> GetAll()
        {
            throw new NotImplementedException();
        }
    }
}

這裡就沒有另外先定義 IOrderService 介面了。實務上,當然還是建議針對介面來寫程式,會比較有彈性。若一開始沒有定義介面倒也無妨,等程式規模發展到一個程度,發現有必要將介面抽離出來時再重構程式碼也行。(謎之音:你這個人好隨便啊!)

展現層

展現層不是本文的重點,所以只用一個簡單的 Console 應用程式來充當用戶端。程式碼如下:

using NorthwindApp.Domain;
using NorthwindApp.Service;

namespace NorthwindApp.ConsoleClient
{
    class Program
    {
        static void Main(string[] args)
        {
            OrderService orderService = new OrderService();
            Order order = orderService.GetByID(10248);
            Console.WriteLine("Customer: " + order.CustomerID);
            Console.WriteLine("ShipName: " + order.ShipName);
            Console.WriteLine("OrderDat: " + order.OrderDate.ToString());
           
        }
    }
}

有沒有一種感覺:似乎看不出這樣分那麼多層有什麼好處或必要性,不如在展現層直接使用資料存取層來得爽快直接。呃...這大概是範例過度簡化所造成的。實際應用程式的需求往往更複雜,規模也比較龐大,此時分層設計就比較能看出其優點,例如單一責任、分工明確、降低耦合、利於撰寫單元測試....等等。

小結

作為一個入門範例,簡單起見,這裡沒用到太多設計模式或比較進階的語法,例如抽象工廠、相依性注入、泛型等等,主要就是傳達一些分層設計的基本觀念,並提供簡易快速的實作範例。未來有許多值得進一步探討和改進的地方,例如前面提過的分層的方式、Repository 可以採用泛型來設計(網路上可找到不少範例)或嘗試改用 Active Record 模式(應該不會吧)、ORM 的部分可以嘗試改用 Entity Framework 等等。

續集:資料應用程式的分層設計 (2)

5 則留言:

  1. 謝謝分享,期待您的分層設計系列

    回覆刪除
  2. 作者已經移除這則留言。

    回覆刪除
  3. 「如果應用程式的需求大多只涉及簡單的資料存取,而沒有複雜商業邏輯的話,Active Record 是很合理的選擇
    相反地,包含複雜商業邏輯的應用程式則可以考慮 Repository 模式。」

    第二行不是很懂,不是說
    「Repository 物件只提供 "較高層次" 的資料存取操作」不是意味著Repository物件的責任應該比較單純嗎

    為什麼還是在 "包含複雜商業邏輯" 的情況下適用的呢?

    回覆刪除
  4. 因為有了 Repository 當作資料存取和商業邏輯的接縫,就不用把兩者混在一起寫,有助於切分責任。但若是簡單的邏輯,就可以考慮省略 Repository 這一層。

    回覆刪除

技術提供:Blogger.
回頂端⬆️