深入理解領域驅動設計中的聚合
簡介:聚合模式是 DDD 的模式結構中較為難於理解的一個,也是 DDD 學習曲線中的一個關鍵障礙。合理地設計聚合,能清晰地表述業務一致性,也更容易帶來清晰的實現,設計不合理的聚合,甚至在設計中沒有聚合的概念,則相反。
作者 | 嵩華
來源 | 阿里技術公眾號
聚合模式是 DDD 的模式結構中較為難於理解的一個,也是 DDD 學習曲線中的一個關鍵障礙。合理地設計聚合,能清晰地表述業務一致性,也更容易帶來清晰的實現,設計不合理的聚合,甚至在設計中沒有聚合的概念,則相反。
聚合的概念並不複雜。本文希望能回到聚合的本質,對聚合的定義和實操給出一些有價值的建議。
一 聚合解決的核心問題是什麼
我們先來看一下在 DDD Reference 中關於聚合的定義。
將實體和值物件劃分為聚合並圍繞著聚合定義邊界。選擇一個實體作為每個聚合的根,並僅允許外部物件持有對聚合根的引用。作為一個整體來定義聚合的屬性和不變數,並把其執行責任賦予聚合根或指定的框架機制。
這是典型的“模式語言”,說明了聚合是什麼,聚合根(aggregation root)是什麼,以及如何使用聚合。但是,模式語言的問題在於過度精煉,如果讀者已經熟悉了這種模式,很容易看懂,但是最需要看懂的、那些尚不夠熟悉這些概念的人,卻容易感到不知所云。為了能深入理解一個模式的本質,我們還是要回到它試圖解決的核心問題上來。
在軟體架構領域有一句名言:
“架構並不由系統的功能決定,而是由系統的非功能屬性決定”。
這句話直白的解釋就是:假如不考慮效能、健壯性、可移植性、可修改性、開發成本、時間約束等因素,用任何的架構、任何的方法,系統的功能總是可以實現的,專案總是能開發完成的,只是開發時間、以後的維護成本、功能擴充套件的容易程度不同罷了。
當然現實絕非如此。我們總是希望系統在可理解、可維護、可擴充套件等方面表現良好,從而多快好省的達成系統背後的業務目標。但是,在現實中,不合理的設計方法有可能增加系統的複雜性。我們先來看一個例子:
假設問題領域是一個企業內部的辦公用品採購系統。
- 企業的員工可以通過該系統提交一個採購請求,一個請求包含了若干數量、若干型別的辦公用品(稱為採購項)。(1)
- 主管負責對採購申請進行審批。(2)
- 審批通過後,系統會根據提供商不同,生成若干訂單。(3)
對同一個問題,存在若干種不同的設計思路,例如以資料庫為中心的設計、面向物件的設計和“正確的 OO”的 DDD 的設計。
如果採用以資料庫為中心的建模方式,首先會進行資料庫設計——我確實看到還有許多團隊仍然在採取這種方法,花費大量的時間進行資料庫結構的討論。為了避免圖表過大,我們僅僅給出了和採購申請相關的表格。結構如下圖所示:
圖1 資料庫視角下的設計
如果直接在資料庫這麼低的設計層次上考慮問題,除了資料庫的設計繁瑣易錯,更重要的是會面臨一些比較複雜的業務規則和資料一致性保證的問題。例如:
- 如果採購請求被刪除,則相應的和該採購請求相關的採購項以及它們之間的關聯都需要被刪除——在資料庫設計中,這種約束可以通過資料庫外來鍵來保證。
- 如果多個使用者在對具有相關關係的資料進行併發處理,則可能涉及到複雜的鎖定機制。例如,如果審批者正在對採購請求進行審批,而採購提交者正在對採購項進行修改,則就有可能導致稽核的資料是過期資料,或者導致採購項更新的失敗。
- 如果同時更新某些相關聯的資料,也可能面臨部分更新成功導致的問題——在資料庫設計中,這類約束則需要通過 transaction 來保證。
確實,每個問題都是有解決方案的,但是,第一,對於模型的討論過早地進入了實現領域,和業務概念脫開了聯絡,不便於持續地和業務人員協作;第二,技術細節和業務規則的細節糾纏在一起,很容易顧此失彼。有沒有一種方案,可以讓我們更多的聚焦於問題領域,而不是深陷到這種技術細節中?
面向物件技術和 ORM(物件-關係對映)有助於我們提高問題的抽象層級。在面向物件的世界中,我們看到的結構是這樣的:
圖2 傳統OO視角下的設計
面向物件的方式提高了抽象層級,忽略了不必要的技術細節,例如已經不需要關心外來鍵、關聯表這些技術細節了。我們需要關心的模型元素的數量減少了,複雜性也相應減少了。只是,業務規則如何保證,在傳統的面向物件方法中並沒有嚴格的實現約束。例如:
從業務角度來看,如果採購申請的審批已經通過,對採購申請的採購項進行再次更新應該是非法的。但是,在面向物件的世界中,你卻沒法阻止程式設計師寫出這樣的程式碼:
...
PurchaseRequest purchaseRequest = getPurchaseRequest(requestId);
PurchaseItem item = purchaseRequest.getItem(itemId);
item.setQuantity(1000);
savePurchaseItem(item);
語句 1 取得了一個採購申請的例項;語句 2 取得了該申請中的一個條目。語句 3 和 4 修改了採購申請條目並儲存。假如採購申請已經審批通過,這種修改豈不是可以輕易突破採購申請的預算?
當然,程式設計師可以在程式碼中加入邏輯檢查來保證一致性:在修改或儲存申請條目前總是檢查 purchaseRequest 的狀態,如果狀態不為草稿就禁止修改。但是,考慮到 PurchaseItem 物件可以在程式碼的任何位置被取出來,且可能在不同的方法間傳遞,如果 OO 設計不當,就可能導致該業務邏輯分散到各處。沒有設計約束,這種檢查的實現並不是一件容易的事情。
讓我們回到本質思考:採購項如果脫離採購請求,它自身的單獨存在有價值嗎?——沒有價值。如果沒有價值:名義上看起來對採購項的修改,本質上是對採購項的修改嗎?還是本質上其實是對採購請求的修改?
如果我們認可“修改採購項也是修改採購請求”這個結論,那麼我們就不應該分開來研究採購項和採購請求,而是應該如下圖所示:
圖3 用聚合封裝物件
我們把“採購請求”和“採購項”組織到一起,看做一個更大的整體,稱為“聚合”。這個聚合內部的業務邏輯,例如“採購申請稽核通過後,不得對採購申請條目進行更改”,應內建於聚合內部。為了實現這一目標,我們約定:對採購項的一切操作(增加、刪除、修改等),都是對採購請求物件的操作。
也就是說:在 DDD 的世界中,從來就不應該存在 savePurchaseItem() 這種方法,而應以 purchaseRequest.modifyPurchaseItem() 和 purchaseRequestRepository.save(purchaseRequest) 取代之。
在新的物件關係中,採購申請負責“把守關隘”(即“聚合根”),採購條目成為了聚合的內部資料。由於聚合現在已經是一個整體,與其相關的操作只能通過採購申請物件進行,業務一致性就可以得到保證。這事實上也是關於物件之間關係的更精確的描述:雖然採購申請和採購項都被建模為物件,但是它們的地位是不對等的。採購項是從屬於採購申請的物件,它們只有是一個整體才有意義。
聚合的本質就是建立了一個比物件粒度更大的邊界,聚集那些緊密關聯的物件,形成了一個業務上的物件整體。使用聚合根作為對外的互動入口,從而保證了多個互相關聯的物件的一致性。合理使用聚合,可以更容易地保證業務規則的一致性,減少了物件之間可能的耦合,提升設計的可理解性,降低出問題的可能性。
所以,通過把物件組織為聚合,在基本的物件層次之上構造了一層新的封裝。封裝簡化了概念,隱藏了細節,在外部需要關心的模型元素數量進一步減少,複雜性下降。但是,封裝邊界的引入也引發了一個新的問題,例如:商品資訊也是採購項的有效部分,應不應該把商品也放入“採購請求”這個聚合呢?提交人和審批人是不是也該放入聚合呢?如果要便利地獲得業務規則的一致性,那豈不是把一切存在業務關聯的物件都應該放在一起更好?如果有些物件應該放入聚合,有些不應該放入聚合,那麼是否存在一個清晰的指導原則?本文在下一節回答這個問題。
二 聚合劃分的原則
聚合作為 DDD 的物件體系中的一層,也同樣應該遵循高內聚、低耦合的原則。本文認為,聚合邊界內的物件應滿足如下的啟發式規則:
- 生命週期一致性
- 問題域一致性
- 場景頻率一致性
- 聚合內的元素儘可能少
1 生命週期一致性
生命週期一致性是指聚合邊界內的物件,和聚合根之間存在“人身依附”關係。即:如果聚合根消失,聚合內的其他元素都應該同時消失。例如,在前述例子中,如果聚合根(採購請求)不存在了,那麼採購項當然也就失去了存在的意義。而商品、作為申請人的使用者等物件,和採購請求之間則不存在此關係。
可以用反證法來證明生命週期一致性:如果一個物件在聚合根消失之後仍然有意義,那麼說明在系統中必然需要存在其他方法訪問該物件。這和聚合的定義相矛盾。所以聚合根內的其他元素必然在聚合根消失後失效。違反生命週期一致性,也會同時帶來實現上的嚴重問題。讓我們一起看一個例子:
其中 User 物件的生命週期和採購申請不一致。現在假如有兩段程式程式碼並行執行:
程式碼 1(例如採購申請的修改)獲得了某個採購申請的物件,對該物件進行了修改,進行儲存。注意由於 User 物件嵌入到了 PurchaseRequest 中,User 物件也會被同時儲存。
r = purchaseRequestRepository.findOne(id);
//...一些修改
purchaseRequestRepository.save(r);
程式碼 2(例如是使用者管理),獲得了該物件對應的審批人的資訊,也進行了修改。
User user = userRepo.findOne(r.getSubmitter().getId());
//...一些修改
userRepo.save(user);
這將會導致一種完全不可接受的後果:對於 User 物件的修改不確定性!因此,對於那些說不清楚是否應該劃入同一個聚合的物件,不妨問一下:這個物件如果離開本聚合的上下文,是否還有單獨存在的價值?如果答案是肯定的,該物件就不應該劃到本聚合中:
- Submitter/Approver 對應的 User 物件脫離了 PurchaseRequest,仍然有單獨存在的理由。
- Product 物件脫離了 PurchaseRequest,是可以單獨存在的。
所以以上兩個物件都不屬於採購申請這個聚合。
2 問題域一致性
第二個原則是問題域一致性。事實上問題域一致是限界上下文(Bounded Context)的約束。聚合作為一種戰術模式,所表示的模型一定會位於同一個限界上下文之內。
雖然原則一說明了物件的生命週期一致性可作為聚合劃分的依據,但是什麼是”一個物件脫離另外一個物件是否有存在的意義“,有時候可能會存在爭議。例如:如果採購申請被刪除,那麼根據此採購申請生成的訂單是否有價值?(由於訂單這個例子可能會陷入另外一種爭論,它可以從業務流程上規避:只要訂單存在,採購申請就不能刪除),讓我們換一個非常近似的例子:
一個線上論壇,使用者可以對論壇上使用者的文章發表評論。文章顯然應該是一個聚合根。如果文章被刪除,那麼,使用者的評論看起來也要同時消失。那麼評論是否可以屬於文章這個聚合?
現在讓我們來考慮評論是否還可能有其他的用途。例如,一個圖書網站,使用者可以對圖書發表評論。如果只是因為文章刪除和評論刪除之間存在邏輯上的關聯,就讓文章聚合持有評論物件,那麼顯然就約束了評論的適用範圍。一目瞭然的事實是,評論這一個概念,在本質上和文章這個概念相去甚遠。所以,我們得到了一個新的、凌駕於原則 1 之上的原則——不屬於同一個問題域的物件,不應該出現在同一個聚合中。對 DDD 熟悉的朋友可能知道,這在 DDD 中對應於限界上下文這一戰略模式。限於文章篇幅,我們在此不過多展開。
圖4 問題域一致性
由於聚合根無法保證聚合之外的一致性,所以我們需要依賴”最終一致性“來實現聚合之間的一致性。例如,在文章刪除的時候,傳送一個文章刪除的訊息。評論系統接收到文章刪除訊息之後,刪除文章對應的評論。
3 場景頻率一致性
依賴於前述兩個原則已經能夠區分出大多數聚合。但是,仍然會存在一些比較複雜的情況。例如,考慮軟體開發中的“產品”和“版本”以及“功能”的關係。“產品”和“版本”算不算是同一個問題域?——這幾個概念之間的關係可能就不如“文章”和“評論”那麼清晰。不過不要緊,我們仍然有一個啟發式規則來規避這種模糊性。這就是“場景頻率一致性”原則。
場景(scenario)是業務用例的具體化描述,反應了使用者使用系統達成業務目標的方式。我們可以觀察這些場景中涉及的領域物件操作,如對領域物件的檢視、修改等。場景操作頻率的一致性是同一聚合內部物件的一個關鍵表徵。經常被同時操作的物件,它們往往屬於同一個聚合。而那些極少被同時關注的物件,一般不應該劃為一個聚合。
以下圖所示的“產品”、“版本”和“功能”這三個概念為例來說明。產品確實包含了很多功能,這些功能通過一系列的版本釋出。但是,在產品層面的操作,例如檢視所有的產品列表,卻並不需要關心特定功能的詳細資訊,也不需要了解特定的某個版本資訊。我們做版本規劃的時候,確實會用到功能列表,但是大多數時候我們並不會去檢視功能詳情,更加不可能在做版本規劃的時候修改功能描述。
圖5 不合適的聚合
根據這一原則,我們劃分出了如下的三個聚合:
圖6 更合理的聚合
基於場景一致性劃分聚合,對於實現也有很大好處。不在同一個場景下操作的物件,放入同一個聚合意味著每次操作一個物件,就需要把其他物件的所有資訊抓取到,這是非常沒有意義的。從實現層次,如果不緊密相關的物件出現在同一個聚合中,會導致它們經常在不同的場景中被併發修改,也增加了這些物件之間衝突的可能性。所以:操作場景不一致的物件,或者說如果一個物件在不同場景下都會被使用,應該考慮把它們分到不同的聚合中。
4 儘量小的聚合
聚合出現的本質是解決一致性問題帶來的複雜性。因此,那麼凡是不破壞以上三個一致性的情況,都沒有必要把它們放到同一個聚合中。僅僅由一個業務概念(即領域模型中的類名及屬性以及後面馬上提到的 Id 物件)構成的聚合在面向物件的世界中是大多數。
根據上述分析,在採購申請的例子中,採購申請、採購申請的一些屬性(如狀態、提交時間等)以及採購項屬於一個聚合。但是,商品、使用者這些不能屬於採購申請這個聚合。這些聚合之間如何關聯起來呢?我們引入一種新的值物件來解決這個問題,如下圖所示。圖中也順便標記了各物件是值物件還是實體物件。
圖7 精化後的聚合封裝
在採購請求這個聚合中,除了採購請求聚合根是實體物件外,其他物件,包括作為對外引用的 Id 物件都是值物件。
對應的程式碼如下:
Id 值物件的引入是一個值得討論的問題。
首先,Id 值物件的引入能斷開聚合,能加快查詢的速度,但是它不可避免的會導致某些場景下,需要對資訊進行第二次查詢,而且無法利用 ORM 的 EagerFetch/LazyFetch 載入機制的遍歷。這是一種損失嗎?簡單地回答是:不是損失。不要貪圖不屬於一個聚合的物件層次巢狀帶來的所謂便利——它引起的麻煩要遠遠多於帶來的益處。這類問題應該由外部服務,例如應用層服務來完成。
其次,為了斷開聚合而額外引入的 Id 值物件,還能算是領域模型或者是 “統一語言” 的一部分嗎?我對這一問題的解釋是:這是 DDD 的實現機制的一部分,它屬於領域模型,但是請把可見性控制在開發團隊。
沒有必要和業務人員溝通這些概念。僅僅使用問題域識別出的實體、值物件、領域服務和領域事件和業務人員進行溝通。Id 值物件、資源庫和工廠以及聚合、聚合根這些概念留給實現人員自己理解和在實現中使用就可以了。它們仍然是領域模型的一部分,它們的存在也仍然是統一語言的一部分,但是正如檢視可以有選擇地忽略部分資訊一樣,這些概念應該在和業務人員的溝通以及業務描述時忽略。
第三,請注意這個 Id 物件引用的只能是其他聚合根的 Id。由於只有聚合根才可能會被外部引用,所以聚合根的 ID 應該做到全域性唯一。聚合內部的物件,無論是實體物件還是值物件,都只需要保證內部的 ID 唯一即可。
三 實現方面的考慮
1 資源庫、工廠面向聚合定義
工廠(Factory)模式、資源庫(Repository)模式都是 DDD 在實現維度的模式。儘管在 DDD Reference 給出的模式關係圖中,工廠、資源庫除了與聚合之間有連線之外,與實體之間也有連線,甚至工廠和值物件之間也有連線,但是,本文認為,這些連線的強度是不同的,價值也是不同的。
工廠模式的存在顯然是為了分離物件的構造與使用,但是在 DDD 的上下文中,它包含了更深層面的意義。聚合內部的物件直接的關係可能是複雜的,業務一致性是需要保證的,那麼使用工廠來構造聚合物件是一種更好的對複雜性的封裝。誠然,工廠模式對於非聚合跟的複雜的體物件和值物件的構造也有價值,但這只是設計或者實現層面的事情,和業務模型扯不上什麼關係。
儘管聚合的工廠和一般物件的工廠都是以工廠模式同名,但是 DDD 以聚合為基本單位設計的 Factory 對於簡化系統的複雜性具有更重要的意義。從設計約束上,在聚合以外,只應該有一個工廠對外可見,那就是聚合的工廠。(領域事件的 Factory 也是有意義的,領域事件離本文的話題稍遠,暫且不做討論)。
資源庫模式也絕非只是意味著持久化,更不是資料庫訪問層,所以不要誤解。資源庫更重要的意義是:資源庫是聚合的倉儲機制,外部世界通過資源庫,而且只能通過資源庫來完成對聚合的訪問。資源庫以聚合的整體管理物件。因此,從設計約束上,一個聚合只能有一個資源庫物件,那就是以聚合根命名的資源庫。除此之外的其他物件,都不應該提供資源庫物件。
圖8 聚合和資源庫
2 程式碼結構與聚合保持一致
細心的讀者肯定已經發現了,在上圖中包的組織方式也是和聚合一致的,並且使用了聚合根的名字作為包名。這是我本人組織程式碼時的慣用方式,把聚合作為程式碼的一個層級(之上當然存在其他層級,例如限界上下文、模組等),把所有屬於該聚合的實體(包含聚合根)物件、值物件、資源庫、工廠等都放入到同一個程式碼包中。程式碼結構和領域模型的結構高度一致,可以降低表示差距,更好的管理物件世界的複雜性。
3 聚合不可跨越部署的邊界
部署的邊界是一個複雜的話題,本文僅就和聚合有關的內容進行討論。首先,如果系統採用了微服務架構,應該保持部署邊界和限界上下文邊界的一致——不要讓部署的粒度大於限界上下文的粒度,這樣可以帶來更好的業務靈活性和可伸縮性。其次,從服務的最小邊界上,不可讓最小邊界小於聚合的粒度,否則會帶來大量的資料的一致性問題——因為微服務之間的一致性一般需要通過最終一致性來保證,如果聚合跨越了部署邊界將會是一致性的災難。曾經在某些書上看到一些關於關於微服務劃分的不甚合理的建議,例如把對每一個物件的增刪改查都做成一個服務。這種建議在我看來是錯誤的。
4 聚合改進了系統性能和可伸縮性
很多人會為 ORM 機制中低效的查詢所困擾。為什麼會這樣?看一下前面的例子就明白了。我們為前述的不正確的聚合的例子加上 Spring JPA 的 Annotation:
由於缺乏聚合的概念,或者不正確的做了一個超大的聚合,那麼每次對 PurchaseRequest 的查詢,都需要從系統抓取大量的物件,耗費了大量的計算資源——也許 User 自己也是一個超大的物件呢?“拔出蘿蔔帶出泥”,效能自然不可能好。
也許有讀者會說,我不用 Eager Fetch,我可以用 Lazy Fetch 啊。是的,這確實對效能上更好一些,但是不幸的是,資料訪問的上下文將不得不一直保留,系統出錯的概率大大增加,也給分散式設計帶來了不便。
小的聚合就完全沒有這個問題了——在這種情形下,每個涉及訪問的物件(事實上就是聚合)不可能很大,而所需的資料又恰如其分的都在,資料完整性和業務完整性就有了保障,還可以方便地進行水平擴充套件,效能和可伸縮性也就同時得到了滿足。
四 總結
建模是我們理解現實世界,簡化問題複雜性的方法之一。聚合作為領域建模的一個層次,通過恰如其分的邊界,實現了資訊隱藏、提高了抽象層級,封裝了緊密關聯的業務邏輯,保證了系統資料的一致性,改進了系統的效能。
本文討論了聚合的定義和價值,概括的說:
- 聚合是面向物件的世界中建模的一個層次。它隱藏了細粒度物件,約束了物件之間的耦合。
- 聚合是一致性的邊界,是對具有緊密關聯關係的物件的封裝。聚合封裝了實體物件和值物件,並且採用其中最重要的一個實體物件作為聚合根。聚合根作為聚合的唯一外部入口,保證了業務規則和資料的一致性。
本文也探討了關於聚合識別的四條啟發式規則,具體是:
- 生命週期一致性
- 問題域一致性
- 場景頻率一致性
- 聚合內的元素儘可能少
從實現角度,資源庫、工廠的粒度應該和聚合的粒度一致,程式碼結構和部署結構也可以和聚合對齊。實現和領域模型保持一致,這也是領域驅動設計作為正確的 OO 的目標和價值所在。
原文連結
本文為阿里雲原創內容,未經允許不得轉載。