如何運用領域驅動設計 - 聚合

概述

在前幾篇的博文中,咱們已經學習到了如何運用實體和值對象。隨着咱們所在領域的不斷深刻,領域模型變得逐漸清晰,咱們已經創建了足夠豐富的實體和值對象。但隨着實體和值對象的數量逐漸增多,它們之間的關係也顯得愈來愈複雜:實體A與實體B存在一對一的關係,實體B又與實體C存在一對多的關係。就這樣一層套一層,原本約束已經足夠好的領域對象們似乎已經開始對咱們不太友好。爲了處理這一系列的問題,咱們須要將一些實體和值對象劃分在一個統一的邊界內,原來存在多重關聯關係的大模型被分解爲較小的領域對象羣。數據庫

而這種強有力的劃分手法就是領域驅動設計戰術模式中的「聚合」。可能你們已經聽過它的一個重要部分「聚合根」,那麼咱們什麼狀況下考慮使用聚合根呢?聚合根又是從什麼地方來?聚合與實體之間又有什麼關係?如何肯定和劃分一個合理的聚合?本文將從不一樣的角度來帶你們從新認識一下「聚合」這個概念,而且給出相應的代碼片斷(本教程的代碼片斷都使用的是C#,後期的實戰項目也是基於 DotNet Core 平臺)。c#

何爲聚合

仍是先來看看原著《領域驅動設計:軟件核心複雜性應對之道》 中對聚合的有關解釋:框架

在具備複雜關聯的模型中要想保證對象更改的一致性是很困難的。不只互不關聯的對象須要遵照一些固定規則,並且緊密關聯的各組對象也要遵照一些固定規則。然而,過於謹慎的鎖定機制又會致使多個用戶之間臺無心義地互相干擾,從而使系統不可用。
首先,咱們須要用一個抽象來封裝模型中的引用。AGGREGATE就是一組相關對象的集合,咱們把它做爲數據修改的單元。每一個AGGREGATE都有一個根(root)和一個邊界(boundary).邊界定義了AGGREGATE的內部都有什麼。根則是AGGREGATE中所包含的一個特定Entity。在AGGREGATE中,根是惟一容許外部對象保持對它的引用的元素,而邊界內部的對象之間則能夠互相引用。除根之外的其餘Entity都有本地標識,但這些標識只有在AGGREGATE內部才須要加以區別,由於外部對象除了根Entity以外看不到其餘對象。分佈式

演化案例

還記得咱們在上一篇博文 如何運用領域驅動設計 - 實體 中所展開的一個關於旅行記帳的案例嗎? 在學習實體的時候,咱們已經構建了一個叫作Itinerary的實體,而且賦予了它應用的行爲操做。 到目前爲止,咱們那個案例好像還和主題離的稍微有點遠,咱們雖然實現了行程這個東西,可是怎麼記帳呢?性能

接下來,讓咱們完善這個案例,讓它更貼近於咱們真實的項目需求:學習

當用戶建立一個行程時,則證實該旅程的帳單已經被開啓了。建立該行程的用戶被認定爲管理員,他能夠添加參與該行程的小夥伴。全部參與行程的小夥伴,均可以在旅行的過程當中記帳(好比小夥伴C和小夥伴A吃了一頓火鍋花了300塊錢,小夥伴C則能夠記入本筆開銷,而該筆開銷的參與者是小夥伴C和A),當你們旅行完成了以後就能夠進行結算,講費用平攤到每一個人身上,誰須要補錢,誰須要退錢等均可以被該應用計算出來。ui

這是簡化後的版本,爲的是但願你們能大體明白咱們須要作一個什麼樣的東西,而且如何用咱們所學到的領域驅動設計知識來建模和編碼,爲了讓你們更清晰的理解需求,我粗淺的爲你們繪製了一個原型圖:編碼

發現實體關係

根據需求描述,再結合咱們已有的領域設計知識,咱們立刻就能找出另一個重要的實體對象出來。沒錯,那就是帳單。在這個案例中,咱們暫定將帳單命名爲記帳薄Account book)。在第二個原型圖中,咱們大體可以理解記帳薄是一個什麼東西,它記錄了行程中全部的開銷內容和開銷金額。這一行一行的開銷信息,咱們將它命名爲開銷項Overhead item)。這裏爲了簡化起見,咱們忽略了每條開銷項中的其它信息,例如參與人員,參與地點等等。設計

接下來,咱們來分析已經發現的兩個事物:記帳薄開銷項。先來講開銷項吧,它是屬於實體仍是值對象呢?結合前兩篇博文中咱們說學到的內容,它須要一個ID來辨識它嗎? 也許仍是有些困惑,由於好像它不像性別、姓名這一類東西具備很明顯的無ID特徵。因此咱們須要來識別該對象擁有的屬性:開銷內容、開銷金額、開銷時間。「在2019年10月12日,買了一個冰糕花費了3元人民幣」,在咱們當前的領域,咱們須要使用一個ID來區分它嗎?很顯然咱們是須要的,咱們不能說只要在同一時間花了一樣的錢買了一樣的東西就是同樣的東西了,好比用戶A在行程A中和用戶B在行程B中同時間一樣的錢買了一樣的東西,咱們會認爲是同樣的嗎?很顯然,不能。因此開銷項是一個實體。那麼記帳薄呢?很顯然,它也是一個實體,咱們須要經過ID來識別究竟是哪一個記帳薄。

此時咱們已經捕獲出了兩個實體對象:記帳薄開銷項。並且能夠清楚的看到,它們之間是一個一對多的關係。而後來嘗試將它們轉換爲咱們熟悉的C#代碼吧:

public class AccountBook
{
    public Guid ID { get; private set; }

    public List<OverheadItem> OverheadItems {private get;private set; }

    //ctor

    // 記帳薄的行爲
    // ....
}

public class OverheadItem
{
    public Guid ID { get; private set; }

    //開銷內容
    public OverheadContent Content { get;private set; }

    //開銷金額
    public OverheadMoney Money { get;private set; }

    // ctor

    // 開銷項目的行爲
    // ....
}

public class Itinerary
{
    public int ID { get; set; }

    public List<Person> Participants { get;private set; }

    public List<Address> Places { get;private set; }

    public ItineraryNote  Note { get;private set; }

    public ItineraryTime TripTime { get;private set; }

    public ItineraryStatus Status { get;private set; }

    //ctor

    // 行程的行爲
    // ....
}

OK,此時咱們已經完成了記帳有關的模型。再來回顧咱們以前的行程實體模型:「當旅程創建的時候,則證實該旅程的帳單已經被開啓了」,所以咱們能夠看出,旅程和帳薄是鏈接在一塊兒的,一個旅程就對應着其擁有的對應帳薄,因此它們是一個一對一的關係。

到目前爲止,咱們擁有了三個比較明顯的實體:旅程、記帳薄、開銷項目,還有該領域中不少大大小小的值對象。旅程和記帳薄是一對一的關係,記帳薄和開銷項目是一對多的關係。多讀一下它們之間的關聯關係,唉!!!好累,那是否是再引入一個領域對象進來,就會讓它們之間的關係更復雜呢?這樣一層繞一層,就彷彿滾毛線球同樣,越理越亂了。

開始劃分邊界吧

我根據目前所涉及的領域對象,大體繪了一個領域之間的圖,固然這個圖並非規範的,裏面缺乏了不少咱們已經捕獲出來的值對象等等,它只是爲了幫助你大體回顧一下咱們目前所Get到的領域模型結果:

域關係圖

圖中將「旅行記帳」的部分於「推薦」的部分用了方塊給隔離開來,這個結果我想你們也很容易理解,由於有關推薦的這些東西,好比推薦餐館呀,推薦花店呀對咱們的旅行記帳來講並無太大的關係。關係域於關係域中,咱們經過劃分了一個合理的邊界來隔離它們,那麼反過來思考,一個域中的各個領域對象,咱們能不能經過一個什麼手段來劃分它們呢?將它們經過邊界的隔離,實現區域內的自治,這樣更方便咱們來處理它們之間的邏輯關係。

假如用戶想查看當前行程的記帳薄,按照常規處理咱們會怎麼辦呢? 用戶會訪問有關記帳薄的倉儲(倉儲的有關概念將在下一篇文章講解),獲取到當前記帳薄。此時,用戶獲取到了帳薄的有關信息,好比開銷項啊,總開銷金額啊等等,可是對用戶來講,它是很迷茫的,由於它僅僅獲取到了帳薄的信息,它不知道這個帳薄屬於哪次行程,因此它必須又得去獲取一下行程的信息。而這種場景每每都是一塊兒出現的,你只要獲取帳薄你就必需要獲取行程。

可能你已經發現了,它們其實能夠是一體的。就像開銷項和記帳薄是一體的同樣,行程記帳薄這兩個大實體竟然也是能夠是一體的。而這種關係,就是咱們今天的主題——「聚合」。

咱們能夠將旅行行程、記帳薄、行程人員、開銷項、行程時間等一系列有關的對象都劃分在行程的邊界內,由於確確實實它們是屬於行程的,一旦脫離了行程它們好像都沒有任何意義。

選取一個聚合根

行程記帳薄是一體的,且它們是一對一的關係。若是將這個關係轉換爲咱們熟悉的代碼,咱們須要將一個類做爲另外一個類的屬性,那麼在這個案例中,咱們是用行程包含記帳薄,將記帳薄做爲屬性呢?仍是記帳薄包含行程呢? 你也許會說,它們能夠相互包含。確實,如今的ORM框架能夠運行你將二者互相包含並映射到數據庫,可是在這裏咱們沒有必要這麼作,由於咱們已經知道,它們是一個總體,獲取一者另一者一樣會被獲取到,不須要再次嵌套。回到剛纔那個問題,是誰在外層呢?其實答案也很清晰了,由於從該例子來講,咱們更關注的是行程,因此咱們很天然的就會將行程做爲主要的實體對象,而在這個聚合關係中,被咱們選取出來做爲邊界範圍的實體就是咱們所說的聚合根。

此時咱們的代碼可能已經能夠改變成這樣了:

public class Itinerary
{
    public int ID { get; set; }

    public List<Person> Participants { get;private set; }

    public List<Address> Places { get;private set; }

    public ItineraryNote  Note { get;private set; }

    public ItineraryTime TripTime { get;private set; }

    public ItineraryStatus Status { get;private set; }

    //將記帳薄放置在了旅行中
    public AccountBook AccountBook{get;private set;}

    //ctor

    // 行程的行爲
    // ....
}

經過聚合根保護你的內部對象

當識別出了一個聚合根的時候,就要保證該聚合的內部是自治的。咱們不能從外界直接訪問聚合根內部的任何領域對象,好比在上面的案例中,咱們則不能直接記帳薄這個實體。若是咱們確確實實須要獲取記帳薄中的有關信息,咱們必須經過聚合根,也就是上面的行程來訪問。也就是說咱們得從倉儲中獲取行程後再來獲得記帳薄的有關信息。

此時,你可能會說,那這樣不就會很麻煩了嗎?我只要記一筆帳,但我必需要獲得旅程的全部信息。這樣數據庫和應用程序不是增長了一些壓力嗎? 是的,這樣作咱們會將更多的數據加載到內存之中來。可是這是合理的,回顧剛纔一下上面的案例,咱們有什麼狀況下須要只獲取帳薄,不獲取旅程信息呢? 是的,沒有,它們永遠是一塊兒出現的。

當聚合內部的對象沒法直接訪問的時候,很顯然也不能直接調用該對象所公開出來的行爲了。好比記帳薄可能會擁有一個叫作「記一筆帳(RecordAnAccount)」的行爲,咱們經過訪問該行爲操做就能夠將開銷項增長到記帳薄中。可是如今咱們不能直接訪問記帳薄了,咱們怎麼記帳呢? 經過轉移行爲給聚合根來完成,好比咱們會將該行爲轉移到行程中,並公佈一個叫作「記錄行程中一筆帳」的行爲供客戶端調用。

public class Itinerary
{
    //Other Property....

    public List<Person> Participants { get;private set; }

    public AccountBook AccountBook{get;private set;}

    //ctor

     public void RecordAnAccountInItinerary(
         int PersonID,
         string itemName,
         double costMoney)
    {
        bool hasThisPerson = Participants.Any(Person=>Person.ID = PersonID);

        if(!hasThisPerson)
            throw new PersonNotInThisItineraryException();

        AccountBook.RecordAnAccount(itemName,costMoney);
    }
}

這樣一來,聚合根內部的全部對象都不會被外界肆意訪問,並且經過聚合根所表達出來的行爲也更容易讓人可以理解。

聚合的一些特性

到了如今,再回頭去看一下概述中原著對聚合概念的闡述。咱們能夠已經大體理解了什麼是聚合,聚合根又是怎麼來的:

  • 聚合是一個明確的邊界
  • 聚合的出現是爲了解決領域模型之間的複雜關聯關係的
  • 聚合封裝了一系列的相關對象,它是這些對象的集合
  • 聚合應該有一個根,而且這個根是經過集合中的一個實體選出來的
  • 聚合外部的事務想引用聚合只能經過根的ID來訪問

再來給你們舉一個原著中的例子,加深印象:汽車修配廠的軟件可能會使用一個汽車模型。汽車是一個具備全局標識的ENTITY:咱們須要將這部汽車與世界上全部其餘汽車區分開(即便是一些很是類似的汽車),咱們可使用車輛識別號來進行區分,車輛識別號是爲每輛新汽車分配的惟一標識符。咱們可能想跟蹤4個輪胎的歷史轉數。咱們可能想知道每一個輪胎的里程數和磨損度。要想知道哪一個輪胎在哪兒,必須將輪胎標識爲Entity。輪胎被安在汽車上,也不會有人要系統中查詢特定的輪胎,而後看看這個輪胎在哪輛汽車上。人們只會在數據庫中查找汽車,而後臨時查看一下這部汽車的輪胎狀況,所以,汽車聚合中的根Entity,而輪胎只是處於這個聚合的邊界以內。

做爲一名普通的手機用戶,當屏幕摔碎的時候,他會選擇將整個手機送至維修中心。由於對他來講手機是一個總體。會不會有人本身把屏幕單獨送去維修中心呢?有吧,可能他是維修師傅。

經過ID引用

public class Student
{
    public int ID { get; set; }
    public string LastName { get; set; }
    public string FirstMidName { get; set; }
    public DateTime EnrollmentDate { get; set; }

    public ICollection<Enrollment> Enrollments { get; set; }
}

public class Enrollment
{
    public int EnrollmentID { get; set; }
    public int CourseID { get; set; }
    public int StudentID { get; set; }
    public Grade? Grade { get; set; }

    public Course Course { get; set; }
    public Student Student { get; set; }
}

該代碼摘自aspnetcore基礎教程

這樣的代碼是很常見的,許多開發人員都找到了一種天然方式在代碼中將關係建模爲對象引用。特別是在使用EF Core中,咱們會很天然的將不一樣對象之間的關係經過對象引用來表示。這是由於咱們以往並無聚合的這一律念,因此咱們要完成一個關聯的操做就須要加載全部的關聯對象而後經過遍歷一個一個的實例對象來處理。很顯然,這會形成性能上的浪費,雖然咱們可使用延遲加載的技術來處理,可是延遲加載會讓模型的處理更加複雜。

在上面的例子中,假如咱們須要知道這個行程建立的管理員用戶是誰。咱們會怎麼處理呢?管理員用戶被抽象爲了一個單獨的聚合根User,該聚合包含了User全部的信息(身份,姓名,性別等等)。咱們會在Itinerary聚合根中添加一個類型爲User的屬性做爲管理員嗎?不須要,在該領域中,咱們爲何要關心管理員的其餘信息呢,以致於每次加載行程都還須要帶出用戶的信息。很顯然,咱們在Itinerary聚合中並不會關心另外聚合的狀況。因此,當一個聚合須要引用到另一個聚合的時候,千萬不要直接使用類型的強引用方式來實現,而是經過使用引用聚合的ID來維持聚合與聚合的關係

這樣作的好處在分佈式系統中更容易體現,旅程和用戶這二者每每會被放在不一樣的系統中,旅程邊界中根本就找不到一個叫作User的實體,而它們之間的引用關係只能經過ID來標記。

聚合真的是不變的嗎

可能咱們經過分析領域模型,已經創建了一個相對來講很好的聚合了,而且提取出了聚合根,將領域對象控制在聚合根的內部。可是?聚合根裏面的實體就永遠存在聚合根以內嗎?答案是不必定的。咱們之因此將實體放置在聚合根以內是由於咱們知道他與聚合根是一體的,外界訪問該實體的時候必定會攜帶上訪問聚合根實體。可是!!!!!假如咱們需求的變動讓咱們確確實實須要單獨訪問目前聚合根裏面的實體呢? 是的,它可能會被單獨提高爲一個聚合根。並且經過ID之間的引用保持對原有聚合根之間的關聯關係。

因此考慮聚合根的重要一點是:在領域中咱們是否會單獨訪問該實體

小的聚合

有時候,聚合的優點可能會成爲糖衣炮彈,它會讓你瘋狂的將大量的實體和值對象融入在其中去,最後的結果是形成聚合愈來愈大。這樣會形成性能的瓶頸,特別是在某個實體存在大量結果的狀況下,這簡直是一個噩夢。因此在考慮聚合以前,咱們要多思考,咱們是否將聚合設計的過大了。

哪怕在某個領域設計出來的聚合是正確的,咱們有時候也會拆分它。緣由很簡單,性能問題。當聚合A中的實體EntityA存在大量數據的時候,咱們訪問聚合A不得不去加載它們,這樣會讓性能形成大量損失。哪怕建模的結果是正確的,可是咱們仍是會考慮折中的辦法,將EntityA提高爲一個單獨的聚合供外界單獨訪問。

一致性

聚合中的全部對象都應該保持一致的變動,這是毫無疑問的。所以一個聚合在持久化的時候理應在一個事務中完成。可是當一個業務用例可能會操做多個聚合的時候,修改了聚合A的同時也更改了聚合B,這是一個很常見的操做,咱們也必須保證多個聚合之間的一致性。這在單體應用中很容易實現,可是在分佈式系統中咱們不得不考慮最終一致性。有關分佈式的相關信息將在後期 《分佈式中的領域驅動設計》 系列中講述。

總結

本次咱們介紹了有關領域驅動設計中「聚合」的內容,咱們知道了什麼是聚合根,已經聚合根與實體之間的關係,以及怎麼去考慮設計一個聚合根。在實際的項目中,其實聚合根是一個很是常見的領域對象,由於咱們大量的業務邏輯和表達都是經過聚合根來完成操做的。回顧一下你如今正準備嘗試或者已經在寫的DDD項目,你使用聚合根了嗎?你又是怎麼來表達聚合根的?

下一期的文章中,是關於倉儲的,它與聚合根其實有密不可分的關係。

相關文章
相關標籤/搜索