聊一聊 DDD 中聚合的概念

DDD 中的聚合模式是最難弄清楚的一種模式,在如何確定聚合邊界這個問題上,更沒有一個明確的指導原則,這導致 DDD 的落地比較難。不過,相信你讀了這篇文章應該對聚合會有更深刻的理解。

本文分三部分來講:
1、什麼是聚合?
2、聚合解決了什麼問題?
3、聚合的邊界劃分指導原則

  1. 什麼是聚合?

首先我們來看下聚合模式的定義:

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

這是典型的 “模式語言”,說明了聚合是什麼,聚合根(aggregation root)是什麼,以及如何使用聚合。

但是,模式語言的問題在於過度精煉,對於還不熟悉這些模式的人,根本不知所云。爲了能深入理解聚合模式的本質,我們還是要一步步回到聚合試圖解決的問題上來。

  1. 聚合解決了什麼問題?

我們先從一個問題域開始,拿大家都能理解的企業採購系統來舉例:

要設計這樣的一個採購系統,不同人有不同的方法。

2.1 面向數據庫設計

我見過大部分人首先想到的是庫表結構的設計,也就是面向數據庫編程。大部分人都能夠設計出如下的幾張表,如下圖所示:

可能你會問,面向數據庫的設計有什麼問題呢?我一直都是這麼做的啊!你一直這麼做並不代表你的方法就是最合理的。

爲了能夠保證業務規則的正確性和數據一致性,在上面的採購系統中,我們需要考慮如下幾個問題:

雖然上面的問題都有對應的技術解決辦法,但是過早地陷入到技術細節的討論中,會讓我們錯失和業務專家充分討論的機會,而很多業務隱含的概念是在和業務專家協作過程中顯現的;同時技術複雜性和業務複雜性混合在一起,讓我們顧此失彼。

總之,在簡單的場景下,採用面向數據庫的設計簡單直接,能快速實現需求。但是在較複雜的業務場景下,如果一上來就在數據庫這麼低的層次上考慮問題,我們會花大量的時間在表結構的設計上,而沒有重視對重要的業務規則的梳理。隨着業務的快速發展,由於我們最初設計考慮不當,我們會疲於應付不斷出現的新需求和 bug,我們會陷入沉重的泥潭,最後系統只能推倒重來。

那我們有沒有一種方法能夠讓我們聚焦於問題領域,而不是過早地陷入到技術細節中呢?答案就是:面向對象設計

2.2 面向對象設計

面向對象設計有助於我們提高抽象的層級,在面向對象的世界中,我們看到的結構是這樣的:

面向對象的設計方法提高了抽象層級,忽略一些不必要的技術細節(例如不用再關心表的外鍵、表的關聯關係等技術細節了),讓我們能夠更加專注地聚焦到問題領域,同時業務人員也能夠看懂,技術和業務專家也能夠基於統一語言進行持續的交流協作。

但是,業務規則如何保證?在傳統的面向對象的設計中,並沒有很好的方法能夠對業務規則進行約束。例如:

從業務規則上來看,當採購申請審批通過了,就不允許申請者再對採購申請中的採購項進行修改。但是在面向對象的設計中,你沒法阻止程序員寫出如下的代碼:

語句 1 取得了採購申請的實例,語句 2 獲取了該採購申請中的一個採購項,語句 3,4 對採購項的數量進行修改並保存。如果該採購申請已經審批通過了,那這種修改就違背了業務規則。

可能你會說在修改之前,我先對 purchaseRequest 的狀態進行校驗,如果狀態是已審批通過,就不允許修改。加上校驗的代碼如下:

 1PurchaseRequest purchaseRequest = getPurchaseRequest(requestId);
 2PurchaseItem item = purchaseRequest.getItem(itemId);
 3if (purchaseRequest.status == "HAS_APPROVED") {
 4    throw new BizException("採購申請已審批通過,不允許對採購進行修改")
 5}
 6
 7item.setQuantity(1000);
 8savePurchaseItem(item);
 9
10

但是 PurchaseItem 在任何地方都能夠被提取出來,並且 PurchaseItem 對象可以在方法間進行傳遞。

要滿足上述的業務規則,你需要在每個對 PurchaseItem 修改的地方加上上面這段校驗代碼。如果設計不當,那這段校驗邏輯就會散落在各個地方,未來要修改這段校驗邏輯,你需要找出散落的每個地方進行修改,這成本可想而知。

沒有設計上的約束,那要保證業務規則的正確性並不是一件很容易的事。

2.3 面向 DDD 的設計

讓我們回到本質問題:採購項脫離了採購申請有單獨存在的價值嗎?
答案顯然是沒有什麼卵用。既然採購項沒有單獨存在的價值,那對採購項的修改本質上是不是對採購申請的修改?

如果我們認同:‘對採購項的修改就是對採購申請的修改’這個結論,那我們就不應該將採購項和採購申請分開來看待,而應該如下圖所示:

我們把 “採購申請” 和“採購項”看做是一個整體,這個比對象更大粒度的整體就稱爲“聚合”。(講了這麼多,終於看到聚合兩個字了:)

這個聚合內部的業務邏輯,例如 “採購申請審批通過後,不得對採購項進行修改”,應該內建於聚合內部。爲了實現這一目標,我們約定:一切對採購項的操作(增刪改查),都是對採購請求對象的操作。

也就是說,在代碼中從來就不應該出現 savePurchaseItem() 這種方法,應該用 purchaseRequest.modifyPurchaseItem() 和 purchaseRequest.savePurchaseItem() 方法代替。

現在對 purchaseItem 的訪問必須通過 purchaseRequest 對象,purchaseRequest 對象作爲訪問聚合的入口,稱爲 “聚合根”(又是一個重要的概念)。由於聚合是一個整體,對聚合的任何操作只能通過聚合根來進行,從而業務規則在聚合內部得到了保證。

讀到這裏大家大致明白聚合是什麼了吧。

聚合的本質就是建立了比對象粒度更大的邊界,聚合了那些緊密聯繫的對象,形成了一個業務上的整體。使用聚合根作爲對外交互的入口,從而保證了多個互相關聯的對象的一致性。

  1. 聚合的邊界劃分原則

雖然到目前我們大致理解了聚合模式的概念以及聚合模式解決的問題,但聚合的邊界又該如何劃分呢?可能有的人會問:

既然採購項是採購申請這個聚合的一部分,那產品是不是也是該聚合的一部分?如果說是爲了業務規則得到保證,那審批人、提交人都放到採購申請這個聚合豈不是更好?

哪些對象該屬於一個聚合?哪些對象不屬於一個聚合?也就是聚合邊界的劃分問題,有沒有一個可指導的原則呢?

當然有。聚合邊界的劃分可以參考如下幾個指導原則:

3.1 生命週期一致性原則

生命週期一致性是指聚合內部的對象,應該和聚合根具有相同的生命週期,聚合根消失,則聚合內部的所有對象都應該一起消失。

例如,在上面的例子中,聚合根採購請求被刪除,那採購項也就沒有存在的意義,但是申請人、審批人、產品和採購申請卻不存在該關係。

如果違反生命週期一致性原則,會帶來比較嚴重的後果。假如提交人也是採購申請這個聚合中的對象,代碼如下:

1public class PurchaseRequest {
2    private Set<PurchaseItem> items;
3    private User submitter;
4    ...
5}
6
7

其中 User 對象的生命週期和 PurchaseRequest 對象的生命週期不一致。
那麼當保存採購申請對象時,也會保存 User 對象的信息,代碼如下:

1r = purchaseRequestRepository.findOne(id);
2//...一些修改
3purchaseRequestRepository.save(r);
4
5

同時員工管理員也可以對同一個 User 對象進行修改,代碼如下:

1User user = userRepo.findOne(r.getSubmitter().getId());
2//...一些修改
3userRepo.save(user);
4
5

這將導致嚴重的後果:對於 User 對象的修改不確定性!

因此如果不確定是否應該將某個對象劃入某個聚合,你不妨問下:
這個對象離開了這個聚合,是不是還有存在的價值?如果這個對象離開了這個聚合有單獨存在的意義,那就不應該就它劃入這個聚合。

回到上面那個例子:

3.2 問題域一致性原則

上面的生命週期一致性只是指導原則之一,有時如果只考慮生命週期一致性原則可能會引起問題。

讓我們考慮一個在線論壇這樣的場景:

一個在線論壇,用戶可以對論壇上用戶的文章發表評論。文章顯然應該是一個聚合根。如果文章被刪除,那麼,用戶的評論看起來也要同時消失。那麼評論是否可以屬於文章這個聚合?

現在讓我們來考慮評論是否還有其他用處。

例如,用戶可以對用戶的文章發表評論,同時也可以對該論壇的電子圖書發表評論。如果只是因爲文章和評論之間存在邏輯上的關聯,就讓文章聚合持有評論對象,那麼顯然就約束了評論的適用範圍。所以,我們得到了一個新的、凌駕於原則 1 之上的原則——不屬於同一個問題域的對象,不應該出現在同一個聚合中。

在上圖中評論這個聚合根可以持有其他聚合根的 id(可評價對象 id), 同時聚合之間的一致性通過最終一致性來保證(文章刪除發送領域事件通知刪除對應的評論)。

3.3 場景一致性原則

通過上面兩個原則,我們基本能夠劃分清楚一個聚合的邊界,但是仍然會存在一些複雜的情況。這時我們可以根據第三個原則來判斷:場景一致性原則。

什麼是場景一致性呢?場景一致性就是場景操作頻率的一致性。

在很多業務場景中,我們會對領域對象進行查看、修改等各種操作。 經常被同時操作的對象,應該屬於同一個聚合,而那些極少被同時關注的對象,即使上面兩個原則都滿足也不應該劃爲一個聚合。

不在同一個場景下操作的對象,放入同一個聚合意味着每次操作一個對象,就需要把其他對象的所有信息抓取到,這是非常沒有意義的。這在日常開發中我也是深有體會。

從實現層次,如果不緊密相關的對象出現在同一個聚合中,會導致它們經常在不同的場景中被併發修改,也增加了這些對象之間衝突的可能性。

所以:大多數時候的操作場景都不一致的對象,應該把它們分到不同的聚合中

3.4 聚合應儘可能地小

在劃分聚合時,除了應該滿足上面三個指導原則外,我們還應該讓我們的聚合儘可能地小。

通常,較小的聚合會讓一個系統變得更快和更可靠,因爲會傳輸較小的數據並且引發併發衝突的概率會較小。而設計一個大的聚合會帶來各種問題:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YU8REOgagP7fxpGARBi2PA