EntityFramework之領域驅動設計實踐【擴展閱讀】:CQRS體系結構模式

CQRS體系結構模式

本文將對CQRS(Command Query Responsibility Segregation,命令查詢職責分離)模式做一個相對全面的介紹。可以這麼說,CQRS打破了經典的領域驅動設計實踐,在應用CQRS的整個過程中,你將會以另一種不同的角度去考慮問題並尋求解決方案。比如,CQRS是事件驅動的體系結構,事件是如何產生如何分發又是如何處理的?事件驅動的體系結構適用於哪些類型的應用系統?CQRS中的倉儲,與經典DDD中的倉儲又有何異同?等等這些問題,都給我們留下了無限的思考空間。

背景

在講CQRS之前,我們先了解一下CQS(Command-Query Separation,命令查詢)模式。名字上看,兩者沒什麼差別,然而CQRS應該說是,在DDD的實踐中引入CQS理論而出現的一種體系結構模式。CQS模式最早由著名軟件大師Bertrand Meyer(Eiffel語言之父,面向對象開-閉原則OCP提出者)提出,他認爲,對象的行爲僅有兩種:命令和查詢,不存在第三種情況。用他自己的話來說,就是:“提問永遠無法改變答案”。根據CQS,任何方法都可以拆分爲命令和查詢兩個部分。比如,下面的代碼:

隱藏行號 複製代碼 代碼
  1. private int i = 0;
  2. 
    
  3. private int Add(int factor)
  4. {
  5.     i += factor;
  6.     return i;
  7. }
  8. 
    

可以替換爲:

隱藏行號 複製代碼 代碼
  1. private void AddCommand(int factor)
  2. {
  3.     i += factor;
  4. }
  5. 
    
  6. private int QueryValue()
  7. {
  8.     return i;
  9. }
  10. 
    

當命令和查詢被分離的時候,我們將會有更多的機會去把握整個事情的細節。比如我們可以對系統的“命令”部分和“查詢”部分分別採用不同的技術架構,以使得系統具有更好的擴展性,並獲得更好的性能。在DDD領域中,Greg Young和Eric Evans根據Bertrand Meyer的CQS模式,結合實際項目經驗,總結了CQRS體系結構模式。

結構

整個系統結構被分爲兩個部分:命令部分和查詢部分。我根據自己的體會,描繪了CQRS的體系結構簡圖如下,供大家參考。在討論CQRS體系結構之前,我們有必要事先弄清楚這樣幾個概念:對象狀態、事件溯源(Event Sourcing)、快照(Snapshots)以及事件存儲(Event Store)。討論的過程中你會發現,很多概念與我們之前對經典DDD的理解相比,有着很大的不同。

CQRS體系結構模式

 

 

對象狀態

 

這是一個大家耳熟能詳的概念了。什麼是對象狀態?在被面向對象編程(OOP)“薰陶”了很久的朋友,一聽到“對象狀態”,馬上想到了一對對的getter/setter屬性。尤其是.NET程序員,在C# 3.0及以後版本中,引入了Auto-Property的概念,於是,對象的屬性就很容易地成爲了對象狀態的代名詞。在這裏,我們應該看到問題的本質,即使是Auto-Property,它也無非是對對象字段的一種封裝,只不過在使用Auto-Property的時候,C#編譯器會在後臺創建一個私有的、匿名的字段(field),而Property則成爲了從外部訪問該字段的唯一途徑。換句話說,對象的狀態是保存在這些字段裏的,對象屬性無非是訪問字段的facade。在這裏澄清這樣一個事實,就是爲了當你繼續閱讀本文的時候,不至於對事件溯源(Event Sourcing)的某些具體實現感到困惑。在Event Sourcing的具體實現中,領域對象不再需要具備公有的屬性,至少外界無法通過公有屬性改變對象狀態(即setter被定義爲private,甚至沒有setter)。這與經典的DDD設計相比,無疑是一個重大改變。例如,現在我要改變某個Customer的狀態,如果採用經典DDD的實現方式,就是:

隱藏行號 複製代碼 代碼
  1. [TestMethod]
  2. public void TestChangeCustomerName()
  3. {
  4.     IocContainer c = IocContainer.GetIocContainer();
  5.     using (IRepositoryTransactionContext ctx = c.GetService<IRepositoryTransactionContext>())
  6.     {
  7.         IRepository<Customer> customerRepository = ctx.GetRepository<Customer>();
  8.         Customer customer = customerRepository
  9.             .Get(Specification<Customer>
  10.             .Eval(p=>p.FirstName.Equals("sunny") && p.LastName.Equals("chen")));
  11.         // Here we use the properties directly to update the state
  12.         customer.FirstName = "dax"; 
  13.         customer.LastName = "net";
  14.         customerRepository.Update(customer);
  15.         ctx.Commit();
  16.     }
  17. }
  18. 
    

現在,很多ORM工具都需要聚合根具有public的getter/setter,這本身就是技術實現上的一種約束,比如某些ORM工具會使用reflection,通過讀寫對象的property來改變對象狀態。爲什麼ORM工具要選擇properties,而不是fields?因爲這些框架不希望自己的介入會改變對象對其狀態的封裝級別(也就是訪問限制)。在引入CQRS後,ORM已經沒有太多的用武之地了,當然從技術選型的角度看,你仍然可以選擇ORM,但就像關係型數據庫那樣,它已經顯得沒那麼重要了。

事件溯源(Event Sourcing)

在某些情況下,我們不僅需要知道對象的當前狀態是什麼,而且還需要知道,對象經歷了哪些路程,才獲得了當前這樣的狀態。Martin Fowler在介紹Event Sourcing的時候,舉了個郵包跟蹤(Package Tracking)的例子。在經典的DDD實踐中,我們只能通過Shipment.Location來獲得郵包的當前位置,卻沒辦法獲得郵包經歷過哪些地址而最終到達當前的地址。

爲了使我們的業務系統具有記錄對象歷史狀態的能力,我們使用事件驅動的領域模型來實現我們的業務系統。簡而言之,就是對模型對象狀態的修改,僅允許通過事件的途徑實現,外界無法通過任何其他途徑修改對象的狀態。那麼,記錄對象的狀態修改歷史,就只需要記錄事件的類型以及發生順序即可,因爲對象的狀態是由領域事件更改的。於是,也就能理解上面所講的爲什麼在Event Sourcing的實現中,領域對象將不再具有公有屬性,或者說,至少不再具有公有的setter屬性。

當對象的狀態被修改後,我們可能希望將對象保存到持久化機制,這一點與經典的DDD實踐上的考慮是類似的。而與之不同的是,現在我們保存的已不再是某個領域對象在某個時間點上的狀態,而是促使對象將其狀態改變到當前點的一系列事件。由此,倉儲(Repository)的實現需要發生變化,它需要有保存領域事件的功能,同時還需要有通過一系列保存的事件數據,重建聚合根的能力。看到這裏,你就知道爲什麼會有Event Sourcing這個概念了:所謂Event Sourcing,就是“通過事件追溯對象狀態的起源(與經過)”,它允許你通過記錄下來的事件,將你的領域模型恢復到之前任意一個時間點。你可能會興奮地說:我的領域模型開始支持事件回放與模型重建了!

Event Sourcing讓我們“透過現象看本質”,使我們更進一步地瞭解到“對象持久化”的具體含義,其實也就是對象狀態的持久化。只不過,Event Sourcing並不是直接保存了對象的狀態,而是一系列引起狀態變化的領域事件。

仍然以上面的更改客戶姓名爲例,在引入領域事件與Event Sourcing之後,整個模型的結構發生了變化,以下是相關代碼,僅供參考。

隱藏行號 複製代碼 代碼
  1. [Serializable]
  2. public partial class CustomerCreatedEvent : DomainEvent
  3.  {
  4.     public string UserName { get; set; }
  5.     public string Password { get; set; }
  6.     public string FirstName { get; set; }
  7.     public string LastName { get; set; }
  8.     public DateTime DayOfBirth { get; set; }
  9. }
  10. 
    
  11. [Serializable]
  12. public partial class ChangeNameEvent : DomainEvent
  13.  {
  14.     public string FirstName{get;set;}
  15.     public string LastName{get;set;}
  16. }
  17. 
    
  18. public partial class Customer : SourcedAggregationRoot
  19.  {
  20.     private DateTime dayOfBirth;
  21.     private string userName;
  22.     private string password;
  23.     private string firstName;
  24.     private string lastName;
  25. 
    
  26.     public Customer(string userName, string password, 
  27.         string firstName, string lastName, DateTime dayOfBirth)
  28.     {
  29.         this.RaiseEvent<CustomerCreatedEvent>(new CustomerCreatedEvent
  30.         {
  31.             DayOfBirth = dayOfBirth,
  32.             FirstName = firstName,
  33.             LastName = lastName,
  34.             UserName = userName,
  35.             Password = password
  36.             
  37.         });
  38.     }
  39. 
    
  40.     public void ChangeName(string firstName, string lastName)
  41.     {
  42.         this.RaiseEvent<ChangeNameEvent>(new ChangeNameEvent
  43.         {
  44.             FirstName = firstName,
  45.             LastName = lastName
  46.         });
  47.     }
  48. 
    
  49.     // Handles the ChangeNameEvent by using Reflection
  50.     [Handles(typeof(ChangeNameEvent))]
  51.     private void DoChangeName(ChangeNameEvent e)
  52.     {
  53.         this.firstName = e.FirstName;
  54.         this.lastName = e.LastName;
  55.     }
  56. 
    
  57.     // Handles the CustomerCreatedEvent by using Reflection
  58.     [Handles(typeof(CustomerCreatedEvent))]
  59.     private void DoCreateCustomer(CustomerCreatedEvent e)
  60.     {
  61.         this.firstName = e.FirstName;
  62.         this.lastName = e.LastName;
  63.         this.userName = e.UserName;
  64.         this.password = e.Password;
  65.         this.dayOfBirth = e.DayOfBirth;
  66.     }
  67. }
  68. 
    

上面的代碼中定義了兩個Domain Event:CustomerCreatedEvent和ChangeNameEvent。在Customer聚合根的構造函數中,直接觸發CustomerCreatedEvent以便該事件的訂閱者對Customer對象進行初始化;而在Customer聚合根的ChangeName方法中,則直接觸發ChangeNameEvent以便該事件的訂閱者對Customer的first name和last name作修改。Customer的基類SourcedAggregationRoot則在領域事件被觸發的時候通過Reflection機制獲得內部的事件處理函數,並調用該函數完成事件處理。在上面的例子中,也就是DoChangeName和DoCreateCustomer這兩個方法。在這裏需要注意的是,類似DoChangeName和DoCreateCustomer這樣的事件處理函數中,僅允許包含對對象狀態的設置邏輯。因爲如果引入其它操作的話,很難保證通過這些操作,對象的狀態不會發生改變

深入思考上面的設計會發現一個問題,也就是當模型對象變得非常龐大,或者隨着時間的推移,領域事件將變得越來越多,於是通過Event Sourcing來重建聚合根的過程也會變得越來越耗時,因爲每一次從建都需要從最早發生的事件開始。爲了解決這個問題,Event Sourcing引入了“快照(Snapshots)”。


快照(Snapshots)

Snapshot的設計其實很簡單。標準的CQRS實現中,採用“每產生N個領域事件,則對對象做一次Snapshot”的簡單規則。設計人員其實可以根據自己的實際情況定義N的取值,甚至可以選用特定的Snapshot規則,以提高對象重建的效率。當需要通過倉儲獲得某一個聚合根實體時,倉儲會首先從Snapshot Store中獲得最近一次的快照,然後再在由此快照還原的聚合根實體上逐個應用快照之後所產生的領域事件,由此大大加速了對象重建的過程。快照通常採用GoF Memento模式實現。請注意:CQRS引入快照的概念僅僅是爲了解決對象重建的效率問題,它並不能替代領域事件所能表述的含義。換句話說,即使引入快照,也不能表示我們能夠將快照之前的所有事件從事件存儲(Event Store)中刪除。因爲,我們記錄領域事件的目的,是爲了Event Sourcing,而不是Snapshots

image

 

事件存儲(Event Store)

通常,事件存儲是一個關係型數據庫,用來保存引起領域對象狀態更改的所有領域事件。如上所述,在CQRS結構的系統實現中,數據庫已經不再直接保存對象的當前狀態了,保存的只是引起對象狀態發生變化的領域事件。於是,數據庫的數據結構非常單一,就是單純的領域事件數據。事件數據的寫入、讀取都變得非常簡單高速,根本無需ORM的介入,直接使用SQL或者存儲過程操作事件存儲即可,既簡單又高效。讀到這裏,你會發現,雖然系統是用的一個稱之爲Event Store的機制保存了領域事件,但這個Event Store已經成爲了整個系統數據存儲的核心。更進一步考慮,Event Store中的事件數據是在倉儲執行“保存”操作時,從領域模型中收集並寫入的,也就意味着,最新的、最真實的數據仍然存在於領域模型中,正好符合DDD面向領域的思想,同時也引出了另一深層次的考慮:In Memory Domain!

 

回到結構

在完成對“對象狀態”、“事件溯源(Event Sourcing)”、“快照(Snapshots)”以及“事件存儲(Event Store)”的討論後,我們再來看整個CQRS的結構,這樣就顯得更加清楚。上文【CQRS體系結構模式】圖中,用戶操作被分爲命令部分(圖中上半部分)和查詢部分(圖中下半部分)。

  1. 用戶與領域層的交互,是以命令的方式進行的:用戶通過Command Service向領域模型發送命令。Command Service通常被實現爲.NET WCF Service。Command Bus在接收到命令後,將命令指派到命令執行器由其負責執行(可以參考GoF Command模式。TBD: 可以選擇更符合CQRS實現的其它途徑)。命令執行器在執行命令時,通過領域事件更改對象狀態,並通過倉儲保存領域對象。而倉儲並非直接將對象狀態保存到外部持久化機制,而僅僅是從領域對象中獲得已產生的一系列領域事件,並將這些事件保存到Event Store,同時將事件發佈到事件總線Event Bus
  2. Event Handler可以訂閱Event Bus中的事件,並在事件發生時作相關處理。上文在討論服務的時候,有個例子就是利用基礎結構層服務發送SMS消息,在CQRS的體系結構中,我們完全可以在此訂閱Warehouse Transferred事件,並調用基礎結構層服務發送SMS消息。Domain Model完全不知道自己的內部事件被觸發後,會出現什麼情況,而Event Handler則會處理這些情況(Domain Model與基礎結構層完全解耦)
  3. 在Event Handler中,有一種特殊的Event Handler,稱之爲Synchronizer或者Denormalizer,其作用就是爲了同步“Query Database”。Query Database是爲查詢提供數據源的存儲機制,用戶在UI上看到的查詢數據均來源於此數據庫。因此,CQRS不僅分離了用戶操作,而且分離了數據源,這樣做的一個最大的優點就是,設計人員可以根據UI的需求來配置和優化Query Database,例如,可以將Query Database設計爲一張數據表對應一個UI界面,於是,用戶查詢變得非常靈活高效。這裏也可以使用DDD中的Repository結合ORM實現數據讀取,與處於Domain Layer中的Repository不同,這個Repository就是DDD中所提到的經典型倉儲了,你可以靈活地使用規約模式。當然,你也可以不使用ORM而直接SQL甚至No SQL,一切取決於用戶需求與技術選型。我們還可以根據需要,對Synchronizer和Denormalizer的實現採用緩存,比如,對於無需實時更新的內容,可以每捕獲N個事件同步一次Query Database,或者當有客戶端query請求時,再做一次同步,這也是提高效率的一種有效方法
  4. 用戶UI通過Data Proxy獲得查詢結果數據,WCF將數據以DTO的形式發送給客戶端

總結

本文介紹了CQRS模式的基本結構,並對其中一些重要概念作了註釋,也是我在實踐和思考當中總結出來的內容(PS:轉載請註明出處)。學習過DDD而剛剛開始CQRS的朋友,在閱讀一些資料的時候勢必會感到疑惑,希望本文能夠幫助到這些朋友。比如最開始閱讀的時候,我也不知道爲什麼一定要通過領域事件去更改對象狀態,而不是在對象狀態變更的時候,去觸發領域事件,因爲當時我仍然希望能夠在Domain Model中方便地使用getter/setter,我當時也希望能夠讓Domain Model同時適應於經典DDD和CQRS架構。在經過多次嘗試後發現,這種做法是不合理、不可取的,也正如Udi Dahan所說:CQRS是一種模式,既然是模式,就是用來解決特定問題的。

還是一句老話:視需求而定。不要因爲CQRS所以CQRS。雖然可以很大程度地提升系統性能,雖然可以使系統具有auditing的能力,雖然可以實現Domain-Centralized,雖然可以讓數據存儲變得更加簡單,雖然給我們提供了很多技術選型的機會,但是,CQRS也有很多不足點,比如結構實現較繁雜,數據同步穩定性難以得到保證,事件溯源(Event Sourcing)出錯時,模型對象狀態的恢復等等。還是引用Udi Dahan的一句話:簡單,但不容易!

出自:http://www.cnblogs.com/daxnet/archive/2010/08/02/1790299.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章