從壹開始微服務 [ DDD ] 之六 ║聚合 與 聚合根 (下)
前言
哈嘍大家週二好,上次咱們說到了實體與值物件的簡單知識,相信大家也是稍微有些瞭解,其實實體咱們平時用的很多了,基本可以和資料庫表進行聯絡,只不過值物件可能不是很熟悉,值物件簡單來說就是在DDD領域驅動設計中,為了更好的展示領域模型之間的關係,制定的一個物件,它沒有狀態和標識,目的就是為了表示一個值。今天呢本來不想說聚合了,因為網上的資料已經鋪天蓋地,想著開始說領域服務和領域事件了,但是為了本系列的完整性,今天就簡單的說一下聚合和聚合根的理解,,如果你已經很明白了,請指出我說的不足之處,以便可以讓大家知道,如果你還不是很明白,請看過後思考以下幾個問題,領域事件下次再說吧,這樣也就完成了今天的頭腦風暴:
1、什麼是聚合?
2、聚合的作用是什麼?
3、我們平時接觸到聚合了麼?
這裡有一個小Code,大家先看看這三者都是屬於什麼(實體,值物件,聚合/聚合根):
public class Order { public Guid Id; public string OrderNo; public Address Address; public List<OrderItem> Items; //... } public classOrderItem { public string Id; public float Price; public Goods Goods; public int Count; //... } public class Goods { public string Id; public string Name; //... } public class Address {public string Id; public string Country; public string Province; //... }
零、今天完成藍色區塊部分
一、聚合的概念 —— 領域的核心
1、聚合的概念
在DDD領域驅動設計第一次被提出的時候,聚合的概念就隨之而來了,在之前的文章中,我們說到了領域和子領域的劃分,也說了限界上下文的定義,這些都是和我們平時以資料模型為中心所不同的概念,可能理解起來不是很容易,但是至少我們有了這個影子,想象著一個大的領域專案,根據業務來拆分成了多個子領域與上下文,可能不同的上下文中甚至有相似的概念,舉個栗子就是,訂單上下文有商品,物流上下文有貨物,庫存上下文有存貨等等等等,這時候你會發現,其實他們都是指的同一個東西,只不過在不同的上下文中被人為的賦予了不同的概念,有的是實體(庫存),有的是值物件(訂單),但是它們又不是一個概念,因為他們屬於不同的子領域。
這個時候,既然我們從大的方面已經對限界上下文進行分離整合,與之而來的肯定是領域模型的分離(我們肯定不能把每一個表放在一起,也不會把他們都一個個並列排開),那既然有分離肯定就是有聚合,這個時候,聚合就出來了,其實DDD提出聚合的概念是為了保證領域內物件之間的一致性問題,因為我們從上邊也看到了,在不同的地方會存在呼叫關係,當然主要還是子領域內部相互呼叫,
比如建立一個訂單,必然會生成訂單詳情,訂單詳情肯定會有商品資訊,我們在修改商品資訊的時候,肯定就不能影響到這個訂單詳情中的商品資訊。再比如:使用者在下單的時候,會選擇一個地址作為郵寄地址,如果該使用者立刻下另一個訂單,並對自己個人中心的地址進行修改,肯定就不能影響剛剛下單的郵寄地址資訊。
這個時候,聚合就有很強的作用,通過值物件保證了物件之間的一致性。我們平時在開發的時候,雖然沒有用到DDD,肯定也是經常用到聚合,就比如上邊的問題,撇開DDD不談,就平時來說,你肯定不會把商品 id 直接繫結到訂單詳情表中,為外來鍵的,不然會死得很慘。這個時候其實我們就有一些聚合的概念了,因為什麼呢,下單的時候,我們關注訂單領域模型,修改商品的時候,我們關注商品領域模型,這些就是我們說到的聚合,當然一個上下文會有很多聚合,而且聚合要儘可能的細分,那如何正確的區分聚合,以及以什麼為基準,請往下看。
2、我們如何對聚合進行劃分
1、哪些實體或值物件在一起才能夠有效的表達一個領域概念。
比如:訂單模型中,必須有訂單詳情,物流資訊等實體或者值物件,這樣才能完整的表達一個訂單的領域概念,就比如文章開頭中提到的那個Code栗子中,OrderItem、Goods、Address等
2、確定好聚合以後,要確定聚合根
比如:訂單模型中,訂單表就是整個聚合的聚合根。
/// <summary> /// 聚合根 Order /// </summary> public class Order : AggregateRoot { public Guid Id; public string OrderNo; public Address Address;//值物件 public List<OrderItem> Items;//實體集合 //... }
3、物件之間是否必須保持一些固定的規則。
比如:Order(一 個訂單)必須有對應的客戶郵寄資訊,否則就不能稱為一個有效的Order;同理,Order對OrderLineItem有不變性約束,Order也必須至少有一個OrderLineItem(一條訂單明細),否則就不能稱為一個有效的Order;
另外,Order中的任何OrderLineItem的數量都不能為0,否則認為該OrderLineItem是無效 的,同時可以推理出Order也可能是無效的。因為如果允許一個OrderLineItem的數量為0的話,就意味著可能會出現所有 OrderLineItem的數量都為0,這就導致整個Order的總價為0,這是沒有任何意義的,是不允許的,從而導致Order無效;所以,必須要求 Order中所有的OrderLineItem的數量都不能為0;那麼現在可以確定的是Order必須包含一些OrderLineItem,那麼應該是通 過引用的方式還是ID關聯的方式來表達這種包含關係呢?這就需要引出另外一個問題,那就是先要分析出是OrderLineItem是否是一個獨立的聚合 根。回答了這個問題,那麼根據上面的規則就知道應該用物件引用還是用ID關聯了。
那麼OrderLineItem是否是一個獨立的聚合根呢?因為聚合根意 味著是某個聚合的根,而聚合有代表著某個上下文邊界,而一個上下文邊界又代表著某個獨立的業務場景,這個業務場景操作的唯一物件總是該上下文邊界內的聚合 根。想到這裡,我們就可以想想,有沒有什麼場景是會繞開訂單直接對某個訂單明細進行操作的。也就是在這種情況下,我們 是以OrderLineItem為主體,完全是在面向OrderLineItem在做業務操作。有這種業務場景嗎?沒有,我們對 OrderLineItem的所有的操作都是以Order為出發點,我們總是會面向整個Order在做業務操作,比如向Order中增加明細,修改 Order的某個明細對應的商品的購買數量,從Order中移除某個明細,等等類似操作,我們從來不會從OrderlineItem為出發點去執行一些業 務操作;另外,從生命週期的角度去理解,那麼OrderLineItem離開Order沒有任何存在的意義,也就是說OrderLineItem的生命周 期是從屬於Order的。所以,我們可以很確信的回答,OrderLineItem是一個實體。
4、聚合不要設計太大,否則會有效能問題以及業務規則一致性的問題。
對於大聚合,即便可以成功地保持事務一致性,但它可能限制了系統性能和可伸縮性。 系統可能隨著時間可能會有越來越多的需求與使用者,開發與維護的成本我們不應該忽視。
怎樣的聚合才算是"小"聚合呢??
好的做法是使用根實體(Root Entity)來表示聚合,其中只包含最小數量的屬性或值型別屬性。哪些屬性是所需的呢??簡單的答案是:那些必須與其他屬性保持一致的屬性。
比如,Product聚合內的name與description屬性,是需要保持一致的,把它們放在兩個不同的聚合顯然是不恰當的。
5、聚合中的實體和值物件應該具有相同的生命週期,並應該屬於一個業務場景。
比如一個最常見的問題:論壇發帖和回覆如何將裡聚合模型,大家想到這裡,聯想到上邊的訂單和訂單詳情,肯定會peng peng的這樣定義;
/// <summary> /// 聚合根 發帖 /// </summary> public class Post : AggregateRoot { public string PostTitle; public List<Reply> Reply;//回覆 //... } /// <summary> /// 實體 回覆 /// </summary> public class Reply : Entity { public string Content; //... }
這樣初看是沒有什麼問題,很正常呀,發帖子是發回復的聚合根,回覆必須有一個帖子,不然無效,看似合理的地方卻有不合理。
比如,當我要對一個帖子發表回覆時,我取出當前帖子資訊,嗯,這個很對,但是,如果我對回覆進行回覆的時候,那就不好了,我每次還是都要取出整個帶有很多回復的帖子,然後往裡面增加回復,然後儲存整個帖子,因為聚合的一致性要求我們必須這麼做。無論是在場景還是在併發的情況下這是不行的。
如果帖子和回覆在一個聚合內,聚合意味著“修改資料的一個最小單元”,聚合內的所有物件要看成是一個整體最小單元進行儲存。這麼要求是因為聚合的意義是維護聚合內的不變性,資料一致性;
仔細分析我們會發現帖子和回覆之間沒有資料一致性要求。所以不需要設計在同一個聚合內。
從場景的角度,我們有發表帖子,發表回覆,這兩個不同的場景,發表帖子建立的是帖子,而發表回覆建立的是回覆。但是訂單就不一樣,我們有建立訂單,修改訂單這兩個場景。這兩個場景都是圍繞這訂單這個聚合展開的。
所以我們應該把回覆實體也單獨作為一個聚合根來處理:
/// <summary> /// 內容 DTO /// </summary> public class Content { public string Id; public DateTime DatePost; public string Contents; public string Title; //... } /// <summary> /// 聚合根 發帖 /// </summary> public class Post : AggregateRoot { public string OrderNo; //... } /// <summary> /// 聚合根 回覆 /// </summary> public class Reply : AggregateRoot { public string Content; public Post Post;//帖子實體聚合根 //... }
當然這樣的話,我們就不能通過帖子一次性全部把回覆拿出來,我們就只能字單寫邏輯了,比如在應用層,但是這樣不會對領域層造成失血,因為本來就不是領域的問題。
二、聚合是如何聯絡的
如何聯絡,在上文的程式碼中以及由體現了,這裡用文字來說明下,具體的可以參考文中的程式碼
1、聚合根、實體、值物件的區別?
從標識的角度:
聚合根具有全域性的唯一標識,而實體只有在聚合內部有唯一的本地標識,值物件沒有唯一標識,不存在這個值物件或那個值物件的說法;
從是否只讀的角度:
聚合根除了唯一標識外,其他所有狀態資訊都理論上可變;實體是可變的;值物件是隻讀的;
從生命週期的角度:
聚合根有獨立的生命週期,實體的生命週期從屬於其所屬的聚合,實體完全由其所屬的聚合根負責管理維護;值物件無生命週期可言,因為只是一個值;
2、聚合根、實體、值物件物件之間如何建立關聯?
聚合根到聚合根:通過ID關聯;
聚合根到其內部的實體,直接物件引用;
聚合根到值物件,直接物件引用;
實體對其他物件的引用規則:1)能引用其所屬聚合內的聚合根、實體、值物件;2)能引用外部聚合根,但推薦以ID的方式關聯,另外也可以關聯某個外部聚合內的實體,但必須是ID關聯,否則就出現同一個實體的引用被兩個聚合根持有,這是不允許的,一個實體的引用只能被其所屬的聚合根持有;
值物件對其他物件的引用規則:只需確保值物件是隻讀的即可,推薦值物件的所有屬性都儘量是值物件;
3、如何識別聚合與聚合根?
明確含義:一個Bounded Context(界定的上下文)可能包含多個聚合,每個聚合都有一個根實體,叫做聚合根;
識別順序:先找出哪些實體可能是聚合根,再逐個分析每個聚合根的邊界,即該聚合根應該聚合哪些實體或值物件;最後再劃分Bounded Context;
聚合邊界確定法則:根據不變性約束規則(Invariant)。不變性規則有兩類:1)聚合邊界內必須具有哪些資訊,如果沒有這些資訊就不能稱為一個有效的聚合;2)聚合內的某些物件的狀態必須滿足某個業務規則;
1.一個聚合只有一個聚合根,聚合根是可以獨立存在的,聚合中其他實體或值物件依賴與聚合根。
2.只有聚合根才能被外部訪問到,聚合根維護聚合的內部一致性。
三、聚合的一些思考
1、優點
其實整篇文章都是在說的聚合的優點,這裡簡單再概況下:
聚合的出現,很大程度上,幫助了DDD領域驅動設計的全部普及,試想一下,如果沒有聚合和聚合根的思維,單單來說DDD,總感覺不是很舒服,而且領域驅動設計所分的子領域和限界上下文都是從更高的一個層面上來區分的,有的專案甚至只有一個限界上下文,那麼,聚合的思考和使用,就特別的高效,且有必要。
聚合設計的原則應該是聚合內各個有相互關聯的物件之間要保持 不變性!我們平時設計聚合時,一般只考慮到了物件之間的關係,比如看其是否能獨立存在,是否必須依賴與某個其他物件而存在。
2、擔憂
我接觸的DDD中的聚合根的分析設計思路大致是這樣:1、業務本質邏輯分析;2、確認聚合物件間的組成關係;3、所有的讀寫必須沿著這些固有的路徑進行。
這是一種靜態聚合的設計思路。理論上講,似乎沒有什麼問題。但實際上,因為每一個人的思路以及學習能力,甚至是專業領域知識的不同,會導致設計的不合理,特別是按照這個正確的路線設計,如果有偏差,就會達到不同的效果,有時候會事倍功半,反而把罪過強加到DDD領域驅動上,或者增加到聚合上,這也就是大家一直不想去更深層去研究實踐這種思想的原因。
DDD本來就是處理複雜業務邏輯設計問題。我看到大家用DDD去分析一些小專案的時候,往往為誰是聚合根而無法達成共識。這說明每個人對業務認識的角度、深度和廣度都不同,自然得出的聚合根也不同。試想,這樣的情況下,領域模型怎麼保持穩定。
不過這也許不是一個大問題,只要我們用心去經營,去學習,去溝通,一切都不是問題!
四、結語
今天簡單的說了下聚合,明天就正式開始專案開發,到領域服務和領域事件了,不知道你能否回答文章開頭的問題了呢?
/// <summary> /// 聚合根 Order /// 實體有標識ID,有生命週期和狀態,通過ID進行區分 /// 聚合根是一個實體,聚合根的標識ID全域性唯一,聚合根中的實體ID在聚合根內部唯一就行 /// 值物件主要就是值,與狀態,標識無關,沒有生命週期,用來描述實體狀態。 /// </summary> /// 屬性都是值物件 public class Order : AggregateRoot { public Guid Id; public string OrderNo;//值物件 public Address Address;//值物件 public List<OrderItem> Items;//實體集合 //... } /// <summary> /// 實體 OrderItem /// 屬性都是值物件 /// </summary> public class OrderItem : Entity { public float Price; public Goods Goods; public int Count; //... } /// <summary> /// 值物件 Goods /// 屬性都是值物件 /// </summary> public class Goods : ValueObject { public string Name; //... } /// <summary> /// 值物件 Address /// </summary> public class Address : ValueObject { public string Country; public string Province; //... } /// <summary> /// 值物件 /// </summary> public class ValueObject { } /// <summary> /// 領域實體 /// </summary> public class Entity { public string Id; } /// <summary> /// 聚合根的抽象實現類,定義聚合根的公共屬性和行為 /// </summary> public abstract class AggregateRoot : Entity { }