深刻理解領域驅動設計中的聚合

簡介:聚合模式是 DDD 的模式結構中較爲難於理解的一個,也是 DDD 學習曲線中的一個關鍵障礙。合理地設計聚合,能清晰地表述業務一致性,也更容易帶來清晰的實現,設計不合理的聚合,甚至在設計中沒有聚合的概念,則相反。

image.png

做者 | 嵩華
來源 | 阿里技術公衆號程序員

聚合模式是 DDD 的模式結構中較爲難於理解的一個,也是 DDD 學習曲線中的一個關鍵障礙。合理地設計聚合,能清晰地表述業務一致性,也更容易帶來清晰的實現,設計不合理的聚合,甚至在設計中沒有聚合的概念,則相反。數據庫

聚合的概念並不複雜。本文但願能回到聚合的本質,對聚合的定義和實操給出一些有價值的建議。架構

一 聚合解決的核心問題是什麼

咱們先來看一下在 DDD Reference 中關於聚合的定義。併發

將實體和值對象劃分爲聚合並圍繞着聚合定義邊界。選擇一個實體做爲每一個聚合的根,並僅容許外部對象持有對聚合根的引用。做爲一個總體來定義聚合的屬性和不變量,並把其執行責任賦予聚合根或指定的框架機制。

這是典型的「模式語言」,說明了聚合是什麼,聚合根(aggregation root)是什麼,以及如何使用聚合。可是,模式語言的問題在於過分精煉,若是讀者已經熟悉了這種模式,很容易看懂,可是最須要看懂的、那些尚不夠熟悉這些概念的人,卻容易感到不知所云。爲了能深刻理解一個模式的本質,咱們仍是要回到它試圖解決的核心問題上來。框架

在軟件架構領域有一句名言:數據庫設計

「架構並不禁系統的功能決定,而是由系統的非功能屬性決定」。

這句話直白的解釋就是:假如不考慮性能、健壯性、可移植性、可修改性、開發成本、時間約束等因素,用任何的架構、任何的方法,系統的功能老是能夠實現的,項目老是能開發完成的,只是開發時間、之後的維護成本、功能擴展的容易程度不一樣罷了。分佈式

固然現實絕非如此。咱們老是但願系統在可理解、可維護、可擴展等方面表現良好,從而多快好省的達成系統背後的業務目標。可是,在現實中,不合理的設計方法有可能增長系統的複雜性。咱們先來看一個例子:微服務

假設問題領域是一個企業內部的辦公用品採購系統。性能

  • 企業的員工能夠經過該系統提交一個採購請求,一個請求包含了若干數量、若干類型的辦公用品(稱爲採購項)。(1)
  • 主管負責對採購申請進行審批。(2)
  • 審批經過後,系統會根據提供商不一樣,生成若干訂單。(3)

對同一個問題,存在若干種不一樣的設計思路,例如以數據庫爲中心的設計、面向對象的設計和「正確的 OO」的 DDD 的設計。學習

若是採用以數據庫爲中心的建模方式,首先會進行數據庫設計——我確實看到還有許多團隊仍然在採起這種方法,花費大量的時間進行數據庫結構的討論。爲了不圖表過大,咱們僅僅給出了和採購申請相關的表格。結構以下圖所示:

image.png

圖1 數據庫視角下的設計

若是直接在數據庫這麼低的設計層次上考慮問題,除了數據庫的設計繁瑣易錯,更重要的是會面臨一些比較複雜的業務規則和數據一致性保證的問題。例如:

  • 若是採購請求被刪除,則相應的和該採購請求相關的採購項以及它們之間的關聯都須要被刪除——在數據庫設計中,這種約束能夠經過數據庫外鍵來保證。
  • 若是多個用戶在對具備相關關係的數據進行併發處理,則可能涉及到複雜的鎖定機制。例如,若是審批者正在對採購請求進行審批,而採購提交者正在對採購項進行修改,則就有可能致使審覈的數據是過時數據,或者致使採購項更新的失敗。
  • 若是同時更新某些相關聯的數據,也可能面臨部分更新成功緻使的問題——在數據庫設計中,這類約束則須要經過 transaction 來保證。

確實,每一個問題都是有解決方案的,可是,第一,對於模型的討論過早地進入了實現領域,和業務概念脫開了聯繫,不便於持續地和業務人員協做;第二,技術細節和業務規則的細節糾纏在一塊兒,很容易顧此失彼。有沒有一種方案,可讓咱們更多的聚焦於問題領域,而不是深陷到這種技術細節中?

面向對象技術和 ORM(對象-關係映射)有助於咱們提升問題的抽象層級。在面向對象的世界中,咱們看到的結構是這樣的:

image.png

圖2 傳統OO視角下的設計

面向對象的方式提升了抽象層級,忽略了沒必要要的技術細節,例如已經不須要關心外鍵、關聯表這些技術細節了。咱們須要關心的模型元素的數量減小了,複雜性也相應減小了。只是,業務規則如何保證,在傳統的面向對象方法中並無嚴格的實現約束。例如:

從業務角度來看,若是採購申請的審批已經經過,對採購申請的採購項進行再次更新應該是非法的。可是,在面向對象的世界中,你卻無法阻止程序員寫出這樣的代碼:

...
PurchaseRequest purchaseRequest = getPurchaseRequest(requestId);
PurchaseItem item = purchaseRequest.getItem(itemId);
item.setQuantity(1000);
savePurchaseItem(item);

語句 1 取得了一個採購申請的實例;語句 2 取得了該申請中的一個條目。語句 3 和 4 修改了採購申請條目並保存。假如採購申請已經審批經過,這種修改豈不是能夠輕易突破採購申請的預算?

固然,程序員能夠在代碼中加入邏輯檢查來保證一致性:在修改或保存申請條目前老是檢查 purchaseRequest 的狀態,若是狀態不爲草稿就禁止修改。可是,考慮到 PurchaseItem 對象能夠在代碼的任何位置被取出來,且可能在不一樣的方法間傳遞,若是 OO 設計不當,就可能致使該業務邏輯分散到各處。沒有設計約束,這種檢查的實現並非一件容易的事情。

讓咱們回到本質思考:採購項若是脫離採購請求,它自身的單獨存在有價值嗎?——沒有價值。若是沒有價值:名義上看起來對採購項的修改,本質上是對採購項的修改嗎?仍是本質上實際上是對採購請求的修改?

若是咱們承認「修改採購項也是修改採購請求」這個結論,那麼咱們就不該該分開來研究採購項和採購請求,而是應該以下圖所示:

image.png

圖3 用聚合封裝對象

咱們把「採購請求」和「採購項」組織到一塊兒,看作一個更大的總體,稱爲「聚合」。這個聚合內部的業務邏輯,例如「採購申請審覈經過後,不得對採購申請條目進行更改」,應內建於聚合內部。爲了實現這一目標,咱們約定:對採購項的一切操做(增長、刪除、修改等),都是對採購請求對象的操做。

也就是說:在 DDD 的世界中,歷來就不該該存在 savePurchaseItem() 這種方法,而應以 purchaseRequest.modifyPurchaseItem() 和 purchaseRequestRepository.save(purchaseRequest) 取代之。

在新的對象關係中,採購申請負責「把守關隘」(即「聚合根」),採購條目成爲了聚合的內部數據。因爲聚合如今已是一個總體,與其相關的操做只能經過採購申請對象進行,業務一致性就能夠獲得保證。這事實上也是關於對象之間關係的更精確的描述:雖然採購申請和採購項都被建模爲對象,可是它們的地位是不對等的。採購項是從屬於採購申請的對象,它們只有是一個總體纔有意義。

聚合的本質就是創建了一個比對象粒度更大的邊界,彙集那些緊密關聯的對象,造成了一個業務上的對象總體。使用聚合根做爲對外的交互入口,從而保證了多個互相關聯的對象的一致性。合理使用聚合,能夠更容易地保證業務規則的一致性,減小了對象之間可能的耦合,提高設計的可理解性,下降出問題的可能性。

因此,經過把對象組織爲聚合,在基本的對象層次之上構造了一層新的封裝。封裝簡化了概念,隱藏了細節,在外部須要關心的模型元素數量進一步減小,複雜性降低。可是,封裝邊界的引入也引起了一個新的問題,例如:商品信息也是採購項的有效部分,應不該該把商品也放入「採購請求」這個聚合呢?提交人和審批人是否是也該放入聚合呢?若是要便利地得到業務規則的一致性,那豈不是把一切存在業務關聯的對象都應該放在一塊兒更好?若是有些對象應該放入聚合,有些不該該放入聚合,那麼是否存在一個清晰的指導原則?本文在下一節回答這個問題。

二 聚合劃分的原則

聚合做爲 DDD 的對象體系中的一層,也一樣應該遵循高內聚、低耦合的原則。本文認爲,聚合邊界內的對象應知足以下的啓發式規則:

  • 生命週期一致性
  • 問題域一致性
  • 場景頻率一致性
  • 聚合內的元素儘量少

1 生命週期一致性

生命週期一致性是指聚合邊界內的對象,和聚合根之間存在「人身依附」關係。即:若是聚合根消失,聚合內的其餘元素都應該同時消失。例如,在前述例子中,若是聚合根(採購請求)不存在了,那麼採購項固然也就失去了存在的意義。而商品、做爲申請人的用戶等對象,和採購請求之間則不存在此關係。

能夠用反證法來證實生命週期一致性:若是一個對象在聚合根消失以後仍然有意義,那麼說明在系統中必然須要存在其餘方法訪問該對象。這和聚合的定義相矛盾。因此聚合根內的其餘元素必然在聚合根消失後失效。違反生命週期一致性,也會同時帶來實現上的嚴重問題。讓咱們一塊兒看一個例子:

image.png

其中 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 中對應於限界上下文這一戰略模式。限於文章篇幅,咱們在此不過多展開。

image.png

圖4 問題域一致性

因爲聚合根沒法保證聚合以外的一致性,因此咱們須要依賴」最終一致性「來實現聚合之間的一致性。例如,在文章刪除的時候,發送一個文章刪除的消息。評論系統接收到文章刪除消息以後,刪除文章對應的評論。

3 場景頻率一致性

依賴於前述兩個原則已經可以區分出大多數聚合。可是,仍然會存在一些比較複雜的狀況。例如,考慮軟件開發中的「產品」和「版本」以及「功能」的關係。「產品」和「版本」算不算是同一個問題域?——這幾個概念之間的關係可能就不如「文章」和「評論」那麼清晰。不過沒關係,咱們仍然有一個啓發式規則來規避這種模糊性。這就是「場景頻率一致性」原則。

場景(scenario)是業務用例的具體化描述,反應了用戶使用系統達成業務目標的方式。咱們能夠觀察這些場景中涉及的領域對象操做,如對領域對象的查看、修改等。場景操做頻率的一致性是同一聚合內部對象的一個關鍵表徵。常常被同時操做的對象,它們每每屬於同一個聚合。而那些極少被同時關注的對象,通常不該該劃爲一個聚合。

如下圖所示的「產品」、「版本」和「功能」這三個概念爲例來講明。產品確實包含了不少功能,這些功能經過一系列的版本發佈。可是,在產品層面的操做,例如查看全部的產品列表,卻並不須要關心特定功能的詳細信息,也不須要了解特定的某個版本信息。咱們作版本規劃的時候,確實會用到功能列表,可是大多數時候咱們並不會去查看功能詳情,更加不可能在作版本規劃的時候修改功能描述。

image.png

圖5 不合適的聚合

根據這一原則,咱們劃分出了以下的三個聚合:

image.png

圖6 更合理的聚合

基於場景一致性劃分聚合,對於實現也有很大好處。不在同一個場景下操做的對象,放入同一個聚合意味着每次操做一個對象,就須要把其餘對象的全部信息抓取到,這是很是沒有意義的。從實現層次,若是不緊密相關的對象出如今同一個聚合中,會致使它們常常在不一樣的場景中被併發修改,也增長了這些對象之間衝突的可能性。因此:操做場景不一致的對象,或者說若是一個對象在不一樣場景下都會被使用,應該考慮把它們分到不一樣的聚合中。

4 儘可能小的聚合

聚合出現的本質是解決一致性問題帶來的複雜性。所以,那麼凡是不破壞以上三個一致性的狀況,都沒有必要把它們放到同一個聚合中。僅僅由一個業務概念(即領域模型中的類名及屬性以及後面立刻提到的 Id 對象)構成的聚合在面向對象的世界中是大多數。

根據上述分析,在採購申請的例子中,採購申請、採購申請的一些屬性(如狀態、提交時間等)以及採購項屬於一個聚合。可是,商品、用戶這些不能屬於採購申請這個聚合。這些聚合之間如何關聯起來呢?咱們引入一種新的值對象來解決這個問題,以下圖所示。圖中也順便標記了各對象是值對象仍是實體對象。

image.png

圖7 精化後的聚合封裝

在採購請求這個聚合中,除了採購請求聚合根是實體對象外,其餘對象,包括做爲對外引用的 Id 對象都是值對象。

對應的代碼以下:

image.png

Id 值對象的引入是一個值得討論的問題。

首先,Id 值對象的引入能斷開聚合,能加快查詢的速度,可是它不可避免的會致使某些場景下,須要對信息進行第二次查詢,並且沒法利用 ORM 的 EagerFetch/LazyFetch 加載機制的遍歷。這是一種損失嗎?簡單地回答是:不是損失。不要貪圖不屬於一個聚合的對象層次嵌套帶來的所謂便利——它引發的麻煩要遠遠多於帶來的益處。這類問題應該由外部服務,例如應用層服務來完成。

其次,爲了斷開聚合而額外引入的 Id 值對象,還能算是領域模型或者是 「統一語言」 的一部分嗎?我對這一問題的解釋是:這是 DDD 的實現機制的一部分,它屬於領域模型,可是請把可見性控制在開發團隊。

沒有必要和業務人員溝通這些概念。僅僅使用問題域識別出的實體、值對象、領域服務和領域事件和業務人員進行溝通。Id 值對象、資源庫和工廠以及聚合、聚合根這些概念留給實現人員本身理解和在實現中使用就能夠了。它們仍然是領域模型的一部分,它們的存在也仍然是統一語言的一部分,可是正如視圖能夠有選擇地忽略部分信息同樣,這些概念應該在和業務人員的溝通以及業務描述時忽略。

第三,請注意這個 Id 對象引用的只能是其餘聚合根的 Id。因爲只有聚合根纔可能會被外部引用,因此聚合根的 ID 應該作到全局惟一。聚合內部的對象,不管是實體對象仍是值對象,都只須要保證內部的 ID 惟一便可。

三 實現方面的考慮

1 資源庫、工廠面向聚合定義

工廠(Factory)模式、資源庫(Repository)模式都是 DDD 在實現維度的模式。儘管在 DDD Reference 給出的模式關係圖中,工廠、資源庫除了與聚合之間有鏈接以外,與實體之間也有鏈接,甚至工廠和值對象之間也有鏈接,可是,本文認爲,這些鏈接的強度是不一樣的,價值也是不一樣的。

工廠模式的存在顯然是爲了分離對象的構造與使用,可是在 DDD 的上下文中,它包含了更深層面的意義。聚合內部的對象直接的關係多是複雜的,業務一致性是須要保證的,那麼使用工廠來構造聚合對象是一種更好的對複雜性的封裝。誠然,工廠模式對於非聚合跟的複雜的體對象和值對象的構造也有價值,但這只是設計或者實現層面的事情,和業務模型扯不上什麼關係。

儘管聚合的工廠和通常對象的工廠都是以工廠模式同名,可是 DDD 以聚合爲基本單位設計的 Factory 對於簡化系統的複雜性具備更重要的意義。從設計約束上,在聚合之外,只應該有一個工廠對外可見,那就是聚合的工廠。(領域事件的 Factory 也是有意義的,領域事件離本文的話題稍遠,暫且不作討論)。

資源庫模式也絕非只是意味着持久化,更不是數據庫訪問層,因此不要誤解。資源庫更重要的意義是:資源庫是聚合的倉儲機制,外部世界經過資源庫,並且只能經過資源庫來完成對聚合的訪問。資源庫以聚合的總體管理對象。所以,從設計約束上,一個聚合只能有一個資源庫對象,那就是以聚合根命名的資源庫。除此以外的其餘對象,都不該該提供資源庫對象。

image.png
圖8 聚合和資源庫

2 代碼結構與聚合保持一致

細心的讀者確定已經發現了,在上圖中包的組織方式也是和聚合一致的,而且使用了聚合根的名字做爲包名。這是我本人組織代碼時的慣用方式,把聚合做爲代碼的一個層級(之上固然存在其餘層級,例如限界上下文、模塊等),把全部屬於該聚合的實體(包含聚合根)對象、值對象、資源庫、工廠等都放入到同一個代碼包中。代碼結構和領域模型的結構高度一致,能夠下降表示差距,更好的管理對象世界的複雜性。

3 聚合不可跨越部署的邊界

部署的邊界是一個複雜的話題,本文僅就和聚合有關的內容進行討論。首先,若是系統採用了微服務架構,應該保持部署邊界和限界上下文邊界的一致——不要讓部署的粒度大於限界上下文的粒度,這樣能夠帶來更好的業務靈活性和可伸縮性。其次,從服務的最小邊界上,不可以讓最小邊界小於聚合的粒度,不然會帶來大量的數據的一致性問題——由於微服務之間的一致性通常須要經過最終一致性來保證,若是聚合跨越了部署邊界將會是一致性的災難。曾經在某些書上看到一些關於關於微服務劃分的不甚合理的建議,例如把對每個對象的增刪改查都作成一個服務。這種建議在我看來是錯誤的。

4 聚合改進了系統性能和可伸縮性

不少人會爲 ORM 機制中低效的查詢所困擾。爲何會這樣?看一下前面的例子就明白了。咱們爲前述的不正確的聚合的例子加上 Spring JPA 的 Annotation:

image.png

因爲缺少聚合的概念,或者不正確的作了一個超大的聚合,那麼每次對 PurchaseRequest 的查詢,都須要從系統抓取大量的對象,耗費了大量的計算資源——也許 User 本身也是一個超大的對象呢?「拔出蘿蔔帶出泥」,性能天然不可能好。

也許有讀者會說,我不用 Eager Fetch,我能夠用 Lazy Fetch 啊。是的,這確實對性能上更好一些,可是不幸的是,數據訪問的上下文將不得不一直保留,系統出錯的機率大大增長,也給分佈式設計帶來了不便。

小的聚合就徹底沒有這個問題了——在這種情形下,每一個涉及訪問的對象(事實上就是聚合)不可能很大,而所需的數據又恰如其分的都在,數據完整性和業務完整性就有了保障,還能夠方便地進行水平擴展,性能和可伸縮性也就同時獲得了知足。

四 總結

建模是咱們理解現實世界,簡化問題複雜性的方法之一。聚合做爲領域建模的一個層次,經過恰如其分的邊界,實現了信息隱藏、提升了抽象層級,封裝了緊密關聯的業務邏輯,保證了系統數據的一致性,改進了系統的性能。

本文討論了聚合的定義和價值,歸納的說:

  • 聚合是面向對象的世界中建模的一個層次。它隱藏了細粒度對象,約束了對象之間的耦合。
  • 聚合是一致性的邊界,是對具備緊密關聯關係的對象的封裝。聚合封裝了實體對象和值對象,而且採用其中最重要的一個實體對象做爲聚合根。聚合根做爲聚合的惟一外部入口,保證了業務規則和數據的一致性。

本文也探討了關於聚合識別的四條啓發式規則,具體是:

  • 生命週期一致性
  • 問題域一致性
  • 場景頻率一致性
  • 聚合內的元素儘量少

從實現角度,資源庫、工廠的粒度應該和聚合的粒度一致,代碼結構和部署結構也能夠和聚合對齊。實現和領域模型保持一致,這也是領域驅動設計做爲正確的 OO 的目標和價值所在。


【2021阿里巴巴研發效能峯會】開放報名

6月23日,阿里巴巴合夥人、IBM副合夥人、德勤雲服務首席架構師、PMI業務副總裁等,近30位海內外大咖分享效能趨勢和實踐,雲原生、低代碼、智能化、將來架構、DevOps、數字化轉型1200分鐘精選乾貨匯聚,和你一塊兒感知行業技術水位,洞悉將來發展態勢。

點擊這裏,免費預定吧~

本文內容由阿里雲實名註冊用戶自發貢獻,版權歸原做者全部,阿里雲開發者社區不擁有其著做權,亦不承擔相應法律責任。具體規則請查看《阿里雲開發者社區用戶服務協議》和《阿里雲開發者社區知識產權保護指引》。若是您發現本社區中有涉嫌抄襲的內容,填寫侵權投訴表單進行舉報,一經查實,本社區將馬上刪除涉嫌侵權內容。
相關文章
相關標籤/搜索