"DDD理論學習系列——案例及目錄" 1.引言 聚合,最初是UML類圖中的概念,表示一種強的關聯關係,是一種整體與部分的關係,且部分能夠離開整體而獨立存在,如車和輪胎。 在DDD中,聚合也可以用來表示整體與部分的關係,但不再強調部分與整體的獨立性。聚合是將相關聯的領域對象進行顯示分組,來表達整體的概 ...
1.引言
聚合,最初是UML類圖中的概念,表示一種強的關聯關係,是一種整體與部分的關係,且部分能夠離開整體而獨立存在,如車和輪胎。
在DDD中,聚合也可以用來表示整體與部分的關係,但不再強調部分與整體的獨立性。聚合是將相關聯的領域對象進行顯示分組,來表達整體的概念(也可以是單一的領域對象)。比如將表示訂單與訂單項的領域對象進行組合,來表達領域中訂單這個整體概念。
我們知道,領域模型是由一系列反映問題域概念的領域對象(實體和值對像)組成,聚合正是應用在領域對象之上。如果要正確應用聚合,我們首先得理清領域對象間的關聯關係。
2. 梳理關聯關係
在設計領域模型的初期,我們習慣專註於領域中的實體和值對象,而忽略領域對象之間的關聯關係,以至於我們會基於現實業務場景或數據模型來建立關聯關係。這樣就會引入大量不必要的關聯,比如下圖:
然而圖中的關聯關係都是必要的嗎?我想未必。這樣的關聯關係,加大了實現領域模型的技術難度。
當我們建立對象的關聯關係時,思考以下問題:
- 這個關聯關係的作用時什麼?
- 誰需要這個關聯關係去發揮作用?
而如何簡化關聯呢?
- 基於業務用例而非現實生活建立必要的關聯
- 減少不必要的關聯
- 將雙向的關聯轉換為單向關聯
如果遵從這個原則,那我們的領域模型將會是這樣的:
領域對象間清晰的關聯關係,能夠清晰反映領域概念,便於我們設計出比較理想的領域模型。理清了領域對象間的關聯關係,我們下麵來應用聚合。
3. 應用聚合
領域對象不是孤立存在的,往往幾個對象的組合才能表示一個完整的概念,如上文所說的訂單和訂單項。那如何組合對象呢?也就是我們本文的主題。
聚合是領域對象的顯示分組,旨在支持領域模型的行為和不變性,同時充當一致性和事務性邊界。
這句話涉及到幾個概念,我們來拆解一下:
- 領域對象的顯示分組
- 領域行為和不變性
- 一致性和事務性邊界
其中我們需要澄清下領域不變性:
Domain invariants are statements or rules that must always be adhered to.
領域不變性指的是必須遵守的陳述或規則。換句話說,就是領域內我們關註的業務規則。比如,訂單必須具有唯一訂單編號、訂單日期;訂單必須冗餘商品的基本信息(名稱、價格、折扣);訂單至少有一個商品,刪除商品時,訂單項需要一併刪除;等等。
前兩句話綜合來說,就是聚合通過對領域對象的封裝來體現領域中的業務規則。
而邊界的目的是分離聚合內外,聚合內通過事物來保證強一致性。
總而言之,聚合不僅僅是簡單的對象組合,其主要的目的是用來封裝業務和保證聚合內領域對象的數據一致性。
一致性和事務性邊界,又如何理解呢?
一致性是指數據一致性,事務性指的資料庫的ACID原則。
下麵我們來著重介紹下。
4.一致性邊界
為了確保系統的可用性和可靠性,我們必須保證數據的一致性。
訂單支付成功後,訂單狀態要更新為已支付狀態,且現有庫存要根據訂單中商品實際銷售數量進行扣減。
下麵我們就以這個案例,來分析說明。
4.1.事務一致性
針對這個用例,傳統的做法就是,在一個事務中,去更新訂單狀態和扣減庫存。這樣似乎滿足了業務場景需求,但是我們不得不考慮另外一個問題——併發衝突。比如,在更新訂單的同時,商城來了一批貨,要進行庫存更新,這個時候就存在潛在的衝突,而問題可能表現為資料庫級別的阻塞或更新失敗(由於悲觀併發),如下圖:
這個併發問題我們該如何解決呢?
首先我們要分析問題的原因,這個用例陳述了具體的業務規則。我們錯誤的將業務涉及到的所有領域對象都放到了一個事務性邊界中去了。其實這個用例涉及到三個子域,銷售、商品、庫存子域。從領域不變性的角度來看,我們應該維護各自子域內業務規則的不變性,而不是為了業務場景實現一概而論。按照這個思想,我們把訂單、商品、庫存拆分成三個獨立的聚合,如下圖所示。
從圖中我們可以看出,每個聚合都有自己的事務一致性邊界。也就是說這三個聚合分別在不同的事務中維持自己的不變性,也就是說聚合是用來維護內部事務一致性。那針對以上用例,明顯需要跨域多個聚合,我們又該如何保證一致性呢?因為我們不能在一個事務中更新多個聚合,所以我們只能實現最終一致性。
4.2. 最終一致性
最終一致性的實現原理是藉助領域事件來完成事務的拆分,如下圖所示。
而針對我們的用例,在更新訂單支付狀態時,發佈一個訂單已支付的領域事件,庫存聚合訂閱處理這個事件,即可完成庫存的更新。事務拆分如下圖:
4.3. 特殊情況
凡事沒有絕對,在一個聚合中僅修改一個聚合是最佳方法。但有時候,在一個事務中更新多個聚合也是可行的,這需要結合具體場景區別對待。另外還有一點需要澄清,以上使用一致性的目的,主要是針對聚合的修改。在一個事務中載入和創建多個聚合是沒有問題的,因為並不會導致併發衝突。
5. 聚合的設計
根據上面的闡述:聚合不僅僅是簡單的對象組合,其主要的目的是用來封裝業務和保證聚合內領域對象的數據一致性。
那聚合設計時要遵循怎樣的原則呢?
- 遵循領域不變性
- 聚合內實現事務一致性,聚合外實現最終一致性
一個事物一次僅更新一個聚合。當業務用例要跨域多個聚合時,使用領域事件進行事務拆分,實現最終一致性。 - 基於業務用例而非現實生活場景
- 避免成為集合或容器
對聚合的一大誤解就是,把聚合當作領域對象的集合或容器。當發現這個徵兆時,你要考慮你聚合是否需要改造。 - 不僅僅是HAS-A關係
聚合不是簡單的包含關係,要確定包含的領域對象是否為了滿足某個行為或不變性。 - 不要基於用戶界面設計聚合
聚合不應該根據UI界面的需求進行設計。而應該通過載入多個聚合數據映射到UI展示需要的視圖模型中。 - 創建具有唯一標識的聚合根
聚合根作為聚合的網關,通過聚合根完成聚合中領域對象的持久化和檢索。 - 優先使用值對象
聚合根內的其他領域對象優先設計成值對象 - 使用ID關聯,而非對象引用
對象引用不僅會導致聚合邊界的模糊,而且會導致延遲載入的問題。 - 通過唯一標識引用其他聚合
聚合邊界之外的對象不能持有聚合內部對象的引用;聚合內部的領域對象可以持有其他聚合根的引用。 - 避免在聚合內使用依賴註入
對於依賴的對象,我們應該在調用聚合方法之前查找獲取並通過參數傳遞。可以在應用服務中通過依賴註入資源庫或領域服務獲取聚合依賴的對象,然後傳入聚合。 - 使用小聚合
通常,較小的聚合使系統更快且更可靠,因為更少的數據傳輸以及更少的併發衝突。
大聚合會影響性能:聚合的每一個成員都增加了從資料庫載入和保存到資料庫的數據量,直接影響到性能。
大聚合容易導致併發衝突:大的聚合可能有多個職責,意味著它涉及到多個業務用例。我們可以量化一個聚合涉及到的業務用例數,數量越大,設計的聚合邊界越應該被質疑,嘗試將其細化拆解成小聚合。
大聚合擴展性差:聚合的設計要關註可擴展性。大聚合可能會跨越多個資料庫表或文檔,這就在資料庫級別形成了耦合,它將阻礙你對數據子集進行數據遷移。同時,在業務改變時,大聚合不能很好的適應變化。
6.最後
聚合是一個複雜的概念,其正確應用的關鍵是領域對象間關聯關係的把握和領域不變性的理解。其實現的難點在於一致性的維護上:聚合內實現事務一致性,聚合外實現最終一致性。聚合的設計是一個持續性的活動,不可能在初始階段就能設計出完美的聚合,我們應該根據對領域知識的深入和經驗的積累持續改進聚合的設計。