關於領域驅動設計 DDD 的聚合設計的 4 大原則
DDD 社區官網上關於聚合設計的幾個原則
文章地址:http://dddcommunity.org/library/vernon_2011
,該地址中包含了一篇關於介紹如何有效的設計聚合的一些原則,共 3 個 pdf 文件。該文章中指出了以下幾個聚合設計的原則:
-
聚合是用來封裝真正的不變性,而不是簡單的將對象組合在一起;
-
聚合應儘量設計的小;
-
聚合之間的關聯通過 ID,而不是對象引用;
-
聚合內強一致性,聚合之間最終一致性;
上面這幾條原則,作者通過一個例子來逐步闡述。下面我按照我的理解對每個原則做一個簡單的描述。
聚合是用來封裝真正的不變性,而不是簡單的將對象組合在一起
這個原則,就是強調聚合的真正用途除了封裝我們本身所關心的信息外,最主要的目的是爲了封裝業務規則,保證數據的一致性。在我看來,這一點是設計聚合時最重要和最需要考慮的點;當我們在設計聚合時,要多想想當前聚合封裝了哪些業務規則,實現了哪些數據一致性。所謂的業務規則是指,比如一個銀行賬號的餘額不能小於 0,訂單中的訂單明細的個數不能爲 0,訂單中不能出現兩個明細對應的商品 ID 相同,訂單明細中的商品信息必須合法,商品的名稱不能爲空,回覆被創建時必須要傳入被回覆的帖子(因爲沒有帖子的回覆不是一個合法的回覆)等。
聚合應儘量設計的小
這個原則,更多的是從技術的角度去考慮的。作者通過一個例子來說明,該例子中,一開始聚合設計的很大,包含了很多實體,但是後來發現因爲該聚合包含的東西過多,導致多人操作時併發衝突嚴重,導致系統可用性變差;後來開發團隊將原來的大聚合拆分爲多個小聚合,當然,拆分爲小聚合後,原來大聚合內維護的業務規則同樣在多個小聚合上有所體現。所以實現了既能解決併發衝突的問題,也能保證讓聚合來封裝業務規則,實現模型級別的數據一致性;另外,回覆中的一位道友 “” 提到,聚合設計的小還有一個好處,就是:業務決定聚合,業務改變聚合。聚合設計的小除了可以降低併發衝突的可能性之外,同樣減少了業務改變的時候,聚合的拆分個數,降低了聚合大幅重構(拆分)的可能性,從而能讓我們的領域模型更能適應業務的變化。
聚合之間通過 ID 關聯
這個原則,是考慮到,其實聚合之間無需通過對象引用的方式來關聯;
-
首先通過引用關聯,會導致聚合的邊界不夠清晰,如果通過 ID 關聯,由於 ID 是值對象,且值對象正好是用來表達狀態的;所以,可以讓聚合內只包含只屬於自己的實體或值對象,那這樣每個聚合的邊界就很清晰;每個聚合,關心的是自己有什麼信息,自己封裝了什麼業務規則,自己實現了哪些數據一致性;
-
如果通過引用關聯,那需要實現 LazyLoad 的效果,否則當我們加載一個聚合的時候,就會把其關聯的其他聚合也一起加載,而實際上我們有時在加載一個聚合時,不需要用到關聯的那些聚合,所以在這種時候,就給性能帶來一定影響,不過幸好我們現在的 ORM 都支持 LazyLoad,所以這點問題相對不是很大;
-
你可能會問,聚合之間如果通過對象引用來關聯,那聚合之間的交互就比較方便,因爲我可以方便的直接拿到關聯的聚合的引用;是的,這點是沒錯,但是如果聚合之間要交互,在經典 DDD 的架構下,一般可以通過兩種方式解決:1)如果 A 聚合的某個方法需要依賴於 B 聚合對象,則我們可以將 B 聚合對象以參數的方式傳遞給 A 聚合,這樣 A 對 B 沒有屬性上的關聯,而只是參數上的依賴;一般當一個聚合需要直接訪問另一個聚合的情況往往是在職責上表明 A 聚合需要通知 B 聚合做什麼事情或者想從 B 聚合獲取什麼信息以便 A 聚合自己可以實現某種業務邏輯;2)如果兩個聚合之間需要交互,但是這兩個聚合本身只需要關注自己的那部分邏輯即可,典型的例子就是銀行轉賬,在經典 DDD 下,我們一般會設計一個轉賬的領域服務,來協調源賬號和目標賬號之間的轉入和轉出,但源賬號和目標賬號本身只需要關注自己的轉入或轉出邏輯即可。這種情況下,源賬號和目標賬號兩個聚合實例不需要相互關聯引用,只需要引入領域服務來協調跨聚合的邏輯即可;
-
如果一個聚合單單保存另外的聚合的 ID 還不夠,那是否就需要引用另外的聚合了呢?也不必,此時我們可以將當前聚合所需要的外部聚合的信息封裝爲值對象,然後自己聚合該值對象即可。比如經典的訂單的例子就是,訂單聚合了一些訂單明細,每個訂單明細包含了商品 ID、商品名稱、商品價格這三個來自商品聚合的信息;此時我們可以設計一個 ProductInfo 的值對象來包含這些信息,然後訂單明細持有該 ProductInfo 值對象即可;實際上,這裏的 ProductInfo 所包含的商品信息是在訂單生成時對商品信息的狀態的冗餘,訂單生成後,即便商品的價格變了,那訂單明細中包含的 ProductInfo 信息也不會變,因爲這個信息已經完全是訂單聚合內部的東西了,也就是說和商品聚合無關了。
-
實際上通過 ID 關聯,也是達到設計小聚合的目標的一種方式;
聚合內強一致性,聚合之間最終一致性
這個原則主要的背景是:如果用 CQRS+Event Sourcing 的架構來實現 DDD,那聚合之間因爲通過 Domain Event(領域事件)來實現交互了,所以同樣也不需要聚合與聚合之間的對象引用,同時也不需要領域服務了,因爲領域服務已經被 Process(流程聚合根)和 Process Manager(流程管理器,無狀態)所替代。流程聚合根,負責封裝流程的當前狀態以及流程下一步該怎麼走的邏輯,包括流程遇到異常時的回滾處理邏輯;流程管理器,無狀態。負責協調流程中各個參與者聚合根之間的消息交互,它會接受聚合根產生的 domain event,然後發送 command。另外一方面,由於 CQRS 的引入,使得我們的 domain 只需要處理業務邏輯,而不需要應付查詢相關的需求了,各種查詢需求專門由各種查詢服務實現;所以我們的 domain 就可以非常瘦身,僅僅只需要通過聚合根來封裝必要的業務規則(保證聚合內數據的強一致性)即可,然後每個聚合根做了任何的狀態變更後,會產生相應的領域事件,然後事件會被持久化到 EventStore,EventStore 用來持久化所有的事件,整個 domain 的狀態要恢復,只需要通過 Event Sourcing 的方式還原即可;另外,當事件持久化完成後,框架會通過事件總線將事件發佈出去,然後 Process Manager 就可以響應事件,然後發送新的 command 去通知相應的聚合根去做必要的處理;
上面這個過程可以在任何一個 CQRS 的架構圖(包括 enode 的架構圖)中找到,我這裏就不貼圖了。enode 中對經典的轉賬場景用這種思路實現了一下,有興趣可以去下載 enode 源代碼:https://github.com/tangxuehua/enode
,然後看一下其中的 BankTransferSample 這個例子就清楚了。另外,因爲事件的響應和 Command 的發送是異步的,所以,這種架構下,聚合根的交互是異步的;
需要再次強調的一點是,聚合如果只需要關注如何實現業務規則而不需要考慮查詢需求所帶來的好處,那就是我們不需要在 domain 裏維護各種統計信息了,而只要維護各種業務規則所潛在的必須依賴的狀態信息即可;舉個例子,假如一個論壇,有版塊和帖子,以前,我們可能會在版塊對象上有一個帖子總數的屬性,當新增一個帖子時,會對這個屬性加 1;而在 CQRS 架構下,domain 內的版塊聚合根無需維護總帖子數這個統計信息了,總帖子數會在查詢端的數據庫獨立維護;
從聚合和哲學的角度思考,爲什麼需要狀態?
聚合的角度
首先,什麼是狀態?很簡單,比如一個商品的庫存信息,那麼該庫存信息有一個商品的數量這個屬性,表示當前商品在庫存中還有多少件;那麼我們爲什麼需要記錄該屬性呢?也就是爲什麼需要記錄這個狀態呢?因爲有業務規則的存在。以這個例子爲例,因爲存在 “商品的庫存不能爲負數” 這樣的一個業務規則,那這個規則如果要能保證,首先必須先記錄商品的庫存數量;因爲商品的庫存數量是會隨着商品的賣出而減少的,而減少就是通過:Product.Count = Product.Count - 1 這樣的邏輯運算來實現;這個邏輯運算要能運行的前提就是商品要有庫存信息。從這個例子我們不難理解,一個聚合根的很多狀態,不是平白無辜設計上去的,而是某些業務規則潛在的要求,必須要設計這些狀態才能實現相應的業務規則;這樣的例子還有很多,比如銀行賬號的餘額不能小於 0,導致我們的銀行賬號必須要設計一個當前餘額的屬性;
另外一個原因是,看起來像是廢話,呵呵。就是:因爲我們關心這些信息,所以需要設計在當前聚合上;比如,以一個論壇的帖子爲例,作爲一個帖子,我們通常都會關心帖子的標題、描述、發帖人、發帖時間、所屬版塊(如果論壇有版塊這個概念的話);所以,我們就會在帖子聚合根上設計出這些屬性,以表達我們所關心的這些信息的狀態;
哲學的角度
下面在從偏哲學的角度表達一下對象的概念吧:
人類永遠無法認識完整的事物,因爲我們認識到的總是事物的某一方面。我們所說的對象實際上是客觀事物在人頭腦裏的反應,而事物則是不因人的認識發生改變的客觀存在。同樣一根鐵棒,在鋼材生產廠家看來,它是成品;在機械加工廠家看來,它是原料;在廢品站看來,他是商品。成品、原料、商品,這三者擁有不同的屬性,有本質的不同。爲什麼同一事物在不同人的眼裏就截然不同了呢?這是因爲我們總是取對我們有用的方面來認識事物。當這根鐵棒作爲商品時,它的原料屬性依然存在,只是我們不關心了。
所以,總結出來就是,因爲我們關心一個對象的某些方面,所以我們纔會爲他設計某些狀態屬性;
關於聚合的設計的一些思考
上面只是簡單提到,聚合的設計應該多考慮它封裝了哪些業務規則這個問題。下面我想再多講一點我的一些想法:
關於 GRASP 九大模式中的最重要模式:信息專家模式
還是以論壇的帖子爲例,創建一個帖子時,有一個業務規則,那就是帖子的發帖人、標題、描述、所屬板塊(如果論壇有板塊這個概念的話)都不能爲空或無效的值,因爲這些信息只要有任何一個無效,那就意味着被創建出來的帖子是無效的,那就是沒有保證業務規則,也就沒辦法談領域模型的數據一致性了;如果像以往的三層貧血架構,那帖子只是一個數據的載體,不包含任何業務規則,帖子會先被構造一個空的帖子對象出來,然後我們給這個空帖子對象的某些屬性賦值,然後保存該帖子對象到數據庫;這種設計,帖子對象只是一個數據的容器,它完全控制不了自己的狀態,因爲它的狀態都是被別人(如 service)去修改的;這樣的設計,相當於是沒有把業務規則封裝在業務對象內部,而是轉移到了外部 service 中,雖然這樣通常也沒問題,事實上我們大部分人都一直在這麼幹,因爲這樣幹寫代碼很隨意,也很高效,呵呵。
GRASP 九大模式中有一個面向對象的模式叫**「信息專家模式」**,不知道大家有了解過沒有,該模式的描述是:**「將職責分配給擁有執行該職責所需信息的對象」**;這個模式告訴我們,如果一個對象負責維護一些信息,那它就有職責維護好這些信息。體現到對象的屬性上,那就是這個對象的屬性不能被外部隨便更改,對象自己的屬性必須自己負責維護修改。構造函數和普通的方法都會改變對象的狀態,所以,我們對構造函數和對象普通的公共方法,都要秉持這個原則;這點非常重要,否則,如果像貧血模型那樣,那對象就不叫對象了,而只是一個普通的容納數據的容器而已,和數據庫裏的一條記錄也無本質差別了。實際上,在我看來,這也是 DDD 中的聚合區別於貧血模型中的實體的最大的地方。聚合不僅有狀態,還有嚴格維護好自己狀態的各種方法,包括構造函數在內;而貧血模型,則只有狀態,沒有行爲;
關於 DDD 中一個領域對象是否是聚合根的考慮
這個問題,沒有非常清晰的放之四海而皆準的確定方法,我的想法是:
-
首先從我們對領域的最基本的常識方面的理解去思考,該對象是否有獨立的生命週期,如果有,那基本上是聚合根了;
-
如果領域內的一個對象,我們會在後臺有一個獨立的模塊去管理它,那它基本上也是聚合根了;
-
是否有獨立的業務場景會去創建或修改一個對象;
-
如果對象有全局唯一的標識,那它也是聚合根了;
-
如果你不能確定一個對象是否是聚合根的的時候,就先放一下,就先假定它是聚合根也無妨,然後可以先分析一下你已經確定的那些聚合根應該具體聚合哪些信息;也許等你分析清楚其他的那些聚合的範圍後,也推導出了你之前不確定是否是聚合根的那個對象是否應該是聚合根了呢。
關於一個聚合內應該聚合哪些信息的思考
-
把我們所需要關心的屬性設計進去;
-
分析該聚合要封裝和實現哪些業務規則,從而像上面的例子(商品庫存)那樣推導出需要設計哪些屬性狀態到該聚合內;
-
如果我們在創建或修改一個對象時,總是會級聯創建或修改一些級聯信息,比如在一個任務系統,當我們創建一個任務時,可能會上傳一些附件,那這些附件的描述信息(如附件 ID,附件名稱,附件下載地址)就應該被聚合在任務聚合根上;
-
聚合內只需要值對象和內部的實體即可,不需要引用其他的聚合根,引用其他的聚合根只會讓當前聚合的邊界模糊,對其他聚合根的引用應該通過 ID 關聯;
-
聚合內的實體和值對象應該具有相同的生命週期,整個聚合是一個整體,從外部看就像是一個對象一樣,聚合應該遵循同生共死的原則;
關於如何更合理的設計聚合來封裝各種業務規則的思考
這一點在最上面的幾個原則中,實際上已經提到過一點,那就是儘量設計小聚合,這裏的出發點主要是從技術的角度去思考,爲了降低對公共對象(大聚合)的併發修改,從而減小併發衝突的可能性,從而提高系統的可用性(因爲系統用戶不會經常因爲併發衝突而導致它的操作失敗);關於這一點,我還想再舉幾個例子,來說明,其實要實現各種業務規則,可以有多種聚合的設計方式,大聚合只是其中一種;
比如,帖子和回覆,大家都知道一個帖子有多個回覆,沒有帖子,回覆就沒有意義;所以很多人就會認爲帖子應該聚合回覆;但實際上不需要這樣,如果你這樣做了,那對於一個論壇來說,同一個帖子被多個人同時回覆的可能性是非常高的,那這樣的話,多個人同時回覆一個帖子,就會導致多個人同時修改同一個帖子對象,那就導致大家都回復不了,因爲會有併發衝突或者數據庫事務的等待超時,因爲大家都在修改同一個帖子聚合根;實際上如果我們從業務規則的角度去思考一下,那可以發現,其實帖子和回覆之間,只有一個簡單的規則,那就是回覆一旦被創建,那他所對應的帖子不能被修改即可;這樣的話,要實現這個規則其實很簡單,把回覆作爲聚合根,然後把帖子傳入回覆聚合根的構造函數,然後回覆保存帖子 ID,然後回覆將帖子 ID 設置爲不允許外部修改 (private set; 即可),這樣我們就實現了這個業務規則,同時還做到了多人同時推一個帖子回覆時,不會對同一個帖子對象就併發修改,而是每個回覆都是並行的往數據庫插入一條回覆記錄即可;
所以,通過這個例子,我們發現,要實現領域模型內的各種業務規則,方法不止一種,我們除了要從業務角度考慮對象的內聚關係外,還要從技術角度考慮,但是不管從什麼角度考慮,都是以實現所要求的業務規則爲前提;
從這個例子,我們其實還發現了另外一件有意義的事情,那就是一個論壇中,發表帖子和發表回覆是兩個獨立的業務場景;一個人發表了帖子,然後可能過了一段時間,另一個人對該帖子發表了回覆;所以將帖子和回覆都設計爲獨立的很容易理解;這裏雖然帖子和回覆是一對多,回覆離開帖子確實也沒意義,但是將回復設計在帖子內沒任何好處,反而讓系統的可用性降低;相反,像上面提到的關於創建任務時同時上傳一些附件的例子,雖然一個任務也是對應多個附件信息,但是我們發現,人物的附件信息總是隨着任務被創建或修改時,一起被修改的。也就是說,我們沒有獨立的業務場景需要獨立修改任務的某個附件信息;所以,沒有必要將任務的附件信息設計爲獨立聚合根。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/8aCnCXoHML77zISf9-MAmQ