DDD是近年軟件設計的熱門。CQRS與Event Sourcing做爲實施DDD的一種選擇,也逐步進入人們的視野。圍繞這兩個主題,軟件開發的大咖[Martin Fowler]、[Greg Young]、[Udi Dahan]分別有所論述,[MSDNC QRS Journey]、[Implementing DDD]、[Patterns, Principles, and Practices of DDD]等著述也提供了範例,國內外各大論壇的文章和DDD開源框架更是數不勝數,爲學習CQRS和Event Sourcing提供了大量指導。html
其中,Greg Young的論文最爲系統。故本文經過解讀其論文,簡單梳理了CQRS與Event Sourcing的發展脈絡,釐出其中的主要技術重點,並提出以Akka做爲落地方案,以求對這兩個主題有一個較爲全面的總結。錯謬之處,還望指正。前端
本文並未就DDD的相關方法和戰術模式等進行介紹。git
這是典型的傳統架構,其中Application Service是Domain Model的屏風,負責與Client打交道:github
在傳統架構下,數據在UI、模型和持久化的數據庫之間流動,遵循下圖所示的循環。數據庫
系統從數據庫中讀出DTO(數據傳輸對象,Data Transfer Object)後,根據DTO與領域對象的映射規則,將其轉換爲領域對象,同時呈如今UI上。當用戶在UI上完成修改後,又根據一樣的映射規則,將其更新到領域對象上,同時持久化到數據庫當中。最後,系統根據持久化結果刷新UI,完成一次領域操做,從而保證了從UI到領域對象,再到持久化數據之間的一致性。這當中,DTO只是爲防止暴露模型細節而設計的領域對象的投影,UI則體現爲對該DTO各字段的對照呈現。編程
很天然地,系統的全部業務流程也隨之演變成一系列圍繞DTO的CRUD操做(Create-Read-Update-Delete)。因而在很長一段時間裏,下圖這樣千篇一概的界面發展成爲MIS(信息管理系統,Management Infomation System)類軟件的主流,圖中左下角的記錄導航條成爲標配元素。在這種狀況下,若是把DTO看成數據庫裏的一行記錄,那麼整個系統能夠視做以DTO爲Row、以CRUD操做爲主要事務的一系列數據庫Table。windows
傳統架構(包括分層架構)簡單直觀,只要設計好數據模型,系統設計就算成功了一大半,並且全部寫入操做都由事務包裹,可以達到強一致性要求。但它也有如下幾個弊端:安全
傳統架構中的CRUD模式,其最大的弊端在於語義與操做的脫節。Application Service中的API一般表明着用例的某個方面,所以尚含有領域語義,好比API PlaceOrder()
表示用戶下單。然而該API在到達內部模型後,就被拆解映射爲CRUD操做Order.Create()
。相應地,API AddOrderItem()
被映射爲Order.Update()
,CancelOrder()
被映射爲操做Order.Delete()
,等等。這樣的彆扭,又在DTO轉譯的負擔之上,給理解和維護模型帶來了必定的困難。架構
因而,爲了儘量保留用戶意圖,咱們首先想到經過命名規範和方法的二次封裝,使CRUD操做在字面上接近API的語義,好比用Product.Rename()
封裝Product.Update(Name = "NewName")
。但這樣的作法並未能改變實質,所以即便套用了Aggregate、Value Object和Repository等DDD的戰術概念,但這種徹底以「DTO結構 + CRUD操做」爲主要元素構成的模型,被Martin Fowler等人稱爲「[貧血模型]」(Anemic Model)。併發
接下來,吸收貧血模型的教訓,開始着手創建富含領域行爲的各類領域對象。當API PlaceOrder()
最終交給Order.Place()
完成時,工做彷佛已經畫上了圓滿句號。
在函數式編程範式中,「抽象數據類型ADT + 代數方法」組成的模型,與「DTO + CRUD」的方式很是相似,但從函數式編程的視角,這纔是最合理、最優雅的模型。那麼,它到底是不是貧血模型呢?😄
當模型再也不貧血以後,對充血模型中領域對象的方法調用,將與CRUD存在典型區別,由於傳遞給領域對象方法的再也不是「肥胖的」DTO,而只有那些必要的少許參數,且方法名直接就表達了領域語義。好比,Order.RelocateAddress(address)
,而不是Order.Update(Address="NewAddress")
。
接下來,採用重構手法,將這些必要參數封裝爲Command對象,縮短方法調用的參數列表長度。進而在此基礎上,引入[Command Pattern],將本來直接調用領域對象的方法,變成先構造Command對象、再委託Command對象執行統一的Execute()接口方法的兩個步驟。
最後,再以Service API爲請求方,Command對象爲載體,領域對象的方法爲Command Handler,使上述模式演變爲Requst-Reponse Pattern,實現了API與領域對象方法之間調用關係的脫耦,接口變得更加一致和優雅。上面的例子就變成Service.Send(RelocateAddressCommand)
和Order.HandleCommand(RelocateAddressCommand)
。
至此,改進模型的工做應該算真正結束了吧。用戶經Service API構造併發出Command對象,領域對象接收並處理Command對象,完成自身狀態更新,而後把狀態轉譯爲DTO持久化到數據庫。同時,Service API根據Command對象處理結果,將狀況反饋給呈現層實現UI刷新。
這樣的結果,雖然增長了系統的複雜度,但爲實現Undo/Redo等複雜機制提供了基礎,同時Command對象藉助消息中間件傳遞,還能夠實現Application Service Layer與Domain Model的跨主機部署,爲分佈式應用提供了條件。最關鍵的是,Command對象自己富含領域語義,其名稱體現了用戶意圖,其字段限制了模型受影響的範圍。
從中還能夠獲得以下的啓示:
走到這一步,Service API、Command對象、Domain Model這幾方面都已經作到「面向領域」了,剩下的只有UI和持久化了。
Microsoft在[Inductive User Interface]指南中,總結了改進用戶體驗的一些建議,強調不要寄但願於用戶徹底瞭解軟件的總體架構和工做原理、流程,而要儘可能使用引導式、聚焦式的UI設計,幫助用戶專一於當前某個具體領域行爲,確保一次只完成一項任務。在目前架構條件下,UI是Command的發起人,因此UI的關注點能夠相應地限制於Command所需的那部分,這便獲得了Task-based UI。
以前的例子按Task-based UI的要求改造後,當用戶點擊列表項「已離職」下方的複選框時,就會彈出第二個對話框,提示填寫離職的緣由。
這樣的UI設計變化,就比如論述題與填空題的區別。傳統UI就象論述題,用戶得知道解答論述題的套路:先解釋主要概念,再回答特性、分類等等。而Task-based UI就象填空題,用戶始終是在一個上下文裏回答當前的提問,這樣必然更直觀和人性化。
通過前述改進,架構與循環分別變成下面這樣:
若是把循環按左右一分爲二,左半部分都執行的查詢操做,右半部分都是寫入操做,因而設想把API一分爲二,其中Command部分的方法都沒有返回值,但會修改聚合對象實例狀態;Query部分的方法只返回查詢結果,但不會修改任何東西。這便獲得了CQS原則(Command Query Separation)。
關於CQS原則,Meyer的這句話很是準確:「Asking a question should not change the answer」。
在CQRS Journey的[Conference案例]中,ConferenceService就是典型的CQS示例。
在使用CQS原則對Service API進行切分後,進一步根據讀寫職責不一樣,把領域模型切分爲Command端與Query端兩個部分,便獲得了下圖所示的CQRS模式(命令與查詢職責分離,Command and Query Responsibility Segregation)。Command端與Query端共享同一份持久數據,但Command端只寫入狀態,Query端只讀取狀態。
爲進一步提升效率,讀寫端的持久數據分離成爲必然選擇,但也產生了新的矛盾——如何在兩端進行數據同步,以達到最終一致性(Eventual Consistency)。
一方面,從CQRS模式的結構看,系統狀態變化都發生在Command端,所以只有Command端掌握着具體是哪些內容發生了變化,若是把變化的這些內容封裝在一塊兒,代表系統「剛剛發生了哪些變化」,就獲得了所謂的事件Event。
反觀Query端,查詢返回的老是反映系統當前狀態的靜態數據。根據「當前狀態 + 變化 = 新的狀態」,若是能從Command端獲得「變化」,就能獲得變化後的「新的狀態」。而Event正好符合「變化」的定義,因此選擇從Command端將Event推送到Query端,Query端根據Event刷新狀態,就能保證兩端的模型都反映系統的最終狀態,達到最終一致性。
另外一方面,在解決了取得最終一致性的難題後,還得設法改進數據的持久化。
首先能肯定的是,從Query端查詢獲得的老是系統當前狀態的靜態數據,因此從傳統架構一直沿用到CQRS模式下的DTO方案依然有效。可是,因爲這樣的DTO直接映射領域對象,會暴露領域對象細節,並且這種映射會產生阻抗失配,致使過多的間接查詢和多聚合數據的聯結,使優化查詢變得很是困難。因此,爲提升查詢效率,能夠採起相似關係數據庫中「視圖」的方式,直接面向數據模型,採用一切可以使用的數據庫技術,構造一個Thin Read Layer。
再是Command端的持久化。根據「初始狀態 + 若干次變化 = 當前狀態」,在初始狀態上依次疊加每一次變化,一樣能獲得當前狀態。其中聚合對象實例的初始狀態是固定的,每一次變化即處理Command後產出的事件Event,那麼只要保存好全部發生過的歷史事件,就能從初始對象重現(Replay)到當前狀態。因此,Command端的持久化最終演變成事件歷史的持久化,這即是事件存儲(Event Storage)。
最終,事件的產生、存儲、推送和重現,即構成了完整的事件溯源(Event Sourcing)。
在CQRS與Event Sourcing的支持下,系統架構也相應地變成了下圖這樣:
CQRS使Event Sourcing成爲改變和存儲系統狀態的核心機制。在這種模式下,由Application Service Layer統攬整個業務流程。Service首先從Query端查詢系統狀態,爲執行Command準備好上下文環境;而後Service構造好Command,併發送給利用Repository.GetByID()
加載(重現)獲得的聚合對象實例;接着聚合對象實例使用內置的Command Handler完成命令處理,更新聚合狀態,併產生Event,在其被持久化的同時推送往Query端;Query端收到Event後,對其自身維護的系統狀態也進行更新,達到與Command端一樣的一致,以迎接下一次Service的查詢。
從上述過程可知,Service是一切活動的發起者和組織者,Command的執行環境均由Service準備,Command是活動內容的承載者,聚合是活動的執行者,而Event是活動的推進者。
同時要注意,Command本質是對領域模型的一種請求,可能會被模型拒絕執行(悲傷路徑)。而Event則不一樣,它表明着系統剛剛完成了某項任務,一定發生了某種變化。事件的用語一定是確定的過去式,而不只僅是某個事實,好比應該是OpenFileFailed,而不是FileNotFound。
對須要多個步驟、跨越多個聚合協做才能完成的活動,本質上一樣遵循上述循環,但爲保證步驟間的有效銜接,又有一個新的模式Saga推出(在CQRS Journey和部分框架中,被稱爲Process Manager)。
Saga發出Command,也訂閱Event。它在向某個聚合發出第一個Command後,就等待Event的回饋,而後根據該Event準備下一個步驟所需的上下文環境,接着向某個聚合發出下一個Command,再等待下一個Event回饋,如此周而復始,直到流程結束。
關於Saga應否有狀態,爭論也很是多。CQRS Journey第6章A Saga on Sagas專門就Saga進行了講解。我的意見,Saga應當是無狀態的(Stateless),不然還得花費額外精力去持久化Saga的狀態。在這方面,能夠參考Web服務的一些設計原則與方案。
⚡ 重要提示
「世上沒有後悔藥,只有亡羊補牢」——因爲事件意味着改變已經發生,因此沒法被Undo,所以在以事件驅動的系統裏,沒有還原和回滾,只有善後和補救,這是與以事務爲中心的傳統架構的重要差異。
此外,C端與Q端的差異主要有如下幾點:
![]() |
Command Side | Query Side |
---|---|---|
一致性 | 一般使用事務維護強一致性。 | 一般採用最終一致性。 |
數據存儲 | 爲限制事務邊界,一般要求符合第三範式。 | 爲減小聯結操做,一般知足第一範式便可。 |
擴展性 | 處理命令一般只佔到系統事務很小的一部分,因此對擴展要求不高。 | 一般是命令處理量的數倍,所以對擴展性有較高要求。 |
方法 | 改變聚合對象實例的狀態,而不返回任何結果(或者僅返回成功與否的標誌)。Repository將剔除GetByID之外的其餘方法。 | 一般返回DTO給調用者,再呈現到UI。 |
數據來源 | 處理的目標即領域對象的自己。 | 處理的目標是DTO,但它已經從領域對象的投影演變成直接面向數據模型的特異化結構。 |
瞭解程度 | 對領域模型必須有完整的理解和掌握。 | 只需能理解數據模型並從中拼合出DTO便可,對業務規則等無需關注。 |
Command的常見實現以下所示,其中AggregateId指示是由哪一個聚合對象實例處理,Version指示在將Command發送給該聚合時聚合的最新版本,以備發生併發衝突時進行檢驗。
class Command { Guid Id; Guid AggregateId; Int Version; // 包含其餘信息的字段 }
Event的常見實現與Commanda基本相同,區別只是AggregateId指示是由哪一個聚合對象實例產生的Event,Version表示Event發生時聚合對象實例的版本。
聚合Aggregate是Command的處理器和事件的發佈器,其Command Handler與Event Handler的基本結構以下:
class Aggregate { public readonly Guid AggregateId; public readonly List<Event> UnsavedEvents = new List<Event>(); public Int Version = 0; public void HandleCommand(Command c) { if (!Valid(c)) throw new AggregateException(); var e = new SomeEvent(AggregateId, ...); this.HandleEvent(e); e.Version = this.Version; this.UnsavedEvents.Add(e); DoAnythingWithSideEffect(); } void HandelEvent(Event e) { ModifyState(); this.Version ++; } public void Replay(List<Event> events) { foreach(var e in events) { this.HandleEvent(e); } } }
Repository是聚合的集合,其主要方法GetByID()
負責返回聚合對象實例給調用者。當該實例還沒有在內存當中之時,將從Event Storage讀取全部對應該聚合Id的事件,接着構造一個空白的初始對象,利用獲取的歷史事件按版本前後重現到對象的最新版本,此後即可直接從內存中返回實例,而再也不須要重複上述加載過程了,這被稱爲In-Memory特性。
重現部分的簡單實現,參見前述Aggregate.Replay()
Event Storage是一個追加型的數據庫。因爲事件總與聚合對象實例相關,因此一個以聚合對象實例的Id爲key、事件序列化流爲value的Key-Value型NoSQL數據庫將很是適合這樣的場景。固然,傳統的關係數據庫也徹底能勝任。數據庫的結構也很簡單,每條Event做爲一條記錄,大體爲這樣的結構:
Name | Type | Content |
---|---|---|
Id | Guid | Event的Id,方便索引 |
AggregateId | Guid | 產出該事件的聚合對象實例Id |
Version | Integer | 該事件的版本編號 |
Data | Blob | Event序列化獲得的二進制流 |
Data字段的序列化除採用二進制流的方式,也可使用Json或者XML等結構化文本方式。
除上述字段外,還可附加Time Stamp等字段,這給系統回溯到指定時點提供了最基本的數據支持。
而在Query端,其數據主要目的爲前端展現,因此在數據模型設計上,更趨向於「面向界面」或「面向查詢」,須要一次性加載呈現所需的所有數據,因此私覺得MongoDB這樣的文檔型NoSQL數據庫很是符合Query端的狀況。
在傳統架構下,Repository從Data Storage中加載聚合對象實例,一般很糾結因而否使用延遲加載(Lazy Load)。
而在Event Sourcing條件下,由於寫模型本質是歷史的疊加,每一次操做都是追加事件,而不是刷新整個對象,因此延遲加載沒有存在必要。
在CQRS Journey第33頁有一段關於Lazy Load在CQRS條件下有無必要的對話能夠借鑑。
可是,每次從Event Storage讀取全部屬於某個聚合對象實例的事件而後進行重現,還是能夠改進的,方法就是使用快照(Snapshot)。
快照就是特定版本的聚合對象實例,因此構建快照的方法和重現得到一個聚合對象實例是相似的:構造一個空白的初始對象,利用獲取的歷史事件,按版本前後重現到特定版本。正由於快照等價於某個版本的聚合對象,因此快照的生成能夠徹底獨立並行於系統運行,並且能夠在快照基礎上重現其後續版本的事件,以獲得更新版本的聚合對象實例。
Command只有一個接收者,而Event能夠有若干個訂閱者,因此Command總與特定類型的聚合Command Handler綁定。在引入Command隊列後,根據聚合對象實例的Id進行Command分組,便可保證一個聚合對象實例在任意時刻只會處理一條Command,從而保證聚合的線程安全。這也是借鑑了Actor模式(此處的Actor並不是特指Akka框架裏的Actor,而是範指如下這樣的模式。)
每一個Actor,都是一個封閉的、有狀態的、自帶郵箱、經過消息與外界進行協做的併發實體。在Actor之間的消息發送、接收都是併發的,可是在Actor內部,消息被郵箱存儲後都是串行處理的。即Actor在同一時刻只會對一條異步消息作出迴應,從而回避加鎖等併發策略。
若是不採用Actor模式,那麼就須要本身處理併發衝突。因爲Command與Command Handler是一對一的,因此只有當存在多個相同Id的聚合對象實例時,好比爲提升吞吐量而將多個同一Id的聚合對象實例分佈於不一樣結點,或者因結點切換致使發生同一聚合對象實例被同時修改時,可能會發生併發衝突。此時聚合的版本號,將成爲併發控制的有力武器之一,主要策略不外乎樂觀或者悲觀兩種方式:
另外一方面,正如CQRS Journey第256頁的「Commands and optimistic concurrency」所述,因爲Command的執行環境來自於UI和Query端,因此當Query端與UI未同步時,好比管理員Tom剛停售某Product,而此時顧客Jimmy已經在提交包含該Product的Order,這便會出現破壞最終一致性的狀況。相應的一個解決方案,就是在Query模型裏保存當前聚合對象實例的最新版本號(即最近一個事件的版本號),而後由Service在構造Command對象時附上該版本號(參見前述Command的常見結構)。最後,由聚合對象實例在收到該Command對象時,與自身當前版本號做對比。若二者一致,即代表Query端目前發送來的Command正是基於聚合對象實例的當前最新版本。
在CQRS與Event Sourcing搭配的狀況下,事件在持久化的同時更新Query端是一個顯著的技術難點,由於這兩個動做必須同時成功,不然將會破壞最終一致性。若是持久化成功,而更新Query端失敗,那麼Query端呈現的就不是正確的系統狀態;若是持久化失敗,而更新Query端成功,那麼Command端執行環境與系統實際狀態不符。
爲此,CQRS Journey總結了業內的三種方案:
Greg Young在論文及其開發的框架[EventStore]中,都採用了最後一種方案。其主要思想是給每條Event添加一個Long類型的SequenceNumber字段,該字段在庫中是惟一且遞增的,表明着事件被推送的順序號。只要Event Storage保存好推送成功的最後一條事件的SequenceNumber,就能夠肯定推送完成的狀況了。
Actor模型最先出自1973年Carl Hewitt等人所著論文A Universal Modular ACTOR Formalism for Artificial Intelligence。
Akka是Lightbend公司推出的一個基於Actor模型的分佈式框架,目前主要支持的語言包括Java和Scala。
如下是官網及個人筆記連接:
用Akka實現CQRS與Event Sourcing的示意圖以下:
Lightbend公司在Akka基礎上,推出了一個微服務框架Lagom。
Lagom框架堅持,微服務是按服務邊界Boundary將系統切分爲若干個組成部分的結果,這意味着要使它們與限界上下文Bounded Context、業務功能和模塊隔離等要求保持一致,才能達到可伸縮性和彈性要求,從而易於部署和管理。所以,在設計微服務時應考慮大小是否「Lagom」,而非是否足夠「Micro」。
如下是官網和個人筆記連接:
Lagom封裝了服務定位、服務網關、消息隊列和路由、集羣等功能。每一個服務由服務描述子、調用標識符、消息處理器等組成,在服務的內部實現中,由Akka提供的EventSourcedBehavior承擔實際的消息處理和持久化。
本文是近年我的學習DDD和Event Sourcing的心得總結。限於篇幅,沒有就更多細節進行探討。
在實踐中,我使用DDD指導建模的流程可簡單總結以下: