官方彩票投注

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

概述

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

而這種強有力的劃分手法就是領域驅動設計戰術模式中的“聚合”。可能大家已經聽過它的一個重要部分“聚合根”,那么我們什么情況下考慮使用聚合根呢?聚合根又是從什么地方來?聚合與實體之間又有什么關系?如何確定和劃分一個合理的聚合?本文將從不同的角度來帶大家重新認識一下“聚合”這個概念,并且給出相應的代碼片段(本教程的代碼片段都使用的是C#,后期的實戰項目也是基于 DotNet Core 平臺)。

何為聚合

還是先來看看原著 中對聚合的有關解釋:

在具有復雜關聯的模型中要想保證對象更改的一致性是很困難的。不僅互不關聯的對象需要遵守一些固定規則,而且緊密關聯的各組對象也要遵守一些固定規則。然而,過于謹慎的鎖定機制又會導致多個用戶之間臺無意義地互相干擾,從而使系統不可用。
首先,我們需要用一個抽象來封裝模型中的引用。AGGREGATE就是一組相關對象的集合,我們把它作為數據修改的單元。每個AGGREGATE都有一個根(root)和一個邊界(boundary).邊界定義了AGGREGATE的內部都有什么。根則是AGGREGATE中所包含的一個特定Entity。在AGGREGATE中,根是唯一允許外部對象保持對它的引用的元素,而邊界內部的對象之間則可以互相引用。除根以外的其他Entity都有本地標識,但這些標識只有在AGGREGATE內部才需要加以區別,因為外部對象除了根Entity之外看不到其他對象。

演化案例

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

官方彩票投注接下來,讓我們完善這個案例,讓它更貼近于我們真實的項目需求:

當用戶創建一個行程時,則證明該旅程的賬單已經被開啟了。創建該行程的用戶被認定為管理員,他可以添加參與該行程的小伙伴。所有參與行程的小伙伴,都可以在旅行的過程中記賬(比如小伙伴C和小伙伴A吃了一頓火鍋花了300塊錢,小伙伴C則可以記入本筆開銷,而該筆開銷的參與者是小伙伴C和A),當大家旅行完成了之后就可以進行結算,講費用平攤到每個人身上,誰需要補錢,誰需要退錢等都可以被該應用計算出來。

這是簡化后的版本,為的是希望大家能大致明白我們需要做一個什么樣的東西,并且如何用我們所學到的領域驅動設計知識來建模和編碼,為了讓大家更清晰的理解需求,我粗淺的為大家繪制了一個原型圖:

發現實體關系

根據需求描述,再結合我們已有的領域設計知識,我們馬上就能找出另外一個重要的實體對象出來。沒錯,那就是賬單。在這個案例中,我們暫定將賬單命名為記賬薄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項目,你使用聚合根了嗎?你又是怎么來表達聚合根的?

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

posted @ 2019-12-18 18:16  句幽  閱讀(...)  評論(...收藏