應用程式的分層設計 (2) - 一點改進

延續上集,這次要繼續修改第一版的很粗略的範例程式,再加一點東西進去。

概觀

先看一下整個範例的專案結構。這次的版本比上一版多了一個商業邏輯層,總共五個組件:
  • NorthwindApp.ConsoleClient:Console Application 專案,角色屬於展現層(presentation layer)。
  • NorthwindApp.Service:Class Library 專案。服務層,或應用程式層。撰寫應用程式邏輯的地方。這應該是薄薄的一層,不包含商業邏輯,而只是利用下一層的領域物件來提供展現層所需的服務。
  • NorthwindApp.BusinessLayer:Class Library 專案。商業邏輯層
  • NorthwindApp.Domain :Class Library 專案。領域層,用來放領域模型。上一版是將商業邏輯也放在這裡,這次進一步切開了。
  • NorthwindApp.DataAccess:Class Library 專案。資料存取層。這次底層還是使用 Enterprise Library 的 DAAB 來存取資料庫。

在 Solution Explorer 裡面看起來像這樣:


這次新增加的 NorthwindApp.BusinessLogic 組件中只有一個類別:OrderManager,它的責任是處理與訂單有關的商業邏輯。這裡我採用 *Manager  的方式來命名商業邏輯管理員(business managers),所以未來可能會有一堆 managers,例如 ProductManager、CustomerManager。

底下這片白板顯示了各層模組之間的關係,以及類別之間的互動:


其中紅色的文字是各組件中的類別(除了 UI 層的 Main 方法)。注意右邊的 Domain Model 在左邊四層當中都有用到。

用 Visual Studio 產生的組件相依圖可能會更清楚些:



從相依關係圖可以明顯看出 NorthwindApp.Domain 是由所有組件共用,也就是說,從資料存取層開始就會用到其中定義的 entity 類別,並且把它們當作 DTO,一路傳遞至商業邏輯層、應用程式服務層、以及展現層(也會由上層往下層傳遞)。接著進一步解釋為什麼要這樣設計。

Note:服務層(或者說 Application Layer)不是絕對必要;如果實際的需求並不那麼複雜,架構沒那麼大,也可以讓 UI 層直接使用商業邏輯層。

領域層

這次的 NorthwindApp.Domain 組件中只單純放領域類別(domain classes),其作用等同於 DTO(Data Transfer Objects),主要是用來傳遞資料(entities)。請注意:當文中提到「domain class」和「entity class」時,它們指的是同樣東西。domain model 在這裡也是跟 entity model 同樣意思。嗯,這的確不是那麼「領域驅動」,至少現在還不是。
Note: 如果你對領域類別的解讀是偏向包含商業邏輯的 business class,可能就會覺得 NorthwindApp.Domain 的設計有點怪,應該要把它跟 BusinessLogic 擺在一起才對。我想這是對 "domain" 一詞的觀點不同所致。如果有這種情形,請暫時記住它們只是 DTO 或 entity class 而已。

問題:為什麼要將 entity classes 拆出來放在單獨的組件呢?

反過來問,如果不這樣的話,那些 entity classes 要放在哪一層?

如果將 entity classes 放在 BLL(商業邏輯層),那麼你的 DAL(資料存取層)就必須參考 BLL 組件。可是由於 BLL 必定得參考 DAL 組件來存取資料,這就有了組件循環參考的問題--Visual Studio 不會讓你這麼做。

如果將 entity classes 放在 DAL 呢?組件循環參考的問題是解決了,可是你的 UI 層卻得直接參考 DAL 組件,這又不大好了。如果朝這條路繼續想辦法,大概會得出一個結論:我們必須在 UI 層和 BLL 之間再插入一層(可能叫 ViewModel),用來放 UI 所需的 entity 類別,然後再寫一些程式碼來將 DAL 的 entity 屬性對應(複製)到 UI 層的 entity 類別(也就是要寫一些 object to object 的程式碼)。這樣的設計聽起來也合理,但是層次分得越細,要花的工當然就越多,程式也越複雜。就目前而言,我們並沒有明顯感受到增加這些額外的複雜性能夠帶來多大的好處,所以就還是先採用比較簡單的作法吧。

所謂比較簡單的做法,就是把所有的 entity 類別放在一個單獨的組件裡,例如 NorthwindApp.Domain。如此一來,UI 層、商業邏輯層、資料存取層便都會參考這個共用的 Domain 組件。在目前這個版本的範例中,我傾向讓領域類別保持單純,亦即 POCO,主要當 DTO 用,不放任何商業邏輯。但有時候可能會需要在這裡寫一點點資料驗證的程式碼,我認為這倒還好。資料驗證的程式碼有可能散在各層,包括 UI、DAL、BLL,以及大家共用的 Domain Model,但還是應該以 DAL 和 BLL 為主。

商業邏輯管理員

前一版的服務層是直接使用 Repository 類別來存取資料,現在有了商業邏輯層,UI 或服務層就不用再直接碰觸資料存取層,而是由商業邏輯層裡面的 Manager 類別代勞了;Manager 類別會再去呼叫 Repository 類別來處理資料。參考下方 OrderManager 類別的實作:

namespace NorthwindApp.BusinessLogic
{
    public class OrderManager
    {
        private IOrderRepository orderRepository;

        public OrderManager()
        {
            // TODO: Use IoC container to get the repository instance, 
            //       so that we can easily replace the implementation in the future.
            orderRepository = new OrderRepository();            
        }

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

        public IEnumerable<Order> GetOrders()
        {
            return orderRepository.GetAll();
        }
    }
}

服務層的 OrderService 類別會使用 OrderManager:

namespace NorthwindApp.Service
{
    public class OrderService
    {
        private OrderManager orderManager;

        public OrderService()
        {
            orderManager = new OrderManager();
        }

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

        public IEnumerable<Order> GetAll()
        {
            return orderManager.GetOrders();
        }
    }
}

展現層的部分與前一個版本相同,這裡就不列出來了。

利用 ADO.NET Entity Data Model 來產生領域類別

上次曾介紹一個工具,可以幫你產生這些 entity classes,省得自己手工打造每個領域類別。另一個方法,是利用 T4 來產生這些類別。這部分可以參考 91 的文章:透過 T4 產生對應 DB table 的 entity

這裡偷懶一下,直接利用 ADO.NET Entity Data Model 來產生領域類別--不使用 DbContext 或 Entity Framework 的其他功能,就只是單純利用它產生的 entity classes 而已。我不知道實務上有沒有人這樣用,感覺上有點奢侈,但的確方便。

首先,開啟 Visual Studio 2012,在 NorthwindApp.Domain 專案中建立一個資料夾:Model。我打算把所有 entity classes 放在這個資料夾裡面。於是,將來存取這些類別時,使用的命名空間就會是 NorthwindApp.Domain.Model.*。

接著在 Model 資料夾中加入一個 ADO.NET Entity Data Model,命名為 NorthwindModel.edmx。建立此資料模型時,選擇「Generate from database」,如下圖所示。


節省版面起見,其他操作步驟的畫面就不貼上來了。若對這部分的操作不太熟悉,可以參考MSDN 網站上的教學影片:Database First

產生 entity model 之後,我另外手動將所有的 Order_Detail(s) 類別與集合屬性名稱改為 OrderDetail(s)。這只是個人偏好,不是必要步驟。最終的 entity model 如下圖所示:



不過,這個模型在這裡並不重要。就如前面提過的,我們只是利用它來產生領域類別而已。

領域類別可以從 Solution Explorer 中的 NorthwindApp.Domain 專案的 Model \ NorthwindModel.edmx \ NorthwindMode.tt 底下找到。如下圖所示:


在建立 NorthwindModel.edmx 時,會產生兩個 T4 樣板檔案(.tt):NorthwindModel.Context.tt 主要是用來產生 DbContext 類別,NorthwindModel.tt 則是用來產生領域類別。底下列出其中一個領域類別 Customer.cs 的原始碼:

public partial class Customer
{
    public Customer()
    {
        this.Orders = new HashSet<Order>();
        this.CustomerDemographics = new HashSet<CustomerDemographic>();
    }

    public string CustomerID { get; set; }
    public string CompanyName { get; set; }
    public string ContactName { get; set; }
    public string ContactTitle { get; set; }
    public string Address { get; set; }
    public string City { get; set; }
    public string Region { get; set; }
    public string PostalCode { get; set; }
    public string Country { get; set; }
    public string Phone { get; set; }
    public string Fax { get; set; }

    public virtual ICollection<Order> Orders { get; set; }
    public virtual ICollection<CustomerDemographic> CustomerDemographics { get; set; }
}

你可以看到,Entity Framework 5 所產生的 POCO 類別還蠻簡潔的,應該不用改太多就可以跟 Enterprise Library 的 row mapper 一起搭配使用。

到目前為止,跟前一版的範例程式比較起來,只是組件的安排略有調動(領域模型獨立出來成為單獨的組件)以及領域類別改由 EF 產生,所以程式碼的部分並沒有改太多,整個 solution 就可以順利通過編譯。不過,執行時仍會出現錯誤:

Unhandled Exception: System.InvalidOperationException: The column Customer was not found on the IDataRecord being evaluated. This might indicate that the access or was created with the wrong mappings.

這是因為 EF 幫我們產生的 Order 類別定義中包含了與其關聯的 Customer、Employee、Shipper 物件,導致原本的 OrderRepository 在利用 Enterprise Library 來對應物件屬性與資料欄位時找不到匹配的欄位名稱。底下是稍微修剪過的 Order 類別定義:

public partial class Order
{
    public Order()
    {
        this.Order_Details = new HashSet<Order_Detail>();
    }

    public int OrderID { get; set; }
    public string CustomerID { get; set; }
    public Nullable<int> EmployeeID { get; set; }
    ....(略)
    public virtual Customer Customer { get; set; }
    public virtual Employee Employee { get; set; }
    public virtual ICollection<OrderDetail> OrderDetails { get; set; }
    public virtual Shipper Shipper { get; set; }
}

暫且將其中的 Customer、Employee、和 Shipper 這幾行屬性宣告註解掉,程式就可以順利執行了。執行結果跟前一版的範例一樣:


加入物件工廠

前一版的 OrderRepository 類別已經有實作一個 GetByID() 方法,可以取得特定編號的訂單。其他方法則尚未實作,例如 GetAll()。這次把它加上,與 GetByID() 一併列出來:

public 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<Order> mapper = MapBuilder<Order>.BuildAllProperties();
        Order order = mapper.MapRow(rdr);
        return order;
    }
    return null;
}

public IEnumerable<Order> GetAll()
{
    var orders = new List<Order>();
    string sql = "select * from Orders";
    IDataReader rdr = database.ExecuteReader(CommandType.Text, sql);
    while (rdr.Read())
    {
        IRowMapper<Order> mapper = MapBuilder<Order>.BuildAllProperties();
        Order order = mapper.MapRow(rdr);
        orders.Add(order);
    }
    return orders.AsEnumerable<Order>();
}

這兩個方法都有重複的程式碼:建立 Order 物件和對應屬性的部分。將來,我們很可能需要以不同的方式來建立 Order 物件,比如說,在建構函式中傳入一個 Customer 物件來指名該訂單隸屬某位客戶,或者傳入一個 Order 物件來複製一個新物件,諸如此類的。

既然建立物件的方式可能有很多種,而這裡又出現了重複的程式碼,這似乎在暗示:把建立 Order 物件的責任放在 OrderRepository 裡面已不太恰當,有違反 SRP(單一責任原則)的疑慮。也許我們可以將這部分獨立出去,由另一個類別來專門負責建立 Order 物件--就叫它 OrderFactory 好了。參考下圖:


我將這個工廠類別也放在資料存取層,跟 OrderRepository 一起。程式碼如下:

public static class OrderFactory
{
    public static Order CreateOrder(IDataRecord record) 
    {
        IRowMapper<Order> mapper = MapBuilder<Order>.BuildAllProperties();
        Order order = mapper.MapRow(record);
        return order;
    }

    public static IList<Order> CreateOrderList(IDataReader reader)
    {
        var orders = new List<Order>();
        while (reader.Read())
        {
            orders.Add(CreateOrder(reader));
        }
        return orders;
    }

    public static Order CloneOrder(Order order)
    {
        throw new NotImplementedException();
    }
}

除了 CreateOrder 方法,我還加了一個 CloneOrder,表示該方法是要從既有的 Order 物件複製成一個新的。由此可見使用 OrderFactory 的一個好處是,你可以取個恰當的方法名稱,而不用不像建構函式那樣只能用 new 運算子來建立物件。物件工廠的寫法讓程式碼更直覺、容易理解。

有了 OrderFactory,原先 OrderRepository 的程式碼就可以改成這樣(變動的部分已標註 *** ):

public 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())
    {
        return OrderFactory.CreateOrder(rdr); // ***
    }
    return null;
}

public IEnumerable<Order> GetAll()
{
    string sql = "select * from Orders";
    IDataReader rdr = database.ExecuteReader(CommandType.Text, sql);
    return OrderFactory.CreateOrderList(rdr); // ***
}

集合物件型別的選擇:在傳回集合物件時,這裡同時使用了 IList 和 IEnumerable 泛型介面。一般來說,可盡量採用輕巧的 IEnumerable 泛型介面。若呼叫端需要使用 IList 來操作集合,只要呼叫 ToList() 方法就能轉成串列。有時我也會用 IList 當作回傳型別,並且在這類方法的名稱後面加上 "List",例如這裡的 CreateOrderList()。另一個選擇是傳回 IQueryable 泛型介面,它具有延遲執行(deferred execution)的特性,在某些情況下有比較好的查詢效能

也許你已經知道,這裡的 OrderFactory 其實是運用了 Domain-Driven Design 的工廠模式(factory pattern)。值得一提的是,這裡的工廠模式與 GoF 的工廠模式並不相同。DDD 的工廠模式偏向領域層次的概念,GoF 的工廠模式則屬於「技術層次」...呃,有點抽象。這麼說吧:我們可能會在 DDD 的工廠類別裡面運用 GoF 的工廠模式(例如 Abstract Factory 或 Factory Method),但不太可能反過來做。

問題:我們需要為所有的領域類別提供物件工廠嗎?例如:CustomerFactory、CategoryFactory、ProductFactory、OrderDetailFactory....等等。這樣看起來好像比較一致,但其實很容易弄出一堆不是那麼必要的類別。當你覺得增加某個工廠類別能夠讓程式碼更簡潔、更容易閱讀,以及最重要的--更容易維護,只有在這個時候才去寫它,否則只是徒增應用程式的複雜性而已。

實作查詢條件

目前我只為 OrderRepository 實作了兩個查詢方法:GetByID() 是查詢特定編號的訂單,GetAll() 則是傳回全部的訂單。實務上通常會有更多種查詢條件,例如:

  • GetByCustomer:查詢特定客戶的訂單。
  • GetByDate:查詢特定日期的訂單。
  • GetByTotalAmount:查詢總金額落在特定範圍內的訂單。

為每一種查詢條件寫一個方法,看起來好像有點老土。比較「高級」的作法有查詢物件(Query Object)和規格模式(Specification Pattern)。

然而就目前的進展來看,還是先用「土方法」就夠了。況且,這種寫法具有直觀、易讀的優點(只要查詢方法的名稱取得恰當),也不至於太差。

小結

跟上一版比起來,這次主要是把層次切得比較清楚了。修改的部分包括:
  • 把商業邏輯層和領域層分別放在不同的組件:NorthwindApp.BusinessLogic.dll 和 NorthwindApp.Domain.dll。
  • 原先服務層是直接使用 Repository 類別來存取資料,現在已不直接依賴資料存取層,而是透過商業邏輯層的 Manager 類別(例如 OrderManager),再由 Manager 類別透過 Repository 取得資料。
  • 領域層裡面的類別基本上只包含 POCO 類別,作為 DTO,供 UI 層、商業邏輯層、和資料存取層共用。
  • 領域類別改由 ADO.NET Entity Data Model 產生(純粹為了節省時間)。
  • 加入工廠類別(OrderFactory)。

沒有改變的部分,是依然使用 Repository 模式來作為商業邏輯層與資料存取層之間的媒介,且實作時是將 repository 類別放在資料存取層。此外,資料存取層也還是繼續使用 Enterprise Library 的 DAAB 來處理關聯式資料模型與領域物件模型的對應。

資料存取的增、刪、改、查,目前只碰觸到「查」而已。其他三種操作,也許將來有時間寫續集時再補上。不過,我想 Repository + EL DAAB 的實作範例大概就到此打住,下次可能會把 Repository 裡面的 DAAB 換成 Entity Framework 吧。

各種作法都體驗一下也不錯!Happy coding :)

下載範例程式


  • 開發環境:Visual Studio 2012、SQL Server 2008、Northwind 資料庫。
  • 執行之前記得要修改 ConsoleApp 應用程式專案的 app.config 裡面的連線字串參數。

延伸閱讀
Copyright © 2012. Huan-Lin 學習筆記 - All Rights Reserved
Powered by Blogger
Template Design by Cool Blogger Tutorials
Published by Templates Doctor