1. 程式人生 > >如何運用領域驅動設計 - 聚合

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

目錄

  • 概述
  • 何為聚合
  • 演化案例
    • 發現實體關係
    • 開始劃分邊界吧
    • 選取一個聚合根
    • 通過聚合根保護你的內部物件
  • 聚合的一些特性
    • 通過ID引用
  • 聚合真的是不變的嗎
  • 小的聚合
  • 一致性
  • 總結

概述

在前幾篇的博文中,我們已經學習到了如何運用實體和值物件。隨著我們所在領域的不斷深入,領域模型變得逐漸清晰,我們已經建立了足夠豐富的實體和值物件。但隨著實體和值物件的數量逐漸增多,它們之間的關係也顯得越來越複雜:實體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專案,你使用聚合根了嗎?你又是怎麼來表達聚合根的?

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