應用程式的分層設計 (4) - CQRS 風味

這第四集離上一篇好遠...

最近有機會在工作中接觸到採用 CQRS 模式的 web 應用程式架構,覺得比以往採用的分層架構更清楚、簡明,再加上看了 Jimmy Bogard 的研討會影片,便自己練習寫了一個超級陽春的、基於 CQRS 模式的範例,並且透過這篇筆記來做個記錄以及整理一些想法。

範例程式的原始碼在這裡:CQRS Demo (可用 Visual Studio 2013/2015 開啟)

此範例的一些功能或值得注意的地方:
  • CQRS 風味:讀寫分離(稍後解釋)的風味(就像「牛肉風味」的泡麵,裡面不一定是真的牛肉)。
  • 組件的切分與類別的組織方式會反映出個人在設計框架時的偏好與著重的面向(例如容易理解與學習、不過度設計等等),同時也會形成一種程式撰寫模型或 coding 慣例。如果這個 coding 模型是清楚且一致的,就更容易上手,開發速度也能加快。
  • 使用 ASP.NET Web API 2.2 來示範,而非 ASP.NET MVC。也就是說,目前沒有 UI。測試功能時係利用瀏覽器或 Postman 之類的工具。
  • 查詢時的分頁、排序、篩選等操作都是在資料庫端執行。這個部分主要是依賴兩個現成套件:PagedList 和 LINQKit
  • 使用 Entity Framework Code First。此範例程式每一次啟動時都會重新建立資料庫,並且自動產生一些範例資料(即所謂的 seeding)。
  • 沒有 Repository 層。
  • 使用 Autofac 作為 DI 容器。相依套件:Autofac、Autofac.WebApi2。 
還沒實作的部分:
  • 單元測試。
  • model validation。

CQRS 概說

CQRS 是 Command-Query Responsibility Segregation 的縮寫,意思是把命令與查詢這兩種責任給明確區分開來。

從實作的觀點來說,引述 Bertrand Meyer 對 CQS 的解釋
一個方法要麼是執行某種動作的命令(command),要麼是回傳資料給呼叫端的查詢(query),不可兩者皆是。

後來有人基於此 CQS 概念,衍生出 CQRS 模式,其中較為人所熟知的,大概是 Greg YoungMartin Fowler 的文章吧。

CQS 描述的對象是方法,CQRS 則將範圍擴大至物件。也就是說,以 CQRS 來設計應用程式時,會將資料的操作分成兩類,並以此為原則來切分類別的責任。比如說,在資料存取層(data access layer)會有兩類物件,一類是專用來查詢資料的物件,另一類則是專用來修改資料的物件。
事件源(Event Sourcing)常與 CQRS 一併提及,此設計模式的用意,根據 Martin Fowler 的說法,是要讓應用程式能夠將異動資料的事件按照發生的時間順序全部記錄下來;如此一來,我們不只能夠查詢過去任意時間點的資料狀態,也能夠重現特定時間範圍之間所發生的事件過程。比如說,採用 Event Sourcing 的銀行交易系統可以讓使用者得知帳戶餘額是如何變成目前的狀態的。Event Sourcing 常與 CQRS 一起搭配運用,但採用 CQRS 模式的應用程式並不一定需要使用 Event Sourcing。

分層架構、DDD、CQRS

剛剛提到資料存取層,那還是先提一下以往大家熟知的分層架構,如下:


以「客戶資料」來說,為了提供客戶基本資料的增刪改查操作,我們可能設計一個 Customer 類別,作為 DTO(data transfer object)或對應至底層客戶資料表的 entity 類別。然後...
  • 在 UI 層,通常會有一個 CustomerController,負責 BLL 和 UI 之間的溝通。這個類別的建構子可能會要求注入一個實作了 ICustomerService 的物件。使用者發出的增刪改查的要求,送到了 CustomerController 之後,大多只是轉呼叫 CustomerServer 物件來完成任務。這層通常還會有 CustomerViewModel 之類的物件,作為傳遞給網頁顯示資料之用。
  • 在 BLL 或 Application Layer 這一層,我們有 CustomerService 類別,提供了與客戶這個概念有關的服務——基本上也少不了增刪改查。為了清楚區分責任,我們又把實際讀寫資料的操作寫在 DAL 裡面。
  • 在 DAL 裡面,有各類資料存取物件(data access object),例如 CustomerDAO。這個類別會呼叫 ADO.NET 的 API 來執行資料的增刪改查。
之後,越來越多人接受 Eric Evans 提出的 DDD(Domain-Driven Design),於是進一步衍生出基於 DDD 理念的應用程式架構。基本上,我是攏統地把六角、洋蔥等架構模式都歸類在 DDD 家族裡(因為我還沒搞懂 DDD )

想想:如果應用程式提供的功能或服務當中,大多屬於讀取(查詢)資料的操作,而比較少寫入(異動)資料,有必要這樣經過那麼多層(layers)才能完成一個讀取操作嗎?這是考慮採用 CQRS 的理由之一。

採用這類架構的應用程式,在實作上經常會有 Repository 類別。仍以客戶資料為例,相關的類別可能有:
  • Customer (domain/entity 類別,放在核心層或領域層)
  • CustomerController 與其介面 + 一些 View Models(放在 UI 層)
  • CustomerService 與其介面 (放在應用程式層或服務層)
  • CustomerRepository 與其介面 (裡面包 Entity Framework 的 DbContext,或者呼叫另一個 DAO 來真正負責資料讀寫操作)
其中的 CustomerRepository 通常會包含一些對客戶資料進行增刪改查的操作。若採用 CQRS 的設計方式,則有點像是把原本的 CustomerRepository 拆成兩個類別,例如 CustomerQueries 和 CustomerCommands,分別負責查詢和異動資料的操作。這麼一來,對於某些只需要讀取資料的場合(例如報表),就只需要關注 Query 類別。在實作上,MVC 或 Web API 的 Controller 類別也就可以視實際的需要注入 Query 和 Command 物件。比如說,用來處理訂單的 OrderController 可能需要注入三類物件:IOrderQueries, IOrderCommands, 和 ICustomerQueries;由於它不需要修改客戶資料,所以不用注入 ICustomerCommands。

另一方面,有了 Query 和 Command 物件,原本的 Repository 也就可以拿掉了。那麼,客戶資料維護的相關類別會變成:
  • Customer
  • CustomerController
  • CustomerService 與其介面
  • CustomerCommands 和 CustomerQueries 及其介面
這樣好像只是把每個 Repository 類別拆成兩個,然後把類別名稱改一下而已——換湯不換藥的概念?

倒也不盡然。

我覺得這樣一來,除了能夠以 Query 和 Command 來更清楚的對應 HTTP GET 以及 POST/PUT/DELETE 操作,對於比較複雜的系統,也更容易理解和維護。另一個好處是,Repository 的角色定位總是有點模糊不清——它到底是屬於資料存取層還是商業邏輯層,抑或介於兩者之間薄薄的抽象層?採用 CQRS 之後,負責資料讀寫操作的 Query 和 Command 物件自然屬於資料存取層,不至於有爭議。
註:有一種設計是,仍保留 Repository 類別,但把其中的 CRUD 操作全都拿掉,只剩下 entity 集合的宣告。這種 Repository 真的就只是像個資料倉庫,只是用來劃清 domain 邊界的用途而已。
約略有個概念之後,接著就來分解範例程式吧。

此範例應用程式的結構

此範例一共包含四個 C# projects:

  • SalesApp.Web : UI 展現層, 包括 Web API 的 controllers 以及 view models 類別。
  • SalesApp.Application : 應用程式層/服務層。其實這個 project 在此範例中並沒有使用到,因為我是直接把 Command 和 Query 物件注入至 Controller 了(透過建構函式注入)。之所以保留這個組件,是預備將來碰到比較複雜的情況時,可將特定服務寫在這個組件裡;到時候注入 CustomerController 的物件可能就變成 ICustomerService,而 CustomerService 則需要注入 ICustomerCommands 和 ICustomerQueries——當然這只是預想,很可能是我想太多了。
  • SalesApp.DataAccess : 資料存取層,包括 Entity Framework 的 DbContext 衍生類別、初始化資料庫的程式碼、以及 Command 和 Query 類別,都寫在這裡。
  • SalesApp.Core : 整個應用程式的核心,也就是說,其他專案都一定會參考到這個組件。這個組件包含了 entity model(如 Customer、Product 等)的定義。

下面這張專案結構圖顯示了三個重要的組件、組件之間的依賴方向,以及幾個關鍵型別。


值得一提的是,在 SalesApp.Web 專案中,我把一些 view models 以及 Web API controller 需要繫結的參數物件以及傳回的結果,全都定義在此專案的 Models 資料夾下的「某商業領域」子目錄下。例如與客戶資料維護相關的訊息傳遞物件,就放在 Models\Customer 資料夾下。我覺得這樣比較集中、好找。

不過,對於資料存取層的 Command 和 Query 物件,就是以功能類型來區分目錄了。你可以看到(上圖的右上角),CustomerQueries 類別是放在 SalesApp.DataAccess.Queries 資料夾下,而 CustomerCommands 是放在 SalesAll.DataAccess.Commands 裡面。

只要是個專案共用的介面,大都放在 SalesApp.Core 專案的 Interfaces 資料夾下。

程式碼

結構拆完了,接著可以再往細部看,也就是程式碼的部分。

此範例雖然單純,但若要詳細說明,仍得花不少時間打字,而寫了又不見得有多少人看。所以,我還是偷懶一下,請有興趣的朋友直接看 GitHub 上面的原始碼吧。如有任何問題或意見,歡迎在下方留言。

範例程式的原始碼:CQRS Demo(可用 Visual Studio 2013/2015 開啟)

閱讀提示一:可以從 CustomerController 的 GetCustomers 方法開始看起。

閱讀提示二:如果你不清楚 CustomerController 的建構函式要求注入的兩個物件(ICustomerQueries 和 ICustomerCommands)到底從哪裡生出來,請看 Global.asax.cs(尋找關鍵字 "autofac")。同理,CustomerQueries 和 CustomerCommands 這兩個類別的建構函式都需要注入 SalesContext 物件,但程式裡面找不到任何 new SalesContext 的程式碼,原因也藏在 Global.asax.cs 裡面。這裡是使用了 Dependency Injection 的技巧(厚臉皮推薦:有關 DI 的詳細介紹與用法可參考拙作《.NET 相依性注入》。

參考資料與延伸閱讀

沒有留言:

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