關(guān)于領(lǐng)域驅(qū)動設(shè)計DDD的聚合設(shè)計的4大原則
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進!你不來,我和你的競爭對手一起精進!
編輯:業(yè)余草
cnblogs.com/netfocus/p/3307971.html
推薦:https://www.xttblog.com/?p=5290
DDD社區(qū)官網(wǎng)上關(guān)于聚合設(shè)計的幾個原則
文章地址:http://dddcommunity.org/library/vernon_2011,該地址中包含了一篇關(guān)于介紹如何有效的設(shè)計聚合的一些原則,共 3 個 pdf 文件。該文章中指出了以下幾個聚合設(shè)計的原則:
聚合是用來封裝真正的不變性,而不是簡單的將對象組合在一起; 聚合應(yīng)盡量設(shè)計的小; 聚合之間的關(guān)聯(lián)通過 ID,而不是對象引用; 聚合內(nèi)強一致性,聚合之間最終一致性;
上面這幾條原則,作者通過一個例子來逐步闡述。下面我按照我的理解對每個原則做一個簡單的描述。

聚合是用來封裝真正的不變性,而不是簡單的將對象組合在一起
這個原則,就是強調(diào)聚合的真正用途除了封裝我們本身所關(guān)心的信息外,最主要的目的是為了封裝業(yè)務(wù)規(guī)則,保證數(shù)據(jù)的一致性。在我看來,這一點是設(shè)計聚合時最重要和最需要考慮的點;當(dāng)我們在設(shè)計聚合時,要多想想當(dāng)前聚合封裝了哪些業(yè)務(wù)規(guī)則,實現(xiàn)了哪些數(shù)據(jù)一致性。所謂的業(yè)務(wù)規(guī)則是指,比如一個銀行賬號的余額不能小于 0,訂單中的訂單明細(xì)的個數(shù)不能為 0,訂單中不能出現(xiàn)兩個明細(xì)對應(yīng)的商品 ID 相同,訂單明細(xì)中的商品信息必須合法,商品的名稱不能為空,回復(fù)被創(chuàng)建時必須要傳入被回復(fù)的帖子(因為沒有帖子的回復(fù)不是一個合法的回復(fù))等。
聚合應(yīng)盡量設(shè)計的小
這個原則,更多的是從技術(shù)的角度去考慮的。作者通過一個例子來說明,該例子中,一開始聚合設(shè)計的很大,包含了很多實體,但是后來發(fā)現(xiàn)因為該聚合包含的東西過多,導(dǎo)致多人操作時并發(fā)沖突嚴(yán)重,導(dǎo)致系統(tǒng)可用性變差;后來開發(fā)團隊將原來的大聚合拆分為多個小聚合,當(dāng)然,拆分為小聚合后,原來大聚合內(nèi)維護的業(yè)務(wù)規(guī)則同樣在多個小聚合上有所體現(xiàn)。所以實現(xiàn)了既能解決并發(fā)沖突的問題,也能保證讓聚合來封裝業(yè)務(wù)規(guī)則,實現(xiàn)模型級別的數(shù)據(jù)一致性;另外,回復(fù)中的一位道友“”提到,聚合設(shè)計的小還有一個好處,就是:業(yè)務(wù)決定聚合,業(yè)務(wù)改變聚合。聚合設(shè)計的小除了可以降低并發(fā)沖突的可能性之外,同樣減少了業(yè)務(wù)改變的時候,聚合的拆分個數(shù),降低了聚合大幅重構(gòu)(拆分)的可能性,從而能讓我們的領(lǐng)域模型更能適應(yīng)業(yè)務(wù)的變化。
聚合之間通過ID關(guān)聯(lián)
這個原則,是考慮到,其實聚合之間無需通過對象引用的方式來關(guān)聯(lián);
首先通過引用關(guān)聯(lián),會導(dǎo)致聚合的邊界不夠清晰,如果通過ID關(guān)聯(lián),由于ID是值對象,且值對象正好是用來表達狀態(tài)的;所以,可以讓聚合內(nèi)只包含只屬于自己的實體或值對象,那這樣每個聚合的邊界就很清晰;每個聚合,關(guān)心的是自己有什么信息,自己封裝了什么業(yè)務(wù)規(guī)則,自己實現(xiàn)了哪些數(shù)據(jù)一致性; 如果通過引用關(guān)聯(lián),那需要實現(xiàn)LazyLoad的效果,否則當(dāng)我們加載一個聚合的時候,就會把其關(guān)聯(lián)的其他聚合也一起加載,而實際上我們有時在加載一個聚合時,不需要用到關(guān)聯(lián)的那些聚合,所以在這種時候,就給性能帶來一定影響,不過幸好我們現(xiàn)在的ORM都支持LazyLoad,所以這點問題相對不是很大; 你可能會問,聚合之間如果通過對象引用來關(guān)聯(lián),那聚合之間的交互就比較方便,因為我可以方便的直接拿到關(guān)聯(lián)的聚合的引用;是的,這點是沒錯,但是如果聚合之間要交互,在經(jīng)典DDD的架構(gòu)下,一般可以通過兩種方式解決:1)如果A聚合的某個方法需要依賴于B聚合對象,則我們可以將B聚合對象以參數(shù)的方式傳遞給A聚合,這樣A對B沒有屬性上的關(guān)聯(lián),而只是參數(shù)上的依賴;一般當(dāng)一個聚合需要直接訪問另一個聚合的情況往往是在職責(zé)上表明A聚合需要通知B聚合做什么事情或者想從B聚合獲取什么信息以便A聚合自己可以實現(xiàn)某種業(yè)務(wù)邏輯;2)如果兩個聚合之間需要交互,但是這兩個聚合本身只需要關(guān)注自己的那部分邏輯即可,典型的例子就是銀行轉(zhuǎn)賬,在經(jīng)典DDD下,我們一般會設(shè)計一個轉(zhuǎn)賬的領(lǐng)域服務(wù),來協(xié)調(diào)源賬號和目標(biāo)賬號之間的轉(zhuǎn)入和轉(zhuǎn)出,但源賬號和目標(biāo)賬號本身只需要關(guān)注自己的轉(zhuǎn)入或轉(zhuǎn)出邏輯即可。這種情況下,源賬號和目標(biāo)賬號兩個聚合實例不需要相互關(guān)聯(lián)引用,只需要引入領(lǐng)域服務(wù)來協(xié)調(diào)跨聚合的邏輯即可; 如果一個聚合單單保存另外的聚合的ID還不夠,那是否就需要引用另外的聚合了呢?也不必,此時我們可以將當(dāng)前聚合所需要的外部聚合的信息封裝為值對象,然后自己聚合該值對象即可。比如經(jīng)典的訂單的例子就是,訂單聚合了一些訂單明細(xì),每個訂單明細(xì)包含了商品ID、商品名稱、商品價格這三個來自商品聚合的信息;此時我們可以設(shè)計一個ProductInfo的值對象來包含這些信息,然后訂單明細(xì)持有該ProductInfo值對象即可;實際上,這里的ProductInfo所包含的商品信息是在訂單生成時對商品信息的狀態(tài)的冗余,訂單生成后,即便商品的價格變了,那訂單明細(xì)中包含的ProductInfo信息也不會變,因為這個信息已經(jīng)完全是訂單聚合內(nèi)部的東西了,也就是說和商品聚合無關(guān)了。 實際上通過ID關(guān)聯(lián),也是達到設(shè)計小聚合的目標(biāo)的一種方式;
聚合內(nèi)強一致性,聚合之間最終一致性
這個原則主要的背景是:如果用CQRS+Event Sourcing的架構(gòu)來實現(xiàn)DDD,那聚合之間因為通過Domain Event(領(lǐng)域事件)來實現(xiàn)交互了,所以同樣也不需要聚合與聚合之間的對象引用,同時也不需要領(lǐng)域服務(wù)了,因為領(lǐng)域服務(wù)已經(jīng)被Process(流程聚合根)和Process Manager(流程管理器,無狀態(tài))所替代。流程聚合根,負(fù)責(zé)封裝流程的當(dāng)前狀態(tài)以及流程下一步該怎么走的邏輯,包括流程遇到異常時的回滾處理邏輯;流程管理器,無狀態(tài)。負(fù)責(zé)協(xié)調(diào)流程中各個參與者聚合根之間的消息交互,它會接受聚合根產(chǎn)生的domain event,然后發(fā)送command。另外一方面,由于CQRS的引入,使得我們的domain只需要處理業(yè)務(wù)邏輯,而不需要應(yīng)付查詢相關(guān)的需求了,各種查詢需求專門由各種查詢服務(wù)實現(xiàn);所以我們的domain就可以非常瘦身,僅僅只需要通過聚合根來封裝必要的業(yè)務(wù)規(guī)則(保證聚合內(nèi)數(shù)據(jù)的強一致性)即可,然后每個聚合根做了任何的狀態(tài)變更后,會產(chǎn)生相應(yīng)的領(lǐng)域事件,然后事件會被持久化到EventStore,EventStore用來持久化所有的事件,整個domain的狀態(tài)要恢復(fù),只需要通過Event Sourcing的方式還原即可;另外,當(dāng)事件持久化完成后,框架會通過事件總線將事件發(fā)布出去,然后Process Manager就可以響應(yīng)事件,然后發(fā)送新的command去通知相應(yīng)的聚合根去做必要的處理;
上面這個過程可以在任何一個 CQRS 的架構(gòu)圖(包括 enode 的架構(gòu)圖)中找到,我這里就不貼圖了。enode中對經(jīng)典的轉(zhuǎn)賬場景用這種思路實現(xiàn)了一下,有興趣可以去下載 enode 源代碼:https://github.com/tangxuehua/enode,然后看一下其中的 BankTransferSample 這個例子就清楚了。另外,因為事件的響應(yīng)和 Command 的發(fā)送是異步的,所以,這種架構(gòu)下,聚合根的交互是異步的;
需要再次強調(diào)的一點是,聚合如果只需要關(guān)注如何實現(xiàn)業(yè)務(wù)規(guī)則而不需要考慮查詢需求所帶來的好處,那就是我們不需要在 domain 里維護各種統(tǒng)計信息了,而只要維護各種業(yè)務(wù)規(guī)則所潛在的必須依賴的狀態(tài)信息即可;舉個例子,假如一個論壇,有版塊和帖子,以前,我們可能會在版塊對象上有一個帖子總數(shù)的屬性,當(dāng)新增一個帖子時,會對這個屬性加 1;而在 CQRS 架構(gòu)下,domain 內(nèi)的版塊聚合根無需維護總帖子數(shù)這個統(tǒng)計信息了,總帖子數(shù)會在查詢端的數(shù)據(jù)庫獨立維護;
從聚合和哲學(xué)的角度思考,為什么需要狀態(tài)?
聚合的角度
首先,什么是狀態(tài)?很簡單,比如一個商品的庫存信息,那么該庫存信息有一個商品的數(shù)量這個屬性,表示當(dāng)前商品在庫存中還有多少件;那么我們?yōu)槭裁葱枰涗浽搶傩阅??也就是為什么需要記錄這個狀態(tài)呢?因為有業(yè)務(wù)規(guī)則的存在。以這個例子為例,因為存在“商品的庫存不能為負(fù)數(shù)”這樣的一個業(yè)務(wù)規(guī)則,那這個規(guī)則如果要能保證,首先必須先記錄商品的庫存數(shù)量;因為商品的庫存數(shù)量是會隨著商品的賣出而減少的,而減少就是通過:Product.Count = Product.Count - 1這樣的邏輯運算來實現(xiàn);這個邏輯運算要能運行的前提就是商品要有庫存信息。從這個例子我們不難理解,一個聚合根的很多狀態(tài),不是平白無辜設(shè)計上去的,而是某些業(yè)務(wù)規(guī)則潛在的要求,必須要設(shè)計這些狀態(tài)才能實現(xiàn)相應(yīng)的業(yè)務(wù)規(guī)則;這樣的例子還有很多,比如銀行賬號的余額不能小于0,導(dǎo)致我們的銀行賬號必須要設(shè)計一個當(dāng)前余額的屬性;
另外一個原因是,看起來像是廢話,呵呵。就是:因為我們關(guān)心這些信息,所以需要設(shè)計在當(dāng)前聚合上;比如,以一個論壇的帖子為例,作為一個帖子,我們通常都會關(guān)心帖子的標(biāo)題、描述、發(fā)帖人、發(fā)帖時間、所屬版塊(如果論壇有版塊這個概念的話);所以,我們就會在帖子聚合根上設(shè)計出這些屬性,以表達我們所關(guān)心的這些信息的狀態(tài);
哲學(xué)的角度
下面在從偏哲學(xué)的角度表達一下對象的概念吧:
人類永遠無法認(rèn)識完整的事物,因為我們認(rèn)識到的總是事物的某一方面。我們所說的對象實際上是客觀事物在人頭腦里的反應(yīng),而事物則是不因人的認(rèn)識發(fā)生改變的客觀存在。同樣一根鐵棒,在鋼材生產(chǎn)廠家看來,它是成品;在機械加工廠家看來,它是原料;在廢品站看來,他是商品。成品、原料、商品,這三者擁有不同的屬性,有本質(zhì)的不同。為什么同一事物在不同人的眼里就截然不同了呢?這是因為我們總是取對我們有用的方面來認(rèn)識事物。當(dāng)這根鐵棒作為商品時,它的原料屬性依然存在,只是我們不關(guān)心了。
所以,總結(jié)出來就是,因為我們關(guān)心一個對象的某些方面,所以我們才會為他設(shè)計某些狀態(tài)屬性;
關(guān)于聚合的設(shè)計的一些思考
上面只是簡單提到,聚合的設(shè)計應(yīng)該多考慮它封裝了哪些業(yè)務(wù)規(guī)則這個問題。下面我想再多講一點我的一些想法:
關(guān)于GRASP九大模式中的最重要模式:信息專家模式
還是以論壇的帖子為例,創(chuàng)建一個帖子時,有一個業(yè)務(wù)規(guī)則,那就是帖子的發(fā)帖人、標(biāo)題、描述、所屬板塊(如果論壇有板塊這個概念的話)都不能為空或無效的值,因為這些信息只要有任何一個無效,那就意味著被創(chuàng)建出來的帖子是無效的,那就是沒有保證業(yè)務(wù)規(guī)則,也就沒辦法談領(lǐng)域模型的數(shù)據(jù)一致性了;如果像以往的三層貧血架構(gòu),那帖子只是一個數(shù)據(jù)的載體,不包含任何業(yè)務(wù)規(guī)則,帖子會先被構(gòu)造一個空的帖子對象出來,然后我們給這個空帖子對象的某些屬性賦值,然后保存該帖子對象到數(shù)據(jù)庫;這種設(shè)計,帖子對象只是一個數(shù)據(jù)的容器,它完全控制不了自己的狀態(tài),因為它的狀態(tài)都是被別人(如service)去修改的;這樣的設(shè)計,相當(dāng)于是沒有把業(yè)務(wù)規(guī)則封裝在業(yè)務(wù)對象內(nèi)部,而是轉(zhuǎn)移到了外部service中,雖然這樣通常也沒問題,事實上我們大部分人都一直在這么干,因為這樣干寫代碼很隨意,也很高效,呵呵。
GRASP 九大模式中有一個面向?qū)ο蟮哪J浇?strong style="color: rgb(53, 148, 247);">「信息專家模式」,不知道大家有了解過沒有,該模式的描述是:「將職責(zé)分配給擁有執(zhí)行該職責(zé)所需信息的對象」;這個模式告訴我們,如果一個對象負(fù)責(zé)維護一些信息,那它就有職責(zé)維護好這些信息。體現(xiàn)到對象的屬性上,那就是這個對象的屬性不能被外部隨便更改,對象自己的屬性必須自己負(fù)責(zé)維護修改。構(gòu)造函數(shù)和普通的方法都會改變對象的狀態(tài),所以,我們對構(gòu)造函數(shù)和對象普通的公共方法,都要秉持這個原則;這點非常重要,否則,如果像貧血模型那樣,那對象就不叫對象了,而只是一個普通的容納數(shù)據(jù)的容器而已,和數(shù)據(jù)庫里的一條記錄也無本質(zhì)差別了。實際上,在我看來,這也是DDD中的聚合區(qū)別于貧血模型中的實體的最大的地方。聚合不僅有狀態(tài),還有嚴(yán)格維護好自己狀態(tài)的各種方法,包括構(gòu)造函數(shù)在內(nèi);而貧血模型,則只有狀態(tài),沒有行為;
關(guān)于DDD中一個領(lǐng)域?qū)ο笫欠袷蔷酆细目紤]
這個問題,沒有非常清晰的放之四海而皆準(zhǔn)的確定方法,我的想法是:
首先從我們對領(lǐng)域的最基本的常識方面的理解去思考,該對象是否有獨立的生命周期,如果有,那基本上是聚合根了; 如果領(lǐng)域內(nèi)的一個對象,我們會在后臺有一個獨立的模塊去管理它,那它基本上也是聚合根了; 是否有獨立的業(yè)務(wù)場景會去創(chuàng)建或修改一個對象; 如果對象有全局唯一的標(biāo)識,那它也是聚合根了; 如果你不能確定一個對象是否是聚合根的的時候,就先放一下,就先假定它是聚合根也無妨,然后可以先分析一下你已經(jīng)確定的那些聚合根應(yīng)該具體聚合哪些信息;也許等你分析清楚其他的那些聚合的范圍后,也推導(dǎo)出了你之前不確定是否是聚合根的那個對象是否應(yīng)該是聚合根了呢。
關(guān)于一個聚合內(nèi)應(yīng)該聚合哪些信息的思考
把我們所需要關(guān)心的屬性設(shè)計進去; 分析該聚合要封裝和實現(xiàn)哪些業(yè)務(wù)規(guī)則,從而像上面的例子(商品庫存)那樣推導(dǎo)出需要設(shè)計哪些屬性狀態(tài)到該聚合內(nèi); 如果我們在創(chuàng)建或修改一個對象時,總是會級聯(lián)創(chuàng)建或修改一些級聯(lián)信息,比如在一個任務(wù)系統(tǒng),當(dāng)我們創(chuàng)建一個任務(wù)時,可能會上傳一些附件,那這些附件的描述信息(如附件ID,附件名稱,附件下載地址)就應(yīng)該被聚合在任務(wù)聚合根上; 聚合內(nèi)只需要值對象和內(nèi)部的實體即可,不需要引用其他的聚合根,引用其他的聚合根只會讓當(dāng)前聚合的邊界模糊,對其他聚合根的引用應(yīng)該通過ID關(guān)聯(lián); 聚合內(nèi)的實體和值對象應(yīng)該具有相同的生命周期,整個聚合是一個整體,從外部看就像是一個對象一樣,聚合應(yīng)該遵循同生共死的原則;
關(guān)于如何更合理的設(shè)計聚合來封裝各種業(yè)務(wù)規(guī)則的思考
這一點在最上面的幾個原則中,實際上已經(jīng)提到過一點,那就是盡量設(shè)計小聚合,這里的出發(fā)點主要是從技術(shù)的角度去思考,為了降低對公共對象(大聚合)的并發(fā)修改,從而減小并發(fā)沖突的可能性,從而提高系統(tǒng)的可用性(因為系統(tǒng)用戶不會經(jīng)常因為并發(fā)沖突而導(dǎo)致它的操作失敗);關(guān)于這一點,我還想再舉幾個例子,來說明,其實要實現(xiàn)各種業(yè)務(wù)規(guī)則,可以有多種聚合的設(shè)計方式,大聚合只是其中一種;
比如,帖子和回復(fù),大家都知道一個帖子有多個回復(fù),沒有帖子,回復(fù)就沒有意義;所以很多人就會認(rèn)為帖子應(yīng)該聚合回復(fù);但實際上不需要這樣,如果你這樣做了,那對于一個論壇來說,同一個帖子被多個人同時回復(fù)的可能性是非常高的,那這樣的話,多個人同時回復(fù)一個帖子,就會導(dǎo)致多個人同時修改同一個帖子對象,那就導(dǎo)致大家都回復(fù)不了,因為會有并發(fā)沖突或者數(shù)據(jù)庫事務(wù)的等待超時,因為大家都在修改同一個帖子聚合根;實際上如果我們從業(yè)務(wù)規(guī)則的角度去思考一下,那可以發(fā)現(xiàn),其實帖子和回復(fù)之間,只有一個簡單的規(guī)則,那就是回復(fù)一旦被創(chuàng)建,那他所對應(yīng)的帖子不能被修改即可;這樣的話,要實現(xiàn)這個規(guī)則其實很簡單,把回復(fù)作為聚合根,然后把帖子傳入回復(fù)聚合根的構(gòu)造函數(shù),然后回復(fù)保存帖子ID,然后回復(fù)將帖子ID設(shè)置為不允許外部修改(private set;即可),這樣我們就實現(xiàn)了這個業(yè)務(wù)規(guī)則,同時還做到了多人同時推一個帖子回復(fù)時,不會對同一個帖子對象就并發(fā)修改,而是每個回復(fù)都是并行的往數(shù)據(jù)庫插入一條回復(fù)記錄即可;
所以,通過這個例子,我們發(fā)現(xiàn),要實現(xiàn)領(lǐng)域模型內(nèi)的各種業(yè)務(wù)規(guī)則,方法不止一種,我們除了要從業(yè)務(wù)角度考慮對象的內(nèi)聚關(guān)系外,還要從技術(shù)角度考慮,但是不管從什么角度考慮,都是以實現(xiàn)所要求的業(yè)務(wù)規(guī)則為前提;
從這個例子,我們其實還發(fā)現(xiàn)了另外一件有意義的事情,那就是一個論壇中,發(fā)表帖子和發(fā)表回復(fù)是兩個獨立的業(yè)務(wù)場景;一個人發(fā)表了帖子,然后可能過了一段時間,另一個人對該帖子發(fā)表了回復(fù);所以將帖子和回復(fù)都設(shè)計為獨立的很容易理解;這里雖然帖子和回復(fù)是一對多,回復(fù)離開帖子確實也沒意義,但是將回復(fù)設(shè)計在帖子內(nèi)沒任何好處,反而讓系統(tǒng)的可用性降低;相反,像上面提到的關(guān)于創(chuàng)建任務(wù)時同時上傳一些附件的例子,雖然一個任務(wù)也是對應(yīng)多個附件信息,但是我們發(fā)現(xiàn),人物的附件信息總是隨著任務(wù)被創(chuàng)建或修改時,一起被修改的。也就是說,我們沒有獨立的業(yè)務(wù)場景需要獨立修改任務(wù)的某個附件信息;所以,沒有必要將任務(wù)的附件信息設(shè)計為獨立聚合根。
