淺談如何理解領域驅動設計

本文作者爲長沙.NET社區開發者微笑刺客,轉載已獲得作者授權。

前言

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

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

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

戰略設計

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

統一語言

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

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

領域/子域

領域拆分

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

子域劃分

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

  • 核心域:其所體現的是核心服務,是代表着產品的核心競爭力。

  • 支撐域:其所體現的是支撐服務,沒它不行,但又達不到核心的價值,圍繞着產品內部所需要,但又不能單獨變更爲第三方服務,即它不是一個通用的服務。

  • 通用域:其所體現的中間件服務或第三方服務。本身可以通過現有的解決方案集成來完成的服務。

限界上下文

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

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

上下文(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,望技術有成後能回來看見自己的腳步

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章