DDD聚合設計原則

0xZzzz發表於2020-09-29

“話說天下大勢,分久必合,合久必分”
                  ——《三國演義》

那到底什麼時候該分?什麼時候該合呢?

前言

領域模型的推演是領域專家和技術同學就對業務的理解和抽象進行討論和碰撞的過程,通常情況下,對一個具有一定複雜度的業務進行建模,模型之間的關係會非常複雜。在包含複雜關聯的模型中,要保證物件修改的一致性是很困難的,我們必須保證緊密關聯的物件組也能保證不變性,而不僅僅只保證各個離散的物件。這個問題實際上是源於模型之中缺乏明確的邊界,而聚合的出現就是在模型層面來為這個問題尋找解決方案。在DDD的眾多戰術設計中,聚合是最不容易理解的一個,這也直接導致了聚合設計的難度偏高,本文我們來理一下,DDD聚合設計都有哪些原則。

一致性原則

在DDD中,聚合是由在一致性邊界內的實體和值物件組成的,建立一個聚合最基本的原則是領域物件的群集必須基於領域不變條件。領域不變條件是指無論何時資料發生變化都必須滿足的一致性原則,這個一致性原則一定要滿足真正的業務規則,不能是開發同學自己想當然的約束條件。在討論到一致性時,我們知道存在多種型別的一致性,其中之一便是事務一致性,同時還存在最終一致性。這裡我們討論領域不變條件時,討論的是事務一致性。例如業務規則中有以下不變條件:x + y = z,那麼當x = 1、y = 2時,z必定等於3,根據這條規則,如果z不為3,那麼我們便違背了領域的不變條件,為了保證z的一致性,我們應該為這些屬性設計一個邊界:

Aggregate {
  int x;
  int y;
  int z;
  methods ...
}

這樣,邊界之內的所有內容組成了一套不變的業務規則,任何操作都不能違背這個規則。一致性原則是設計聚合時要遵守的最基本的原則。

假設我們的問題域是一個支付系統,要求系統可以支援消費者進行多批次支付,類似於雙11預售,消費者可以先付一部分定金,到雙11當天再進行尾款支付。這樣我們的領域模型不但要有支付單,還要有針對每個支付批次的批次單,同一個支付單的各個批次單的支付金額之和要等於支付單的總支付金額,這就是一個領域不變條件,顯然我們需要將支付單和批次單圈在一個聚合範圍內,這就是一個簡單的聚合。
image.png

選擇聚合根

要讓聚合保持一致性,其組成部分就不應該在整個領域模型中共享或者可以訪問服務層。這樣我們就可以避免應用程式的其他部分將聚合置為不一致狀態。但是聚合肯定是需要提供行為方法的,這時我們可以通過為聚合選擇一個實體作為聚合根,之後使用一個聚合的所有內容都應該僅通過其根來產生。

聚合根是一個被選中作為進行聚合的入口的實體,它負責協調對聚合的所有變更,確保呼叫方不會將聚合置為不一致的狀態。它通過委託聚合中的其他實體和值物件來支援聚合滿足領域不變條件。其他領域物件僅作為聚合的一部分存在,它們是概念化整體的一部分。呼叫方不允許繞過聚合根直接訪問聚合的內部結構以及直接與聚合的成員實體進行互動,這樣可能會導致聚合處於不一致狀態。所以在設計聚合根時我們同樣要遵守一定的原則:
1)公開行為介面:在使用實體和其他領域物件時,公開聚合行為是非常可取的,這樣你的模型就能顯示地傳達領域概念。對於一個聚合來說這意味著在根上公開表述性方法以供其他聚合與之互動。聚合根介於聚合的其他成員之間,因而它也是所有外部通訊的入口點。

2)保護內部狀態:通過前面表達的內容,我們不難看出,封裝對於領域結構很重要,所以我們要非常小心的使用我們平時經常使用的getter&setter方法,因為它們很可能會公開聚合的內部,進而就很可能會導致聚合處於不一致狀態。而且會增加應用程式的其他部分對於領域模型的耦合度,從而阻礙我們對領域模型進行重構。

3)只允許根具有全域性標識:我們可能聽說過全域性標識和區域性標識之分,在設計聚合時,只允許聚合根擁有全域性標識,因為可以從聚合外部訪問它,而聚合的其他成員只有區域性標識,因為他們位於聚合內部。

針對我們前面提到的支付系統模型,我們可以選擇支付單作為聚合根,針對批次單的操作我們可以通過暴露支付單的行為方法來完成,例如我們可以編輯支付單的批次,使其從一個批次裂化為兩個批次,外部物件不會直接持有批次單的引用,所有對批次單的操作都要經過支付單,這樣我們就可以在聚合內部維護我們的領域一致性原則,確保領域一致性原則不會被破壞。
image.png

小聚合

前面我們提到,在設計聚合時應該遵守一致性原則,我們可以為需要滿足領域不變條件的實體和值物件設計一個邊界來滿足一致性原則,那是不是說我們可以把限界上下文內的模型都圈到一個大聚合中進行控制呢?這樣既簡單粗暴又能滿足領域不變條件豈不是很完美?答案當然是否定的,這樣聚合就失去它存在的意義了,聚合應該設計的儘量小,大聚合存在很多缺陷。

1)大聚合會降低效能:聚合中的每個成員會增加資料的量,當這些資料需要從資料庫中進行載入的時候,大聚合會增加額外的查詢,導致效能降低。大聚合同樣意味著一次性載入到記憶體的資料量會更多,這個問題即使使用延遲載入也很難解決,如果我們需要執行復雜的查詢,或者需要聚合多個資料表的內容返回給呼叫方,我們可以考慮使用CQRS模式而不是大聚合。

2)大聚合會時常導致事務失敗:大聚合可能包含了很多職責,這意味著它要參與多個業務用例。通常我們會使用OCC(樂觀併發控制)來進行資料庫的併發變更控制,這樣當多個使用者對單個聚合進行變更時,出現併發衝突的機率會變大,從而導致事務失敗的機率變大。

3)大聚合擴充套件性差:大聚合意味著與更多的模型產生依賴關係,這會導致重構和擴充套件的難度增加。

支付系統在支付完成後需要將支付完成的狀態通知到訂單系統,告知這筆訂單已經支付完成,這樣訂單才可以繼續向下流轉到發貨履約流程。那是不是說我們一定要將支付單和訂單放在一個聚合中,然後將兩者的狀態在一個資料庫事務中更新到支付成功狀態呢?當然不需要,支付單和訂單都有各自相對獨立的生命週期,雖然訂單會依賴支付單狀態的推進,但是我們完全沒有必要將它們放在同一個聚合中,將支付單和訂單放在同一個聚合中會導致聚合的資料量變大從而導致載入聚合的成本變高,這樣直接的耦合也會阻礙系統的擴充套件,我們可以使用最終一致性來保證雙方支付狀態的一致性,避免大聚合。
image.png

一個事務只修改一個聚合

前面我們提到,大聚合會導致事務失敗的機率變大,在一個事務中修改多個聚合會存在同樣的問題,我們基於領域不變條件來設計聚合,每次請求應該儘量只在一個聚合例項上執行一個命令方法,如果我們發現需要在一個事務中修改多個聚合,我們可以嘗試與領域專家探討用例尋求新的見解,另外採用最終一致性(可以接受的前提下)是一個很好的解決方案。

這個規則同樣適用於上面的例子,消費者支付成功後,對支付單狀態的修改在一個事務中,我們採用最終一致性方案後,對訂單狀態的修改會在另外一個事務中,互不影響。

通過為唯一標識引用

在設計聚合時,我們可能希望使用物件組合,因為這樣我們可以對聚合中的物件樹進行深度遍歷,一個聚合可以引用另一個聚合的根,但是被引用的聚合不應該放在引用聚合的一致性邊界之內,因為領域物件之間的關係應該僅為行為需要而存在。不支援行為的關係會增加領域模型的複雜性。所以聚合之間的物件引用是不必要的。

在不持有物件引用的情況下,我們是不能修改其他聚合的,因此我們可以避免在同一個事物中修改多個聚合。但是,在領域模型中我們總需要物件之間的關聯關係來完成一些任務,這時我們應該怎麼辦呢?我們應該優先考慮通過全域性唯一標識來引用外部聚合。通過這種方式建立的聚合會變得更小,關聯的聚合是不會即時載入的。模型的效能就會隨之變好,因為它需要更少的載入時間和更小的記憶體,更小的使用記憶體不止在記憶體分配上有好處,對於垃圾回收也是有好處的。

有些人傾向在聚合中使用資源庫(repository)來定位其他聚合。這種技術稱為失聯領域模型(Disconnected Domain Model),這種方式實際上就是延遲載入的一種形式。延遲載入的問題我們前面也提到過了,比較推薦的做法還是在呼叫聚合行為方法之前,通過資源庫、工廠或域服務等方式來獲取所需要的物件,這部分內容可以在應用層做控制,然後分發給聚合進行行為方法的執行。

我們沒有把支付單和訂單放在一個聚合中,但是在支付單支付成功推進訂單狀態的這個過程中,支付單需要知道具體要推進哪一筆訂單,所以我們可以在支付單中維護訂單的唯一標識,以此來建立支付單和訂單的關聯關係。
image.png

在邊界外使用最終一致性

有的時候,我們需要在單次請求中修改多個聚合,我們同時也需要保證模型的一致性,這時我們可以使用最終一致性。這可能會帶來一定的延遲,大部分情況下,這種延遲是可以接受的,我們可以和領域專家去討論,你會發現,只要你的理由是合理的,他們甚至會接受更高的延遲,秒級、分鐘級、小時級甚至天級都是可以的。

實現最終一致性的技術手段有很多,一般我們會將領域事件通過訊息佇列進行傳送,在事件消費的流程中處理對其他聚合的變更。這樣我既可以滿足一個事務只修改一個聚合這一原則,又可以藉助訊息佇列中介軟體提供的失敗重試機制,在事件處理失敗時幫助我們進行失敗重試,完成最終一致性。

關於一致性我們可能會有一個疑問,到底什麼時候該使用事務一致性什麼時候該使用最終一致性呢?這裡有一個簡單且實用的指導原則,對於一個用例,問問是否應該由執行該用例的使用者來保證最終一致性。如果是,請使用事務一致性,當然此時依然要遵守其他聚合原則。如果需要其他使用者或者系統來保證資料一致性,那麼請使用最終一致性。以上原則不僅有助於我們做出決定,還能幫助我們更深入地瞭解自己的領域。它向我們展示了真正的系統不變條件:那些必須使用事務一致性的不變條件。通過領域來理解問題比純粹的技術學習更有價值。

前面我們多次提到通用最終一致性來解決支付單支付成功後推進訂單狀態的問題,這裡我們可以將“支付完成”定義為一個領域事件,在支付單支付完成後,傳送“支付完成”領域事件,事件會攜帶訂單號,訂單接受到這個領域事件後,將自己的狀態推進到支付完成。藉助訊息中介軟體,我們可以將事件處理的流程非同步化,甚至完全在不同的系統中,最終達成一致性。
image.png

總結

如果我們遵循聚合的設計原則,那麼我們便可以獲得很好的一致性,並且建立出高效能高伸縮性的系統,同時還可以捕獲到業務領域中的通用語言,更好的理解業務。領域的建設任重而道遠,我輩仍需努力呀~

相關文章