理解領域驅動設計

微笑刺客D發表於2021-01-18

前言

什麼是領域,我習慣描述的是製藥領域、環境領域、建築領域、金融領域等,而在領域內,各種業務規則、業務知識盛行,如何有效的把控規則的變化,應對複雜知識,有一個很關鍵的四字詞語,分而治之。分治法在很多場景下體現了其強大的作用力。領域本身很大,那就拆分,得到更小的領域,也即子域,如同遞迴呼叫一般,將一個複雜問題拆分單獨求解,而最終將解彙總得到複雜問題解。

怎麼拆,拆成怎麼樣合適,依據什麼拆,這些在領域驅動設計中有了一套答案,雖然領域驅動設計不是銀彈,但可以說的上是一套極好的系統方法論或稱為架構設計的方法論。

領域驅動設計常以戰略設計與戰術設計來將整個領域展現的淋漓盡致,其作用範圍既面向業務也面向技術。從戰略角度(個人更喜歡稱其為上帝視角)去規劃系統、劃分領域。而從戰術角度則從技術層面來指導我們該如何去設計。

戰略設計

戰略設計主要從高層俯視(上帝視角)我們的軟體系統,就如同玩即時戰略遊戲般,可以一覽地圖全貌,以此來決定我們是要進攻還是防守哪個方向,同樣,在軟體中我們也可以以此來劃分領域,確定權重方向。

統一語言

提煉領域知識,怎麼個提煉法,千萬條羅馬路,各有各的看家本領。像事件風暴方法,用例分析方法,使用者故事,甚至是開大會,各種討論會等,最終目的都是提煉出領域知識,而提煉過程中,達成描述上的一致性,包括系統目標、系統範圍及系統所具有的功能。

圖片

這不是領域驅動設計所獨有的,但卻是軟體開發中所必須的,為領域專家、業務分析人員、編碼人員和測試人員等團隊所有成員交流時構建統一頻道。

圖片

領域/子域

圖片

領域拆分

對於領域這個概念,習慣性會想到製藥領域、環境領域、金融領域等這些概念,而領域本身所描述的是範圍,是如同現實世界般的複雜,無邊際。藉助分治法,將問題逐級細分來降低業務和技術複雜度,將這複雜的世界劃分出清晰的邊界來,反過來控制著劃分後不那麼複雜的世界,也既領域拆分出細化後的子領域。

圖片

子域劃分

在實際解決問題時,我們也習慣將問題拆分,而怎麼拆,基於什麼原則拆,可能會依據相關性,權重,甚至分類原則等,對於系統而言,會從架構方面考慮,基礎設施考慮等,在領域驅動設計中,更偏向基於業務拆分,降低業務複雜度,也分離技術實現的複雜度,依照業務拆分後的子領域,本身存在權重上的差異,依照重要性和功能劃分為三類,投資佔比也就有所不同。

  • 核心域:其所體現的是核心服務,是代表著產品的核心競爭力。
  • 支撐域:其所體現的是支撐服務,沒它不行,但又達不到核心的價值,圍繞著產品內部所需要,但又不能單獨變更為第三方服務,即它不是一個通用的服務。
  • 通用域:其所體現的中介軟體服務或第三方服務。本身可以通過現有的解決方案整合來完成的服務。

圖片

限界上下文

深入到一個子域中,又是一片小天地,在這天地中,卻又還是存在著因語義與語境上的差異,讓一些概念在這子域中顯得額外尷尬。在一個領域 / 子域中,我們會建立一個概念上的領域邊界,在這個邊界中,任何領域物件都只表示特定於該邊界內部的確切含義。這樣邊界便稱為限界上下文。

其本質上是限界+上下文,引用到張逸老師的一句話

上下文(Context)其實是動態的業務流程被邊界(Bounded)靜態切分的產物

對於子域與上下文間的關係,看到很多書籍或是文章中所描述的都不一樣,這塊的爭論也沒有一個最終答案,個人更傾向於子域中劃分上下文,從拆分角度來講,這樣理解更加簡單。

圖片

上下文識別

對於上下文的識別,沒有可遵循的標準可走,從不同的角度切入將會識別到不同的上下文,可從張逸老師的領域驅動設計實踐中窺之一二,以業務複雜度、管理複雜度和技術複雜度出發,面對這三個角度去依次分析,從業務視角、工作視角、應用視角去識別,進而識別出準確的上下文,通過不斷的分析斟酌考慮,逐漸識別出符合當前預期的上下文,如在實際操作環節發覺當前上下文的設計顯得不那麼合理,還可再進行變動、拆分上下文。

圖片

但需注意的一個是,我們識別上下文的目的是什麼,是為了控制上下文,準確的說是為了控制上下文的邊界、大小,是為了保住我們所守護的上下文不會因過度成長變大而奔潰,亦或因上下文過度縮減而失去價值,保證上下文內一切的穩定,上下文與上下文間互動的可用性,也或者是當我們退出上下文時,交付出來的上下文是非常可觀的,而不是一個爛攤子。

上下文對映

規劃了這麼多限界上下文,該如何穿針引線將這些上下文串起來便是一個問題了,用例場景的完整實現往往是由多個上下文的協作完成的,怎麼去組織這些上下文,領域驅動設計提到的幾種方式及軟體工程中常用模式。

  • 合作關係:一榮俱榮,一損俱損。
  • 共享核心:上下文間共享領域實體。
  • 客戶方-供應方:下游客戶依賴於上游供應方。
  • 遵奉者:下游客戶順應上游供應方。
  • 各行其道:沒有關係的關係,相互隔離。
  • 防腐層:在下游上下文與上游間增加一道屏障,以此來隔絕與上游的直接互動保護下游。
  • 開放主機服務:在上游與下游上下文間增加一道協議,以此來規範下游對上游的整合。
  • 已釋出語言:釋出方上下文釋出一份包含豐富文件的資訊交換語言,消費方上下文翻譯並使用。

這些模式其本質是為了協作,為了滿足用例場景下對多個限界上下文的呼叫,通過上下文對映圖,可以清楚知曉執行邏輯。為了實現上下文對映,簡單講就是如何將兩個上下文連貫起來,常藉助的方式是諸如 RPC、HTTP、訊息佇列等,依照上下文間對映型別,挑選一件趁手的工具。

圖片

分層架構

我們通常喜歡對各種事情歸納總結,如文章的層次分明,如建築結構高低有序、疏密有致,給人一種各處所關注的資訊視角不同,而組合起來顯得如此美妙。軟體中同樣運用著分層來隔離關注點,以此來隔離每層的演進速率。

當我們考慮限界上下文時,不僅需要去考慮其內部的領域設計,還得從其應用邊界本身考慮,限界上下文是屬於架構設計層次,主要針對的是後端架構層次的垂直切分,按照經典 DDD 的分層結構來看,共分為如下四層:

圖片

  • User Interface 為使用者介面層,向使用者展示資訊和傳入使用者命令。這裡指的使用者不單單隻使用使用者介面的人,也可能是外部系統,諸如用例中的參與者。
  • Application 為應用層,用來協調應用的活動,不包含業務邏輯,通過編排領域模型,包括領域物件及領域服務,使它們互相協作。不保留業務物件的狀態,但它保有應用任務的進度狀態。
  • Domain 為領域層,負責表達業務概念,業務狀態資訊以及業務規則。儘管儲存業務狀態的技術細節是由基礎設施層實現的,但是反映業務情況的狀態是由本層控制並且使用的。領域層是業務軟體的核心,領域模型位於這一層。
  • Infrastructure 為基礎實施層,提供公共的基礎設施元件,如持久化機制、訊息管道的讀取寫入、檔案服務的讀取寫入、呼叫郵件服務、對外部系統的呼叫等等。

值得注意的是,給定的分層方式僅僅是邏輯上的分層,而對於實際的物理分層,卻又有所不同,但遵守一個前提為好,即限界上下文的邊界高於分層的邊界。諸如如下兩種開發中常見的程式碼組織方式,都可見到。一種是基於技術分層,而另一種更偏向基於業務分層。

方式一

- application
  - productcontext
  - ordercontext
  - ...
- domain
  - productcontext
  - ordercontext
  - ...
- infrastructure
  - productcontext
  - ordercontext
  - ...

方式二

- productcontext
  - application
  - domain
  - infrastructure
- ordercontext
  - application
  - domain
  - infrastructure

具體採用哪種方式,並沒有強制要求,無論程式碼組織結構是否表達了層的概念,都需要充分理解分層的意義,並使得整個程式碼結構在架構上要吻合分層架構的理念。

戰術設計

相比於戰略設計的怎麼規劃,戰術設計更側重於怎麼執行,詳細的設計和編碼。

圖片

聚合

在認識聚合前,我們得對類再次回顧,類是作為我們開發中的最小單元,一切以類構建,而在上下文的視角中,聚合成了最小概念,包裝了一組高度相關的物件,上下文內以聚合為最小單元,以此來保證聚合邊界。又將分而治之的思想融入到了限界上下文的內部。

聚合本身是由一個或多個實體及值物件組成,其中一個實體作為聚合根。管理著內部關聯的實體與值物件,對外代表著聚合,外部來訪者僅可通過聚合根進行訪問。

圖片

對於聚合圖的畫法,或許因人而異,我更加傾向於用矩形代表實體,橢圓代表值物件,用 UML 類圖中的組合-聚合箭頭來表示其雙方間的關係。

需要注意的是,此處的聚合不要與 UML 類圖中的聚合等同起來,兩者含義並不相同。

實體

對於實體來講,這個概念對於我們並不陌生,擁有者唯一的身份識別符號,內含屬性作為該實體的靜態特徵,作為聚合所擁有的領域知識,擁有著與自身相關的領域行為。

值物件

對於值物件,我傾向於將它理解為,基礎型別之延伸,既能封裝基礎型別,又能約束內部屬性間關係,還能擁有著自身的領域行為,而與實體的區別是,沒有唯一身份標識,儘管帶來了持久化的一些問題,但還是存在解決方案。以 DateTime 理解值物件最好不過了,DateTime 內部的自身約束保證了,每一次變動的 DateTime 都是最新的,當我們想在 2 月 28 日加 1,這便要依靠 DateTime 中的行為去約束內部的屬性。

聚合劃分

經統一語言與業務分析階段,藉助一系列如事件風暴、用例分析法、名次動詞法、四色建模法等活動後,獲得了一系列相關聯的物件。或可形成一張龐大的物件關聯圖。

圖片

如不考慮聚合的劃分,我們依照以往的思路便是建立一大堆表,運用三正規化或是依靠程式去保證資料的一致性不運用主外來鍵。然後瘋狂擼碼,CRUD 好不快活。

而隨著業務的逐漸擴張,這當初的想法已有點吃力了,如同樹苗逐漸成長,枝葉也逐漸增多。藉助枝幹我們可以分清葉子的歸屬,而物件網中呢,變得錯綜複雜了,也就隱約有了大泥球的徵兆。

藉助劃分聚合的一些方法,將其規整化。將原有複雜的物件圖拆分成可控制的小型物件圖。

  • 保持單一導航方向,解除雙向依賴,保持依賴簡單。
  • 保持聚合設計的小巧
  • 聚合內的業務規則一致性
  • 通過聚合識別符號引用其他聚合
  • 聚合與協作聚合間因業務場景、程式邊界等因素影響,可依照場景使用強一致性或是最終一致性。

如上的物件圖依照關係的強弱,關係的主與次進行了聚合劃分,或許得出的部分聚合存在不合理處,可再調整其邊界。

圖片

聚合協作

聚合與協作聚合之間依照聚合根實體的唯一識別符號進行關聯,而不是通過依靠協作聚合的引用例項來完成。保持這個原則有助於保持聚合之間的邊界並避免載入不必要的物件。如我們常習慣上將關聯的集合物件寫入到類中,然後在倉儲使用時,通過 EF 載入導航屬性,以此方便直接載入關聯聚合資料。

//一個聚合內建議用
public class Order : AggregateRoot
{
    public virtual ICollection<OrderItem> OrdrItems { get; set; }
    //...
}
_orderRepository.Include(e=>e.OrderItems).FirstOrDefault();

如 Order 和 OrderItem,當我們考慮將其作為一個聚合時,這麼使用,是可以的,但是不能說跨聚合也這麼用著,如 Enterprise 和 Order,劃分時我們更加傾向於劃分為兩個聚合,遵循保持聚合原則中,引用聚合根的 Id 這一原則,這將改善聚合的邊界使其更加清晰,控制更加妥當。

//多聚合間不建議這麼用
public class Order : AggregateRoot
{
    //遵循聚合原則引用 Enterprice 聚合根 Id,而不是例項
    public int EnterpriceId {get; set;}
    //public virtual Enterprice Enterprice { get; set; }
    //...
}

考慮到多聚合的協作,便要了解下聚合的首要原則,即在一次事務中,只能更改一個聚合的狀態,因此當涉及到多個聚合協作時,如建立訂單完畢,需要往庫存中某一商品數量減少時,訂單本身一般會有商品聚合的標識,藉助這個標識,通過領域事件或是整合事件方式,事件接收方將相關聯的庫存聚合呼叫起來,以此達到多個聚合間的協作。
又或者考慮到,需要呼叫商品的資訊以使得當前訂單中商品資訊更加豐富,可通過防腐層呼叫商品所在上下文遠端服務或是應用服務,最終本質上是呼叫商品聚合中的資訊豐富到訂單中,也使得多個聚合完成協作。

圖片

應用服務

作為限界上下文對外的門戶,也即是外觀模式的體現。通過用例分析識別出來的用例在此處一一對應存在著,對外提供統一介面,以此滿足完整用例場景所需的功能。在應用服務內部,通過編排領域模型物件來完成用例的功能,自身並不包含領域邏輯,但包含著應用邏輯。

圖片

可借鑑整潔架構的經典圖例來看應用層本身的職責所在,Use Case(用例層)-Application Business Rules,雖然是依靠著領域模型物件才完成的(具體是編排領域模型物件所具有的領域行為),卻也說明了應用服務承擔著的是用例的職責。

需要注意的是,應用服務的職責不僅限於編排領域模型物件,還需要控制著橫切關注點,如驗證、日誌、事物等的管理。

[UnitOfWork]
[Authorize(PermissionNames.PartType_Create)]
public async Task CreatePartType(CreatePartTypeDto input)
{
    await _validatingPartTypeManager.CheckUniqueName(input.Name, input.Category);
    var partType= PartType.Create(input.Name, input.Description)
        .SetCategory(input.Category)
        .SetFactory(input.FactoryName);
    await _partTypeRepository.InsertAsync(partType);
    await _appNotifier.NewPartTypeAsync();
}

如上,事務、認證、請求引數校驗(Dto 內),協調領域模型物件和基礎設施服務,這是應用服務的職責,當然也不僅限於這些職責。

領域服務

當我們考慮領域邏輯時,首先想到的應該是實體與值物件中具有的領域邏輯,而有些場景下,實體與值物件無法承載這些領域行為,如對多個領域物件作為輸入,進行計算併產出一個值物件;又或是需要將操作成集合化的聚合,如在 Supplier 下需要將所有 Order 中的單價彙總,而本身 Supplier 和 Order 是為兩個聚合,若考慮藉助 Order 去完成該業務操作,不太妥當,在此場景下,可通過領域服務來承載著這些領域行為。

領域服務存在如下特徵:

  • 執行一個顯著的業務操作過程
  • 對領域物件進行轉換
  • 需要使用多個聚合內的實體和值物件編排業務邏輯
  • 領域行為需要訪問外部資源

雖說領域服務能夠承載領域邏輯,卻不能說將所有的領域邏輯都往裡塞,如此,導致領域物件貧血。只有當實體與值物件承載不住或是本身並不屬於實體或值物件的職責內時,才考慮領域服務來承載,領域服務是一種妥協的結果,並不是說領域服務越多越好。

值物件(Value Object)→ 實體(Entity)→ 領域服務(Domain Service)

如下場景,建立 Invoice,存在幾條業務規則,相應 Order 的狀態需已完成,並且對應的 Supplier 提供財月資訊,這就需要多個聚合的協作,在領域服務編排這些領域物件模型及通過呼叫外部服務閘道器,完成業務邏輯。

// InvoiceManager
public async Task ValidCheck(string orderId, string supplierId)
{
    var order = await _orderService.GetAsync(orderId);
    if(!order.IsCompleted())
    {
      throw new UserFriendlyException("Order status is not completed");
    }
    
    var supplier = await _supplierService.GetAsync(supplierId);
    if(!supplier.IsCompleted())
    {
      throw new UserFriendlyException("Order status is not completed");
    }
}
public async Task SetFinanceMonth(Invoice invoice, string supplierId)
{
    var supplierFinanceMonth = await _supplierService.GetFinanceMonthAsync(supplierId, Current.Date);
    
    if(supplierFinanceMonth == null)
    {
      throw new UserFriendlyException("Supplier not provider finance month");
    }
    
    invoice.SetFinanceMonth(supplierFinanceMonth.StartDate, supplierFinanceMonth.EndDate);
}

在應用服務中,通過呼叫聚合及領域服務,完成這一建立 Invoice 的用例。

[UnitOfWork]
[Authorize(PermissionNames.Invoice_Create)]
public async Task CreateInvoice(CreateInvoiceDto input)
{
    await _invoiceManager.ValidCheck(input.orderId, input.SupplierId);
    var invoice = Invoice.Create(input.Name, input.Description)
        .SetOrder(input.OrderId);
    await _invoiceManager.SetFinanceMonth(invoice, input.SupplierId);
    await _invoiceRepository.InsertAsync(invoice);
    await _appNotifier.NewInvoiceAsync();
}

藉助領域服務,以此來完成多聚合間的協作,通過應用服務編排領域模型物件,完成一個業務用例。

領域事件

在軟體開發中,事件早已被我們所熟悉,一個按鈕按下,產生中斷事件,一個回車,前端頁面有偵聽事件,在事件風暴建模活動中,事件也是作為領域建模的突破口,事件的重要性不言而喻。其本質是發生的事實到引發了相關事情,在這其中的傳遞的資訊便是事件的內容。就如同貓叫了,引發著老鼠跑了,主人醒了,其中的事件便是貓叫了,而該事件是貓執行叫的動作後的結果。

在領域驅動設計中,最開始的版本中並沒有領域事件的概念,在 DDD 社群對領域驅動設計的內容不斷的充實中,引入了領域事件。領域事件的命名遵循英語中的“名詞 + 動詞過去分詞”格式,如,提交訂單後釋出的 OrderCreated 事件,訂單完成後 OrderCompleted 事件,用以表示我們建模的領域中發生過的一件事情,也符合著事件本身是具有時間特徵。

圖片

(EShopOnContainers 中一個例子)

對於領域事件本身,依據各層的使用方式及面對的目標不同,劃分出兩種事件型別,領域事件與應用事件(或整合事件),應用事件側重於應用層的使用,而領域事件沿用原領域事件的稱呼,更偏向於領域層。而又應側重點不同,又有著不同的使用方式,如領域事件更多的是從領域模型中釋出,其最終接收者為當前聚合所在限界上下文,而應用事件更為廣闊,從應用層釋出,其接收者為當前上下文或是其他上下文。

基於限界上下文間採用的部署方式不同,也存在著不同的通訊方式,如整個應用程式為單體,則所有上下文在同一個程式內,則上下文間事件互動時所採用的可以是程式內的事件匯流排,或是程式間使用的訊息佇列,而當在程式間時,就不得不使用程式間的訊息佇列了。

由於 DDD 中遵循一個用例對應一個事務,在一個事務中更新一個聚合,因此對於實際場景中需要變更多個聚合下,我們常通過編排方式呼叫其他聚合的服務,這不可避免的加重了對其他服務的依賴,藉助領域事件,則可以很方便的降低這種耦合,同時對於多個聚合的變更操作,由單個聚合的事務變成了多個聚合的事務,又依照實際影響的聚合情況,有著不同的處理方式,如多個協作的聚合為同一上下文內時,可通過強一致性去保證資料一致性,而處於多個限界上下文間的聚合時,則可依照最終一致性保證資料的一致性。

領域事件主要用途有:

  • 從事件角度豐富了領域模型
  • 保證聚合間的資料一致性
  • 實現事件事件溯源和 CQRS 等
  • 限界上下文間整合(釋出訂閱模式)

資源庫

在剛接觸資源庫(Repository)時,第一反應便是這就是個 DAO 層,訪問資料庫,然後吧啦吧啦,但是,當接觸的越久,越發認識到第一反應是錯的,資源庫更多的是對資源的管理,而不僅僅是資料庫中的資料,資料庫可以作為資源的一部分,但不是全部,我們習慣將對外部系統的呼叫稱為外部資源的獲取,這也是將外部系統作為資源的一部分。

對於聚合來講,資源庫的作用是負責將聚合持久化到資料庫的(通常是持久化到資料庫),並且由於聚合根負責維持聚合的生命週期,也就使得應考慮僅聚合根才應該擁有資源庫,這也是與 DAO 層不同的地方。

在分層設計時,考慮將資源庫的抽象劃分到領域層,屬於領域模型物件的一部分,如同設計防腐層的抽象閘道器般,資源庫的抽象作為特殊的閘道器,當在應用層或是領域層中操作資源庫抽象時,將資源庫作為管理聚合狀態的工具,可以忽視基礎設施層中對資源庫的具體實現。而在考慮基礎設施層中具體實現時,可根據需要選擇適合的工具,以此來管理和操作資源。

圖片

工廠

聚合從 0 到 1 的過程,可以通過多種途徑建立,一般來講,我們開發中常直接例項化或是反射例項化,而對於聚合來講,整個聚合是一個整體,命運共同體,並且由聚合根掌握聚合的生命週期。通常,我們可以藉助幾種方式來建立聚合,組裝聚合,在建立過程中封裝業務邏輯。

  • 聚合自身擔任工廠,在聚合根中實現 Factory 方法
  • 獨立的 Factory 類,用於有一定複雜度的建立過程,或者建立邏輯不適合放在聚合根上
  • 藉助其他聚合來建立,其他聚合擔任工廠角色
  • 藉助構建者模式靈活組裝聚合

聚合根的建立有多種方式,依據聚合內掌握知識的多少與建立邏輯的需要可靈活選擇。

//...
var partType= PartType.Create(input.Name, input.Description)
    .SetCategory(input.Category)
    .SetFactory(input.FactoryName);

如藉助構建者模式,通過拆分許多小的方法,將過多的引數拆分,以此避免一個建立方法引數中滿屏都是引數的情況,需要考慮吧拆分的方法需要滿足業務一致性,如內部的一些屬性間有約束條件下,需要劃分到一個方法中,以維持一致性或不變性。

學無止境

從2004年領域驅動設計到現在已經有17年時間了,並且在其中還有諸如六邊形架構,洋蔥架構,整潔架構等的出現,考慮的側重點不同,衍生著大量的新概念,也不斷地完善著領域驅動設計的思想。在學習與理解領域驅動設計中,總會有新的東西改變我們以往的思想,見到的越多,越發覺認識的越少,這或許也是學起來有點阻力的原因吧。

圖片

參考

  1. 《實現領域驅動設計》- Vaughn Verno
  2. 《領域驅動設計實踐》- 張逸
  3. 《軟體架構編年史》- herbertograca
  4. 領域驅動設計實現之路 - 滕雲
  5. 領域驅動設計編碼實踐 - 滕雲
  6. Package by component and architecturally-aligned testing - Simon

2021-01-18,望技術有成後能回來看見自己的腳步

相關文章