天天看點

關于領域驅動設計(DDD)中聚合設計的一些思考DDD社群官網上一篇關于聚合設計的幾個原則的簡單讨論:從聚合和哲學的角度思考,為什麼需要狀态?關于聚合的設計的一些思考

聚合是用來封裝真正的不變性,而不是簡單的将對象組合在一起;

聚合應盡量設計的小;

聚合之間的關聯通過id,而不是對象引用;

聚合内強一緻性,聚合之間最終一緻性;

上面這幾條原則,作者通過一個例子來逐漸闡述。下面我按照我的了解對每個原則做一個簡單的描述。

這個原則,就是強調聚合的真正用途除了封裝我們本身所關心的資訊外,最主要的目的是為了封裝業務規則,保證資料的一緻性。在我看來,這一點是設計聚合時最重要和最需要考慮的點;當我們在設計聚合時,要多想想目前聚合封裝了哪些業務規則,實作了哪些資料一緻性。所謂的業務規則是指,比如一個銀行賬号的餘額不能小于0,訂單中的訂單明細的個數不能為0,訂單中不能出現兩個明細對應的商品id相同,訂單明細中的商品資訊必須合法,商品的名稱不能為空,回複被建立時必須要傳入被回複的文章(因為沒有文章的回複不是一個合法的回複),等;

這個原則,是考慮到,其實聚合之間無需通過對象引用的方式來關聯;

首先通過引用關聯,會導緻聚合的邊界不夠清晰,如果通過id關聯,由于id是值對象,且值對象正好是用來表達狀态的;是以,可以讓聚合内隻包含隻屬于自己的實體或值對象,那這樣每個聚合的邊界就很清晰;每個聚合,關心的是自己有什麼資訊,自己封裝了什麼業務規則,自己實作了哪些資料一緻性;

如果通過引用關聯,那需要實作lazyload的效果,否則當我們加載一個聚合的時候,就會把其關聯的其他聚合也一起加載,而實際上我們有時在加載一個聚合時,不需要用到關聯的那些聚合,是以在這種時候,就給性能帶來一定影響,不過幸好我們現在的orm都支援lazyload,是以這點問題相對不是很大;

你可能會問,聚合之間如果通過對象引用來關聯,那聚合之間的互動就比較友善,因為我可以友善的直接拿到關聯的聚合的引用;是的,這點是沒錯,但是如果聚合之間要互動,在經典ddd的架構下,一般可以通過兩種方式解決:1)如果a聚合的某個方法需要依賴于b聚合對象,則我們可以将b聚合對象以參數的方式傳遞給a聚合,這樣a對b沒有屬性上的關聯,而隻是參數上的依賴;一般當一個聚合需要直接通路另一個聚合的情況往往是在職責上表明a聚合需要通知b聚合做什麼事情或者想從b聚合擷取什麼資訊以便a聚合自己可以實作某種業務邏輯;2)如果兩個聚合之間需要互動,但是這兩個聚合本身隻需要關注自己的那部分邏輯即可,典型的例子就是銀行轉賬,在經典ddd下,我們一般會設計一個轉賬的領域服務,來協調源賬号和目标賬号之間的轉入和轉出,但源賬号和目标賬号本身隻需要關注自己的轉入或轉出邏輯即可。這種情況下,源賬号和目标賬号兩個聚合執行個體不需要互相關聯引用,隻需要引入領域服務來協調跨聚合的邏輯即可;

如果一個聚合單單儲存另外的聚合的id還不夠,那是否就需要引用另外的聚合了呢?也不必,此時我們可以将目前聚合所需要的外部聚合的資訊封裝為值對象,然後自己聚合該值對象即可。比如經典的訂單的例子就是,訂單聚合了一些訂單明細,每個訂單明細包含了商品id、商品名稱、商品價格這三個來自商品聚合的資訊;此時我們可以設計一個productinfo的值對象來包含這些資訊,然後訂單明細持有該productinfo值對象即可;實際上,這裡的productinfo所包含的商品資訊是在訂單生成時對商品資訊的狀态的備援,訂單生成後,即便商品的價格變了,那訂單明細中包含的productinfo資訊也不會變,因為這個資訊已經完全是訂單聚合内部的東西了,也就是說和商品聚合無關了。

實際上通過id關聯,也是達到設計小聚合的目标的一種方式;

這個原則主要的背景是:如果用cqrs+event sourcing的架構來實作ddd,那聚合之間因為通過domain event(領域事件)來實作互動了,是以同樣也不需要聚合與聚合之間的對象引用,同時也不需要領域服務了,因為領域服務已經被process(流程聚合根)和process manager(流程管理器,無狀态)所替代。流程聚合根,負責封裝流程的目前狀态以及流程下一步該怎麼走的邏輯,包括流程遇到異常時的復原處理邏輯;流程管理器,無狀态。負責協調流程中各個參與者聚合根之間的消息互動,它會接受聚合根産生的domain event,然後發送command。另外一方面,由于cqrs的引入,使得我們的domain隻需要處理業務邏輯,而不需要應付查詢相關的需求了,各種查詢需求專門由各種查詢服務實作;是以我們的domain就可以非常瘦身,僅僅隻需要通過聚合根來封裝必要的業務規則(保證聚合内資料的強一緻性)即可,然後每個聚合根做了任何的狀态變更後,會産生相應的領域事件,然後事件會被持久化到eventstore,eventstore用來持久化所有的事件,整個domain的狀态要恢複,隻需要通過event sourcing的方式還原即可;另外,當事件持久化完成後,架構會通過事件總線将事件釋出出去,然後process manager就可以響應事件,然後發送新的command去通知相應的聚合根去做必要的處理;

需要再次強調的一點是,聚合如果隻需要關注如何實作業務規則而不需要考慮查詢需求所帶來的好處,那就是我們不需要在domain裡維護各種統計資訊了,而隻要維護各種業務規則所潛在的必須依賴的狀态資訊即可;舉個例子,假如一個論壇,有版塊和文章,以前,我們可能會在版塊對象上有一個文章總數的屬性,當新增一個文章時,會對這個屬性加1;而在cqrs架構下,domain内的版塊聚合根無需維護總文章數這個統計資訊了,總文章數會在查詢端的資料庫獨立維護;

首先,什麼是狀态?很簡單,比如一個商品的庫存資訊,那麼該庫存資訊有一個商品的數量這個屬性,表示目前商品在庫存中還有多少件;那麼我們為什麼需要記錄該屬性呢?也就是為什麼需要記錄這個狀态呢?因為有業務規則的存在。以這個例子為例,因為存在“商品的庫存不能為負數”這樣的一個業務規則,那這個規則如果要能保證,首先必須先記錄商品的庫存數量;因為商品的庫存數量是會随着商品的賣出而減少的,而減少就是通過:product.count = product.count - 1這樣的邏輯運算來實作;這個邏輯運算要能運作的前提就是商品要有庫存資訊。從這個例子我們不難了解,一個聚合根的很多狀态,不是平白無辜設計上去的,而是某些業務規則潛在的要求,必須要設計這些狀态才能實作相應的業務規則;這樣的例子還有很多,比如銀行賬号的餘額不能小于0,導緻我們的銀行賬号必須要設計一個目前餘額的屬性;

另外一個原因是,看起來像是廢話,呵呵。就是:因為我們關心這些資訊,是以需要設計在目前聚合上;比如,以一個論壇的文章為例,作為一個文章,我們通常都會關心文章的标題、描述、發帖人、發帖時間、所屬版塊(如果論壇有版塊這個概念的話);是以,我們就會在文章聚合根上設計出這些屬性,以表達我們所關心的這些資訊的狀态;

下面在從偏哲學的角度表達一下對象的概念吧:

人類永遠無法認識完整的事物,因為我們認識到的總是事物的某一方面。我們所說的對象實際上是客觀事物在人頭腦裡的反應,而事物則是不因人的認識發生改變的客觀存在。同樣一根鐵棒,在鋼材生産廠家看來,它是成品;在機械加工廠家看來,它是原料;在廢品站看來,他是商品。成品、原料、商品,這三者擁有不同的屬性,有本質的不同。為什麼同一事物在不同人的眼裡就截然不同了呢?這是因為我們總是取對我們有用的方面來認識事物。當這根鐵棒作為商品時,它的原料屬性依然存在,隻是我們不關心了。

是以,總結出來就是,因為我們關心一個對象的某些方面,是以我們才會為他設計某些狀态屬性;

上面隻是簡單提到,聚合的設計應該多考慮它封裝了哪些業務規則這個問題。下面我想再多講一點我的一些想法:

還是以論壇的文章為例,建立一個文章時,有一個業務規則,那就是文章的發帖人、标題、描述、所屬闆塊(如果論壇有闆塊這個概念的話)都不能為空或無效的值,因為這些資訊隻要有任何一個無效,那就意味着被建立出來的文章是無效的,那就是沒有保證業務規則,也就沒辦法談領域模型的資料一緻性了;如果像以往的三層貧血架構,那文章隻是一個資料的載體,不包含任何業務規則,文章會先被構造一個空的文章對象出來,然後我們給這個空文章對象的某些屬性指派,然後儲存該文章對象到資料庫;這種設計,文章對象隻是一個資料的容器,它完全控制不了自己的狀态,因為它的狀态都是被别人(如service)去修改的;這樣的設計,相當于是沒有把業務規則封裝在業務對象内部,而是轉移到了外部service中,雖然這樣通常也沒問題,事實上我們大部分人都一直在這麼幹,因為這樣幹寫代碼很随意,也很高效,呵呵。

grasp九大模式中有一個面向對象的模式叫資訊專家模式,不知道大家有了解過沒有,該模式的描述是:将職責配置設定給擁有執行該職責所需資訊的對象;這個模式告訴我們,如果一個對象負責維護一些資訊,那它就有職責維護好這些資訊。展現到對象的屬性上,那就是這個對象的屬性不能被外部随便更改,對象自己的屬性必須自己負責維護修改。構造函數和普通的方法都會改變對象的狀态,是以,我們對構造函數和對象普通的公共方法,都要秉持這個原則;這點非常重要,否則,如果像貧血模型那樣,那對象就不叫對象了,而隻是一個普通的容納資料的容器而已,和資料庫裡的一條記錄也無本質差别了。實際上,在我看來,這也是ddd中的聚合差別于貧血模型中的實體的最大的地方。聚合不僅有狀态,還有嚴格維護好自己狀态的各種方法,包括構造函數在内;而貧血模型,則隻有狀态,沒有行為;

這個問題,沒有非常清晰的放之四海而皆準的确定方法,我的想法是:

首先從我們對領域的最基本的常識方面的了解去思考,該對象是否有獨立的生命周期,如果有,那基本上是聚合根了;

如果領域内的一個對象,我們會在背景有一個獨立的子產品去管理它,那它基本上也是聚合根了;

是否有獨立的業務場景會去建立或修改一個對象;

如果對象有全局唯一的辨別,那它也是聚合根了;

如果你不能确定一個對象是否是聚合根的的時候,就先放一下,就先假定它是聚合根也無妨,然後可以先分析一下你已經确定的那些聚合根應該具體聚合哪些資訊;也許等你分析清楚其他的那些聚合的範圍後,也推導出了你之前不确定是否是聚合根的那個對象是否應該是聚合根了呢。

把我們所需要關心的屬性設計進去;

分析該聚合要封裝和實作哪些業務規則,進而像上面的例子(商品庫存)那樣推導出需要設計哪些屬性狀态到該聚合内;

如果我們在建立或修改一個對象時,總是會級聯建立或修改一些級聯資訊,比如在一個任務系統,當我們建立一個任務時,可能會上傳一些附件,那這些附件的描述資訊(如附件id,附件名稱,附件下載下傳位址)就應該被聚合在任務聚合根上;

聚合内隻需要值對象和内部的實體即可,不需要引用其他的聚合根,引用其他的聚合根隻會讓目前聚合的邊界模糊;

這一點在最上面的幾個原則中,實際上已經提到過一點,那就是盡量設計小聚合,這裡的出發點主要是從技術的角度去思考,為了降低對公共對象(大聚合)的并發修改,進而減小并發沖突的可能性,進而提高系統的可用性(因為系統使用者不會經常因為并發沖突而導緻它的操作失敗);關于這一點,我還想再舉幾個例子,來說明,其實要實作各種業務規則,可以有多種聚合的設計方式,大聚合隻是其中一種;

比如,文章和回複,大家都知道一個文章有多個回複,沒有文章,回複就沒有意義;是以很多人就會認為文章應該聚合回複;但實際上不需要這樣,如果你這樣做了,那對于一個論壇來說,同一個文章被多個人同時回複的可能性是非常高的,那這樣的話,多個人同時回複一個文章,就會導緻多個人同時修改同一個文章對象,那就導緻大家都回複不了,因為會有并發沖突或者資料庫事務的等待逾時,因為大家都在修改同一個文章聚合根;實際上如果我們從業務規則的角度去思考一下,那可以發現,其實文章和回複之間,隻有一個簡單的規則,那就是回複一旦被建立,那他所對應的文章不能被修改即可;這樣的話,要實作這個規則其實很簡單,把回複作為聚合根,然後把文章傳入回複聚合根的構造函數,然後回複儲存文章id,然後回複将文章id設定為不允許外部修改(private set;即可),這樣我們就實作了這個業務規則,同時還做到了多人同時推一個文章回複時,不會對同一個文章對象就并發修改,而是每個回複都是并行的往資料庫插入一條回複記錄即可;

是以,通過這個例子,我們發現,要實作領域模型内的各種業務規則,方法不止一種,我們除了要從業務角度考慮對象的内聚關系外,還要從技術角度考慮,但是不管從什麼角度考慮,都是以實作所要求的業務規則為前提;

從這個例子,我們其實還發現了另外一件有意義的事情,那就是一個論壇中,發表文章和發表回複是兩個獨立的業務場景;一個人發表了文章,然後可能過了一段時間,另一個人對該文章發表了回複;是以将文章和回複都設計為獨立的很容易了解;這裡雖然文章和回複是一對多,回複離開文章确實也沒意義,但是将回複設計在文章内沒任何好處,反而讓系統的可用性降低;相反,像上面提到的關于建立任務時同時上傳一些附件的例子,雖然一個任務也是對應多個附件資訊,但是我們發現,人物的附件資訊總是随着任務被建立或修改時,一起被修改的。也就是說,我們沒有獨立的業務場景需要獨立修改任務的某個附件資訊;是以,沒有必要将任務的附件資訊設計為獨立聚合根;

enode提供了一個基于ddd+cqrs+event sourcing+in memory+eda這些技術的應用開發架構;

enode在架構層面就限制了一個command隻能修改一個聚合根,這就杜絕了我們使用unit of work的模式來以事務的方式來一次性修改多個聚合根;

enode提供了可靠的原子操作和并發沖突檢測機制,來保證對單個聚合的操作的強一緻性;

enode提供了可靠的事件機制,來保證我們的domain中的聚合之間資料互動可以通過事件異步通信的方式來實作聚合之間的最終一緻性;如果有些複雜業務場景是一個流程,那我們可以通過process+process manager的思想來實作流程狀态的跟蹤和流程的流轉;

enode因為基于domain event,是以,我們的聚合根不需要引用,每個聚合根隻需要負責自己的狀态更新,然後更新完後産生相應的domain event即可,這本質就是就是實作了:don’t ask, tell這個設計原則;

enode提供了可靠的事件釋出機制,可以確定command side和query side的資料最終一定是一緻的;

enode提供了in memory的設計,使得我們的domain可以非常高效的運作,持久化事件不需要事務,擷取聚合根直接從in memory擷取;

enode提供了很多設計,可以讓我們最大化的對不同的聚合根執行個體做并行操作,進而提高整個系統的吞吐量;

使用enode,将會迫使你思考如何設計聚合,如何通過流程實作聚合之間的異步互動;迫使你思考如何定義domain event,将領域内的狀态更改顯式化;迫使你将外部對領域的各種操作顯式化,即定義出各種command;迫使你将command side和query side的資料分離和架構分離,技術分離。減少的是,我們不必再設計unit of work,不必設計domain service,不必讓聚合設計各種非第一手的備援的統計資訊;