DDD 落地:從阿里單據系統,看 DDD 在大廠如何落地?
阿里單據系統的 DDD 最佳實踐
作者:少嵐,阿里同城履約物流技術團隊
本篇以電商購物場景爲背景,探討了領域驅動設計(DDD)在實際應用中的實踐過程。你會發現,DDD 的核心理念在於,通過一系列實用技巧,挖掘出能揭示問題本質的領域模型,並通過模型間的協作解決領域問題,從而駕馭問題領域的複雜性。對於 DDD 愛好者來說,它猶如一個充滿挑戰和智慧的玩具,在深入思考問題本質和構建抽象知識模型的過程中,讓人沉浸於心流狀態。
一、前言
領域驅動設計(Domain-Driven Design),簡稱 DDD,並非一種框架或具體的架構設計,而是一種架構設計思想。其代表性著作便是 “領域驅動設計之父”Eric Evans 的經典書籍《領域驅動設計》。DDD 的核心目標是通過各種實用方法和技巧提煉出具有體現問題實質的領域模型,並通過保護和組織模型協作來解決領域問題,從而掌控問題領域本身的複雜性,也就是爲什麼 DDD 會被認爲是軟件核心複雜性的應對之道。
DDD 的理想應用場景是具有固定領域體系且複雜性較高的應用軟件系統設計的各個環節和過程,但這無疑是一項艱鉅的任務。DDD 要求技術人員高度協同,提升建模技巧,精通領域設計,並通過不斷的時間推移和領域知識的吸收消化,以達成應對複雜性的目標。只有這樣,DDD 的價值才能在項目的中後期得到充分體現。本文旨在帶領大家從第一視角體驗這種實踐過程,感受 DDD 的獨特魅力,掌握其精髓,爲在 DDD 中探索的朋友們指明方向。
我個人對面向對象編程有着濃厚的興趣,編寫代碼如同孩子玩玩具般充滿樂趣。DDD 讓我有機會玩得更高級、更復雜、更具挑戰性的玩具。對於一個始終保持少年心態的程序員來說,構建領域模型極易讓人進入心流狀態。這種深入思考問題本質,構建抽象知識模型的過程,讓我對 DDD 情有獨鍾。
我想用兩個詞來表達我體會到的魅力:知識、思考。
知識:Eric Evans 發行的《領域驅動設計》一書中第一章介紹的就是知識,特別指領域知識,但是這裏的知識並不是簡單的問題的表象,而是深入到問題的本質,只有獲取到真正的知識,運用好各種 DDD 模式和優秀的戰術,打造具有豐富知識設計的模型,才能充分發揮領域驅動設計的好處。
思考:獲取知識並不容易。例如,給你一批地球日出月落的數據,你可以用地心說、日心說和地平說等不同模型來擬合地球的各種現象,究竟哪個模型的知識最適合呢?產品和業務提出需求時,很多時候難以觸及問題的本質。因此,設計模型、選擇模型都需要設計者做到深入思考,挖掘概念,並和領域專家(如果存在)達成一致。
本文是一篇關於 DDD 實踐的典型案例文章,讀者也可認爲它類似於一種多字段單據的設計模式。全文將以一個簡化的電商購物背景作爲領域上下文,重點介紹領域組件的形成過程,並突出 DDD 的核心要點。但同時需要注意到,本文專注於單個領域上下文的戰術實踐,不涉及多個領域上下文的協作。文章核心內容將按照 4 個小節展開:
-
從實體生命週期出發,圍繞一個聚合根的設計作介紹,包括原因、好處;
-
從單據字段的性質,特點等,挖掘出一類命令對象集合;
-
是體現如何從深層領域本質修正一個狀態機模型,從而改變了我的組件設計爲狀態同步模型;
-
根據防腐層的一些好處,以及如何在防腐層中通過重構去撿回來重要的領域實體;
通過本文,希望大家能更好地理解和應用領域驅動設計,爲複雜業務場景找到解決問題的方法。
二、單據
1、生命週期
1)領域概念:貧血實體
爲了簡化問題,本文將以簡化的電商交易平臺領域爲例,探討其中的核心概念。簡而言之,即消費者在某個購物平臺下單購買商品,支付完成後,商品按照計劃送達消費者手中。
其中系統比較重要的就是訂單,訂單作爲單據,是一種交易憑證,表達了交易關係的事實依據。它主要涵蓋了客戶、商品、時間、支付等要素,可作爲會計覈算的原始資料和重要依據。電商交易單據以電子化形式存在於信息系統中,我們統一稱之爲交易主訂單。
通常情況下,一個交易主訂單代表一次交易行爲。其中的交易內容,會用交易子訂單表示,例如:用戶一次性購買 5 個蘋果,3 個梨子,那就對應爲一個交易主訂單,它刻畫了用戶的購買行爲,其中有兩個交易子訂單,一個描述 5 個蘋果,一個描述 3 個梨子。
如果我們系統的子單可以單獨發貨,甚至多倉發貨的,那麼我們再加一個發貨單的概念,用作和包裹一一對應,一個包裹可以放任意交易子單的物品,例如上面的兩個子單可以放到兩個包裹,用兩個發貨單表示,一個發貨單 4 個蘋果,另一個發貨單 1 個蘋果加 3 個梨子,當然我們的電商系統還有商品、客戶、收件人、供應商等實體,現在我們在系統中有了這些實體,如下圖所示。
在系統中,實體具有各自的生命週期。一個交易主訂單可能包含多個交易子訂單,一個包裹可以隨意組合子訂單進行發貨。但這些模型相對較弱,因爲難以充實。如何將這些需求封裝爲知識,以設計出更完善的模型,只有在實際操作中才能找到答案。這也是系統初期面臨的實際情況,不應過度設計,往往一開始就是一個簡單的 CRUD 系統。
2)領域知識:生命週期
以上介紹的實體都有自己的生命週期,生命週期體現在系統行爲中。以簡單電商系統爲例,從下單到服務結束,基本經歷以下過程行爲:
下單:
-
用戶提交訂單
-
商品的庫存佔用
-
用戶在規定時間內進行支付
-
訂單階段性狀態推進:待支付、支付完成、待發貨、運輸中、配送中、妥投等等
查詢:
- 生命週期中發生查詢請求
取消:
-
訂單有效期到期取消訂單
-
用戶取消訂單
以上流程,都和上面提到的實體相關,但具有相同生命週期的實體組合較少。例如,訂單實體的生命週期與客戶完全不同。客戶從註冊到註銷,一直存在,而訂單僅在一次完整交易行爲中存在。商品和訂單也不同,訂單被取消生命週期結束,而商品可以重新售賣。因此,在商品、供應商、客戶、交易主訂單、交易子訂單、發貨單等實體中,只有交易主訂單和交易子訂單具有相同生命週期,過程還包括髮貨單。
另一方面,我們看一下會改變交易主訂單和交易子訂單狀態的一些代碼行爲(通常我們會封裝到服務類中),代碼在系統剛開始基本會寫成如下這樣:
但以上的依賴鏈路,會導致各種單據實體異常穩定,無人敢於輕易更改。隨着需求的複雜化,管理這種依賴關係顯得尤爲重要。如果不加改進,這種方式可能會引發諸多問題,以下列舉了一些問題及其特點:
事務一致性:在某種程度上,這些服務都需要改變訂單單據的狀態。以提交訂單服務、訂單支付服務、取消邏輯處理服務、時效管理服務、物流服務等服務爲例,它們都需要獨立保證事務的邏輯一致性。這涉及到併發和亂序問題,保持一致性的邏輯代碼複雜且易錯,開發人員維護起來也會感到疲憊。跨進程操作訂單更是災難性的後果。
共同閉包性:我們發現,大部分交易主訂單的狀態是由子訂單或發貨單推進的。例如(妥投一致性規則例子):如果包裹從不同倉庫發出,可以走不同的路線。只有當發貨單 Asub1、Asub2、Asub3 都妥投後,才能將交易主訂單 AOrder 修訂爲完成。每個包裹更新事件基本上都是獨立的一次事務。假設 Asub1 的妥投事件同步過來,我們必須將 Asub2、Asub3、AOrder 從數據庫中取出進行檢查和處理。實際上,子單的狀態也可能被髮貨單推動。如果發現實體組合中有許多這樣的同時修改需求,說明它們基本上是一個共同閉包的整體,我們可以考慮將這樣的組合進行抽象封裝。
共性邏輯散落:例如,以上提到的維護妥投一致性規則的代碼,如果還有一些亂序狀態回傳處理的代碼,記錄狀態變化流水的代碼,這些代碼各自的本質其實是基本相同的。這些重複的邏輯散落在各類服務中,每次修改一個需求的時候可能需要修改各個服務。例如,我需要在記錄流水類換了接口簽名實現,那麼我就需要在各個服務類中都去更換這個接口簽名,這樣的共性邏輯散落對修改就是關閉的。
3)領域模式:聚合與聚合根
實際上,對於熟悉 DDD 的人來說,他們會很容易接觸到聚集的概念。我們需要考慮是否可以將這些單據構建爲聚集,以及構建後是否存在其他潛在問題。在經過對系統各個方面的權衡之後,我們基本上可以確定,得大於失;
知識拓展(聚合與聚合根):在具有複雜關聯的模型中,確保對象更改的一致性是具有挑戰性的。不僅相互無關的對象需要遵循一定的規則,緊密關聯的對象組也需要遵循一定的規則。然而,過於保守的鎖定機制會導致多個用戶之間無謂地相互干擾,從而使系統不可用。針對這些模型,我們採用一個抽象來封裝它們之間的引用。聚集(Aggregate)是一種相關對象的抽象封裝,被視爲數據修改的基本單位。每個聚集都有一個根(Root)和一個邊界(boundary),邊界用於定義聚集內包含什麼,而聚合根則是唯一對外的引用。——摘自《領域驅動設計》。
如下圖所示,我們先來觀察將交易主訂單、交易子訂單和發貨單(包裹)構建爲聚集,並以交易主訂單作爲聚集根後的效果。然後,我將列舉幾個方面,說明通過這些改變,使得交易實體從貧血模型轉變爲充血模型,還有這樣做的理由:
聚合根一致性:聚合模式的核心特徵是,所有涉及交易子訂單的操作都會通過其聚合根交易主訂單來執行,並由聚合根負責保持它們之間的規則一致性。聚合之間的實體能夠相互引用,下面我們將詳細介紹一些關於聚合根一致性的規則:
-
主子單一致性:不僅上面提到的妥投一致性規則,還可以有出倉庫一致性規則,也就是說,交易子單的出倉操作是獨立的,當所有發貨單都已完成出倉,交易主單纔將狀態更新爲出倉。這一規則的邏輯維護責任將轉移到聚合根交易主訂單實體中,每次聚合狀態發生變化時,都會觸發一次檢查;
-
發貨單與子單一致性:如前所述,包裹是存放子單的部分數量的地方。每個包裹中包含哪些子單以及數量多少,所有包裹的子單總數需要與源交易子訂單邏輯保持一致。現在,這一一致性得以由交易主訂單保證,不再分散。在發現不一致的情況下,只需在一個地方進行報警監控。
聚合根封裝細節:所以很自然的,我們也可以把一些散落在各個操作交易主訂單和交易子訂單的邏輯都封裝到聚合根中:
-
節點流水記錄:由於單據作業流水記錄的節點與單據狀態相對應,因此流水記錄邏輯可以被封裝到聚合根中。每當狀態發生變化(例如從提交訂單(Accepted)到支付完成(Paid)),都會記錄一條流水。在 2.3 節中,我們將專門介紹封裝在聚合根內的狀態同步模型。需要注意的是,這裏並不是讓主訂單直接操作數據庫,它只需負責生成流水,而將流水記錄到數據庫則應由領域服務負責;
-
訂單狀態推進:各種事件(支付、發貨、妥投)同步及異步回傳的處理代碼,都將會封裝到交易主訂單中,讓主訂單變更子訂單和發貨單狀態,邏輯只有一份,可維護性強;另外一個狀態的變更用狀態機是前期可以考慮的方案。
事務修改的基本單元:有了聚合,倉儲必須得到整合。而倉儲整合的關鍵是確保聚合的修改成爲事務的基本單元,這具有諸多好處:
-
沒有數據庫概念:取消邏輯服務、查詢服務、支付邏輯處理服務等服務,不在需要寫一遍 SELECT 交易主訂單,交易子訂單,UPDATA 交易主訂單,交易子訂單等邏輯,甚至沒有 INSERT 這種邏輯,而它們都只需兩個動作:拿出交易主訂單聚合,放交易主訂單聚合回倉庫;
-
副作用的保護:以前的模型中,各個服務都會對單據產生副作用。而現在只有交易主訂單會對包裹和子單產生副作用。這種副作用還可以被監控,下一節我們將詳細介紹如何通過深入演進命令實體模型來保護這些副作用。
有了以上的設計,可以想得到,如果需要添加新的狀態或一致性邏輯,只需在交易主訂單聚合操作中進行即可。此外,新增拒收回傳服務也無需重新編寫保障業務事務的邏輯,無需編寫一行記錄流水的代碼,封裝性和可維護性的價值得到了很好的保障。同時,支付處理服務、取消邏輯處理服務和妥投邏輯處理服務等服務的職責變得單一,代碼邏輯變得更加輕鬆,可讀性也得到了提升。
聚合的壞處:正如沒有完美的架構一樣,聚合模式也有其利弊。以下是實施聚合根後需要面臨的問題及解決方法:
-
查詢性能:顯然,如果你只想修改交易主訂單的一個字段,倉儲將加載所有相關的交易子訂單,這無疑會對性能產生負面影響。另一方面,如果你一次性加載所有聚合實體,那麼不需要修改的實體也必須寫回數據庫,但這可以通過一些微小的設計優化來解決,例如,根據聚合根修改了哪個實體,爲該實體添加不同的版本,這樣倉儲就只會根據版本按需更新對象。另外,有些狀態變化可能對一致性沒有影響,但仍然會觸發一致性檢查,這類性能影響不大。
-
無謂的更新:例如你只想更新單據的一個字段,而你的 SQL 是這樣寫的,UPDATE TABLE A SET A.name = "Marry" WHERE XX,而使用了聚合根之後,就需要更新整個 DO 的多個字段。如果你不小心設置了其他字段,它們也會被更新,從而減少了犯錯的成本。但這並不會成爲大問題,可以加入斷言或顯式打印每次修改字段的日誌,以便開發者迅速發現錯誤。
-
屬性訪問:訪問單據的問題顯而易見,例如,某個服務需要訪問交易子訂單的數據,只能通過交易主訂單進行交互,這是否讓人難以接受?其實這個問題很容易解決,只要將查詢分離出來,創建一套聚合的訪問視圖(訪問模型),讓交易主訂單的充血方法返回這個訪問視圖,讓服務操作這個視圖即可。而且這個視圖可以在各種地方使用,不必擔心會產生副作用,性價比非常高。
其實聚合根的設計不應該過大,裏面的實體種類最好不要太多,上面例子提到的聚合只有 3 個 Entity 剛剛好,但實際問題中最多三到四個實體就到了一個比較合適的度了,而且這個時候聚合根的好處會體現的更明顯。
2、隱式概念
1)領域知識:單據字段
這節,我們將會直面單據類 CURD 最討厭的問題,它就是單據的字段。單據字段在 MVC 三層架構中,程序員很可能會去偷懶直接用一個 DO 對象捅到業務層去,最多加一個 DTO 對象。而在聚合根中,字段更加會難以管理,但如果你願意用心去細細思考字段的一些特性,說不定也能發現很多不一樣的世界。
單據字段多樣性:單據最重要的作用是承載屬性,而且屬性非常多,如下面的交易子訂單實體的屬性,而且還有各種用作關聯的屬性,再加上拓展字段,如果這些字段全部由聚合根去維護,那麼聚合根的方法會臃腫成怎麼樣子?
@Getter
@Setter
public class TradeSubOrder {
private Long id;
private Date gmtCreate;
private Date gmtModified;
private boolean test;
private StatusEnum status;
// more field
private String size;
private boolean repositoryTrace = false;
private String extendAttribute;
//還有更多
// getter setter toString
}
如果聚合根有 20 個屬性,發貨單有 15 個屬性,交易子訂單有 20 個屬性,那麼聚合根就要有 (20+20+15) * 2 = 110 個屬性訪問器對外,這個充血對象和 DTO 感覺是沒有差別的,而且新加一個字段需要加兩遍,這樣看的話,子單據、發貨單等實體必須單獨自己去管理自己的字段比較好,而聚合根只需維護一致性的時候去訪問該字段即可。
動態拓展字段:如果要你做一個屬性經常動態變化的實體,你應該很容易想起把屬性打平(建立一個表存 key、value、關聯 id),或者直接加一個 extAttribute 的 Map 實現,把屬性打平後,我們也不用擔心實體的搜索問題,因爲現在的查詢分離的寬表、NoSQL 索引都比較強大了,如果一些字段屬性只是在單據上作展示和透傳用的,並無多少行爲關聯,那麼很建議這樣做。
字段的內聚性:分析一下訂單不難發現,一個訂單的字段可以歸類,從每一類的修改入參可以看出,各自都具有相同的修改原因,如果字段是具有內聚性的,那麼多樣性的字段就應該是可以分類治理的:
-
交易主訂單:“收貨地址”,“收貨人姓名”,“聯繫電話”,“郵箱”;共同變化的原因:< 聯繫人信息類 >
-
交易主訂單:“客戶 id”,“客戶姓名”,“會員等級”,“賬號”;;共同變化的原因:< 購買者信息類 >
-
交易主訂單:“支付方式”,“支付單號”,“支付狀態”,“支付時間”,“實付金額”,共同變化原因 < 支付行爲 >
-
發貨單:“送達時間”、“服務時效”、“配送員”、“物流訂閱商”;共同變化的原因:< 物流節點 >
-
交易子訂單:“規格”、“數量”、“價格”、“圖片”、“貨主”、“優惠價”;共同變化的原因:< 商品編碼 >
-
其他歸類......
有意識的程序員,已經開始把以上各類獲取、設置字段屬性的代碼分別歸類到各個不同的函數中,或者不同的類(可能叫商品表達類、物流信息 Handler 類等)中等等,這種方式在一定程度上是提高了複用性,提高了可維性,但這還遠遠不夠;
字段變化難跟蹤:單據承載了很多的信息,各種字段信息是什麼時候變化的,單據字段的變化也可能是多次變化的,這些變化的時間和軌跡對於業務的意義有時候也很重要,通常有些特殊的字段產品和業務同學會明確給你提用例需求去做,我們來展示一個真實例子 “修改地址”:
-
用戶修改地址:對於電子商務類服務,無論是各種快遞、淘寶等都是支持在未妥投之前讓用戶去修改地址的,做的好的產品,甚至可以支持多次地址的修改,那麼用戶在什麼時間修改了地址呢?修改前是什麼?修改後是什麼?這些信息都必須在某個地方很明顯的表達出來。
-
加一個服務類:當業務需要的時候,我們自然可以專門開發一個服務類插入系統去支持,但這種需求又有多少呢?未來有沒有?能不能有一套設計方案可以保護核心流程,保留可選項,又不失優雅的去支持這類業務呢?
2)概念突破:命令實體
知識拓展:本小節將會介紹一個叫命令實體的領域概念,在許多 DDD 框架和介紹文中,Command 通常被描述爲一個簡單的貧血 DTO 加上參數驗證邏輯,而不承擔業務邏輯的角色。如果經常使用 DDD 框架,可能會對這個概念產生混淆。這種用法可以類比應用 Service 和領域 Service。爲了避免誤解,我們將在下文中明確指出 Command 與 CQRS 架構中的 Command 的區別,並建議將下文的 Command 替換爲 Operation,以符合領域邏輯。
溝通獲取知識:在 DDD 中,想要和領域專家通過溝通獲取知識,統一語言是很重要的,本文的電商領域入門其實比較低,所以基本上溝通會很順暢,但這不代表知識挖掘是一件容易的事情,下面來自我和產品經歷的一段對話:
我:業務最近上了新的話費充值特惠版產品,那個客戶的手機號他不讓用戶輸入,要我從賬號中心獲取,爲什麼?
產品:是的,他們這款是優惠充值產品,只能給客戶自己充值,所以省略用戶自己填寫的步驟,提高體驗。
我:明白了,其實本質都是填寫發貨單的手機號,只不過是實現方式不同,在我們工程領域是一個標準,實現方式不同。
產品:我大概明白你的意思,這樣做沒問題,當然肯定還會有第三種方式的,但他們都是做一件事。
我:我記得在電腦端下單和手機端下單,發貨單的地址填充方式也不同。
產品:嗯,是的,手機端可以提供精準下單地址,電腦不行,這也是不同的方式,而且這些和產品無關了,所以你也要支持組合使用哦。
我:我知道怎麼實現了,我做一個發貨單手機號填充命令,但是實現類不同,下次你們變化,我就讓你們自己配置。
產品:可以,命令我能聽懂,上次小冰跟我說什麼 interface 就不知道是啥了。
我:哦,interface 你就不用管了,其實我也是用的 interface 哈哈哈哈。
其實通過以上溝通,我明白的是,產品需要的是這個補充字段是可以配置的,但大多數人拿到需求立馬代碼就出來了,也不考慮一下這樣寫的原因,其實很多時候,只有在寫代碼的時候,你纔會知道除了業務和產品表面上的需求,內部可能蘊含着更深知識可以挖掘;
專業知識:查詢與命令的劃分是常見的做法。我們經常將代碼分爲兩類:一類用於改變狀態,這類代碼稱爲命令;另一類用於獲取狀態但不改變,這類代碼稱爲查詢。單據的字段通常都會改變單據實體的狀態,因此,如果我們將這類邏輯視爲命令,那麼很明顯,如果我們看到一個類的名稱帶有 Command 後綴,我們可以很容易地想到,這個類必然會改變狀態,而單據的狀態就是字段。
知識拓展(柔性設計):在 Eric Evans 的《領域驅動設計》一書中,他建議我們將邏輯代碼組織成無副作用的函數,讓函數返回 Value Object。然後,讓簡單的副作用命令根據返回的 Value Object 來更改對象狀態。如果可能的話,儘可能將這些邏輯代碼封裝到 Value Object 中,形成一個無副作用、可組合複用的 Value Object。由於無副作用,我們可以自由地組合和複用這些函數。(見《領域驅動設計》——柔性設計 p174)
說到組合複用,再結合產品要的配置,以及我需要的柔性設計,那麼把以前所有的改變狀態的代碼,都組織爲命令對象,讓命令返回修改後的單據的編輯稿版本 (Value Object),最後讓聚合根自己把編輯稿(Value Object) 更新到自己的字段上,這樣就基本符合 Eric Evans 的這種模式。整個過程類似於編輯表單的過程,用戶點擊編輯命令後,會獲得可編輯的界面(表單草稿),編輯完成後提交按鈕觸發後臺操作,將表單草稿應用於實際生效的表單中。
命令模式:這個過程和命令模式是差不多的。我們會把命令交給聚合根去執行,對比上面命令模式的圖我們可以看出,其實運維人員就是 Client,他把封裝好的命令間接設置給交易主訂單聚合根,而 Invoker,則是聚合根,他負責執行具體的命令,同時也會記錄命令的執行,改變自身狀態。例如下面的代碼所示,爲聚合根執行命令的過程。
public class SubmitOrderUsercase{
public void sumit(Request request) {
TradeMainOrder mainOrder = getMainOrder();
//獲取命令的具體實現
IPhoneNumberCompleteCommand command = getCommand(request,IPhoneNumberCompleteCommand.class);
//聚合根執行手機號完善命令
mainOrder.execute(command);
// ......
//獲取命令的具體實現
IDiscountCalculateCommand command = getCommand(request,IDiscountCalculateCommand.class);
//聚合根執行折扣計算命令
mainOrder.execute(command);
// ......
}
public IPhoneNumberCompleteCommand getCommand(Request request,Class clazz){
// 業務配置好的,什麼場景用什麼命令.......
}
}
上面還提到字段內聚性,那麼我可以把所有相同原因變化的邏輯設計爲一個個命令對象來管理我的單據字段。這個對象會封裝邏輯所需的入參,甚至查詢外部服務(實際上只是查詢,沒有狀態變更,查詢結果也是入參的一種,它只依賴於入參)。因此,我們可以創建發貨單完善命令、支付信息完善命令、購買者信息完善命令、商品信息完善命令等等對象,我還可以給他們做一個最大的分類,按照不同實體有不同的命令修改接口得到交易主訂單變更命令、交易子訂單變更命令,這些命令只能由聚合根(交易主訂單)執行。最後,通過修改依賴關係,我們可以得到如下圖所示的組件結構:
如此的靈機一閃,引入命令實體後,以上所有的字段問題,都剛好被這個模型擬合了,我列舉幾個好處:
設計良好:很明顯的倒置依賴,保護聚合根的獨立性;函數式編程,可以組合而不擔心邏輯錯誤,有人可能會質疑,命令內部是不是會直接訪問對象呢?如下圖的命令接口所示,如果這樣設計該接口明顯是有副作用的,但如果我們傳入的是編輯稿(類似視圖),然後我們編輯視圖,最後更新回到實體就可以了。
public interface TradeSubOrderChangeCommand {
String getSubOrderId();
void execute(TradeSubOrderDraft subOrder);
}
字段分治管理:有了命令後,加上適當的命令命名,字段的管理再也不混亂。每個字段都應該有其對應的設置命令進行管理,而不是讓各種服務類去進行賦值管理。同時,對字段的處理也可以封裝到命令中。你可以隨時定位一個字段的變更命令,只需要思考一下字段的歸類。最重要的是,這種字段的分類的獨立性可以讓你操作字段的代碼獨立分離,使其具有更好的開閉性。這一點正好可以解決字段的多樣性問題。
命令封裝邏輯:命令可以封裝 action 調用。賦值只是命令的目的。既然封裝了 action 的調用,那麼對 action 的入參和結果的處理也可以封裝到命令中。更重要的是,只要是符合觸發源的目的、職責單一,部分業務邏輯也可以封裝到命令中。在以往很多貧血系統中,這些都是由 service 負責的,似乎沒有 service 不知道該如何安置代碼一樣。
隨時隨地跟蹤:下面是一個簡單版本的聚合根執行命令的代碼示例。其中 record 方法根據命令本身的屬性提供有選擇性地記錄執行結果的能力。如果有重要的字段,你可以找到該單據對應命令的執行流水,並進行可視化管理。這種粒度的管理在業務運維和開發疑難問題排查上都非常有用。
public class TradeMainOrder{
public void onCommand(TradeSubOrderChangeCommand command) {
if (!tradeSubOrderDict.isEmpty()) {
TradeSubOrder subOrder = findSubOrder(command.getSubOrderId());
// 變更前的快照代碼
command.execute(subOrder);
// 變更後的對比邏輯代碼,記錄字段變化個數、時間
record();
// ......
makeStateConsistent();
} else {
log.error("子單變更命令執行失敗,子單列表爲空,{}", EagleEye.getTraceId());
}
}
public void onCommand(TradeOrderChangeCommand command) {
// 變更前的快照代碼
command.execute(this);
// 變更後的對比邏輯代碼,記錄字段變化個數、時間
record();
// ......
makeStateConsistent();
}
}
組合命令:如前所述,無副作用的函數可以方便地進行組合複用。舉個例子,一個提交訂單的場景中調用了一個名爲 Combine 的發貨單完善命令。這個 Combine 命令充當容器的角色,包含了手機完善、郵箱完善等幾個命令。它的執行邏輯就是依次執行這些命令,因爲它們具有相同的接口,所以實現這個組合非常簡單。此外,它還具備以下特點:
-
只要給每個命令一個 id,那麼命令組合就可以在外界進行配置化;
-
由於命令組合可以進行配置化,因此哪些命令被執行是在運行時決定的,從而體現了靈活性;
-
命令組合的實現都是基於函數式的,因此組合後的命令不會出現 “組合爆炸” 的問題,整個過程也是透明和安全的;
有了組合命令,我們可以輕鬆地根據業務需求將命令定製爲組合並上線。能夠被管理和配置的獨立代碼是程序員追求的最高藝術境界。
3、深層模型
1)領域知識:狀態推進本質
發貨單也具有狀態:已接單、待發貨、運輸中、攬收、妥投、拒收、取消。這些狀態的變化驅動是接收外部事件進行推動的,但因爲要考慮事件丟失、亂序問題,當一個事件到來後,但前置事件已經丟失、延遲未到,那單據應該決策成爲什麼狀態呢?自然而言,我們很容易聯想到狀態機,開始我們也是這樣做的,狀態圖如下:
問題空間(物流實操):業務流程的真正設置如下,且中間流程不允許跳過,例如如果沒有在運輸中,那麼攬收就不可能發生,這說明實際業務狀態轉換與狀態機的解空間不匹配,後者包含了很多不必要的部分。另一方面,如果我們設計一個遊戲機的投幣程序,用一個狀態機實例來表示遊戲機的狀態:投幣狀態、空閒狀態、遊戲狀態,那麼狀態機就完全沒問題,這裏的根本原因在於,問題空間本身就是解空間的模型驅動的。
不純粹的解空間:爲什麼解空間中有多餘的連線?例如:運輸中會跳到妥投,這是因爲解空間考慮了計算機和架構的細節問題,如事件傳播中的異常和速度問題。如果事件保證順序消費,那這條連線就不需要;如果按照這種思路組織代碼和編寫代碼,必然會在領域實體中加入不屬於領域的邏輯,這是領域驅動設計(DDD)所禁忌的。另一方面,如果使用狀態機,需求變更添加一種新狀態,那麼新的連線也會讓很多人感到困擾。是時候調整模型,把該邏輯給去掉了,那麼怎麼去掉呢?
代碼職責問題:假設業務要求在每個狀態節點經歷時,記錄節點流水。狀態機的實現方式會如何呢?
如果正確的事件順序是:
1、運輸事件,2、攬收事件,3、妥投事件,
但實際順序是:
2、攬收事件,1、運輸事件,3、妥投事件;
當狀態順序混亂時,狀態機在攬收狀態,運輸事件到達,記錄運輸節點流水應該讓messageHandler
處理還是攬收節點處理呢?後者明顯不合理,前者也顯得勉強。如果除了記錄流水,還需要發送外部消息呢?那麼messageHandler
的職責就會越來越重。
2)深層模型:修正狀態機模型
不存在狀態推進:我們討論的是發貨單的狀態,它代表者物流的操作過程,所以其操作進度要反饋到訂單的進度,這個過程其實更多的是一種狀態同步過程,而不是狀態流轉的過程,所以我們的解決辦法是**:換個角度思考訂單狀態變更這件事,是狀態同步,而不是狀態推進 **。**我們用一個**流程實例** (也可以設計爲無狀態的流程)來解決整個問題。
我們現在把更新狀態的算法換了,從狀態推進變爲狀態同步,如上圖所示,首先刻畫整個問題空間的狀態流程作爲解空間模型,我們發現這個流程是絕對的無環的,一種拓撲排序。它和狀態機有幾點不同:
-
有序性:狀態機的節點是無序的,或者說只能相對有序,而我們的同步模型則是有序的;這與問題空間的工序順序一致,問題空間的每一步都是有序的。
-
拓撲結構:狀態機的節點可能存在環狀結構,但我們的同步模型是拓撲排序的,符合業務節點的特性。每個節點都沒有環,拓撲結構是該領域的特有屬性,這一點很重要,因爲我們採用領域驅動設計。
-
運作機制:狀態機的運作核心是圍繞事件和當前狀態尋找下一個流轉狀態,而同步模型則以流程實例爲核心。每當有事件到來時,我們將該流程的節點標記爲已同步。如上圖所示,1、2、4、5 對應的事件均已到達,因此它們呈綠色。每次事件處理完成後,我們比較最大序號的節點和單據當前狀態的序號,將序號較大的節點更新爲單據狀態。
-
計算機無關性:模型不再關注事件是否亂序、延遲。只要事件到達,我們就將其對應的節點標記爲已同步,並觸發相應節點的業務邏輯,如計費消息的發送和流水的記錄。
邏輯封裝到節點上:顯然,上述流水記錄代碼無需放入 messageHandler,發送節點的外部消息發送代碼也不需要綁定 messageHandler。它們可以封裝在運輸節點內,或採用觀察者模式,監聽運輸節點以完成相應行爲。這樣的代碼更具擴展性,靈活性較高。
與狀態機相比,新的狀態同步模型在開發效率和代碼維護方面都有所提升。狀態機具有線性複雜度,而狀態同步模型則是常數級別的複雜度。這個例子充分證明了領域驅動設計的核心本質 *:領域的重要性、知識的重要性 *。__
4、邊界模型
1)領域知識:邊界隱式概念
上面我們討論完核心模型,我們這節主要討論的是邊界的模型,軟件設計的一個關鍵在於恰當地區分邊緣。的確,單一訂單處理系統與許多外部系統(如賬戶中心、商品中心、庫存中心、決策中心、支付中心和履約中心等)具有豐富的交互。這些交互主要通過調用各系統的接口來獲取或寫入數據,其依賴關係如下所示:
零散的隱式概念:很明顯在整個接單的系統中,這些邊界很多概念是應該屬於我們的領域上下文中的,例如贈品、計劃、庫存、會員等級等概念,但這些概念往往只是存在於字段屬性中,例如會員等級就只存在賬號實體的屬性中,並沒有專門爲他們創建實體,但需不需要爲他們創建實體,也是一個問題,這種發現實體的契機,其實也是需要另一個因素決定的,那就是有沒有行爲和這些屬性綁定,所以一開始,我們先不爲這些散落的領域邏輯設計實體,但我們應該爲以後需要這些實體而做好準備,下面的防腐層正是最重要的一步。
2)領域模式:防腐層
知識拓展(防腐層):設計一個隔離層,以便根據客戶自己的領域模型爲其提供相關功能。這個層通過與另一個系統的現有接口進行對話,而對系統的修改最小。在內部,這個層負責在兩個模型之間進行必要的雙向轉換。——摘自《領域驅動設計》
如上圖所示的依賴關係,我們顯然與其他領域進行了綁定。爲確保內部邏輯的獨立性,實現對修改的封閉和對擴展的開放,我們必須改變依賴關係。這是明確劃分外部邊界的關鍵一步,如下圖所示:
設計防腐層會帶來一定的編程困擾。你需要在內部設計一個進出參模型或內部接口,並添加一層適配器層。適配器層負責實現內部與外部實體的對接。儘管這種方法較爲複雜,但我們仍需瞭解使用防腐層的理由:
-
保護核心層概念:
-
例子:
例如,你在公司中的角色是老闆,但在家裏的角色是父親。如果你將老闆實體放在家庭中爲孩子做飯,這個家庭就會依賴不必要的邏輯,這違反了整潔架構的原則,可能導致變更和穩定性問題;
-
例子:
在交易系統這種複雜的系統中,例如一個在供應商系統中代表它自己編碼的 merchantCode 可能來到交易系統這邊會變成 supplyMerchantCode,同一個值,用角色字段區分他們這自然是很重要的;
-
關注點分離:
-
說明:外部接口的非邏輯依賴變更不會影響核心邏輯。你只需確保返回字段的含義一致即可;
-
適配邏輯的代碼:
-
說明:有很多代碼你只是用來做外部實體的處理的,變成內部可識別的實體,例如決策中心傳給你的是 2021-07-12 ~ 2021-07-13,但你內部用的是一個 stat 的 Date 變量和一個 end 的 Date 變量;那就需要適配了,這些代碼如果編寫在覈心邏輯中,那你在維護核心邏輯的時候也不得不多思考一件事,不僅代碼臃腫,還消耗你的精力。
-
說明:有一個點很重要,爲什麼要做這種設計,因爲設計就是需要把代碼放在它該呆的地方,這種轉換的代碼,總要有一個適配器處理;
-
可隨時挖掘隱式概念:
-
說明:例如用戶的會員等級,這個會員等級字段屬性,就是一個隱藏的概念,它存在於用戶賬號中,所以你難以發覺。但日後不斷的需求變更中,你或許會發現它可能是一個封裝性很好的實體。下一節我們將具體介紹如何挖掘除會員等級這個實體。
嚴格遵守防腐層並不容易,首先要求編寫代碼的人具備這方面的意識,以免違反規則。其次,編寫代碼的人應對整個系統架構有一定了解。雖然如此,如果有人把控這部分代碼,新手也可以參與系統建設。這一思想來源於《人月神話》中的外科手術醫生只有一個的觀點。
實際上,劃分邊緣有兩種方式:防腐層和基礎設施、應用類業務劃分。當將與領域邏輯無關的邏輯劃分邊界後,六邊形架構就出來了。
3)隱式概念:重構中發現模型
以上提到,在劃分和外部邊界的時候,先不考慮散落的邏輯概念抽象爲實體,有了防腐層之後,當有新實體的產生需求即可把這些概念實體化了;這篇我們用一個例子來說明如何通過重構,從防腐層代碼中,抽象一個實體出來;首先下面是一個簡化版本賬號中心域的防腐設計,我們專門爲計劃的返回做了一個內部的 Entity — 用戶賬號;
@Data
public class UserAccount {
// 其他字段 ......
/**
* 會員等級
*/
private int userLevel;
// 其他字段 ......
}
現在有另一段獲取賬號信息,根據會員等級獲取對應折扣比例的信息,這個代碼是這樣寫的:
public class XxxxxxxService {
public Double getDiscount(UserAccount account) {
switch(account.getUserLeval){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
特別的,我們在其他 service 中也發現了一樣的代碼,當你注意到這點的時候,就是一個領域實體出現的時候了,那麼我們可以複用這段邏輯,並把邏輯和賬號關聯起來,把該行爲封裝到賬號中,如下所示:
@Data
public class UserAccount {
/**
* 客戶等級
**/
int userLevel;
public Double getDiscount() {
switch(userLevel){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
但這還不夠,我們忘記了一個領域概念遺留了,那就是會員等級 ,現在是時候把它顯性化爲一個領域實體了,所以最終的重構結果是:
@Data
public class UserAccount {
/**
* 客戶等級
*/
private UserLevel userLevel;
}
public class UserLevel {
int userLevel;
public Double getDiscount() {
switch(userLevel){
case 1:
return 0.99;
case 2:
return 0.98;
case 3:
return 0.97;
case 5:
return 0.95
default:
return 1.00;
}
}
}
領域上下文:在代碼重構的過程中,可能會遇到這樣一個情況:UserLevel 類在賬戶中心也有,名稱一樣,但數據綁定行爲卻大相徑庭。這是因爲我們所關注的字段所處的領域上下文發生了改變,從賬戶中心領域轉向了交易領域。在不同的領域中,針對相同字段的行爲封裝是各具特色的,這也是領域驅動設計(DDD)的一個顯著特點。
重構是領域驅動設計的引擎:在重構過程中,藉助領域知識來引導設計方向,確保領域邏輯的獨立性,發掘領域實體和聚合根,具有至關重要的意義。這個例子雖然簡單,但在很多情況下,我們要突破深層模型,發掘更優質的設計,都離不開重構。掌握重構技巧是程序員的必備素質。若你認爲代碼難以重構,可以嘗試引入單測和小步快跑的方法。
5、領域服務
知識拓展:有時候,對象不是一個事物,在某些情況下的操作你可能找不到合適的 Entity 或者 Value Object 去封裝,強制把他們歸於一類,不如順其自然引入一種新元素:SERVICE(服務)。其中,這個 SERVICE 元素在 DDD 的各個層中也會有體現,所以會存在應用層的 SERVICE,領域層的 SERVICE 和基礎設施層的 SERVICE。
領域服務:如何設計領域服務是一個值得探討的問題。本文借鑑了《架構整潔之道》中的用例劃分領域服務。在需求分析階段,用例對於問題分析非常有幫助。將一個用例設計爲一個服務,有助於區分應用層服務和領域服務。
-
應用層服務用作和輸入輸出相關的邏輯,並且負責調用領域層服務
-
領域層服務用作和領域模型交互,負責組織和協調的領域模型工作的邏輯
因此,針對本文 “生命週期” 小節介紹的單據的流程,自然就有以下的領域服務:
-
提交訂單領域服務:執行讀取命令配置、執行命令、庫存佔用、價格計算、定時失效等邏輯代碼;
-
支付領域服務:讀取命令配置、執行命令,負責支付校驗、調用支付服務、訂單各種命令執行等邏輯代碼;
-
取消領域服務:讀取命令配置、執行命令,負責釋放庫存、取消訂單、取消定時任務等邏輯代碼;
-
.......
此外,還有應用層服務,如提交訂單應用服務、支付應用服務和取消應用服務。區分它們的關鍵在於邏輯是否具有領域概念。例如,導出操作並無領域含義,但獲取運維針對不同產品身份的命令配置、命令組合及執行命令結果等業務邏輯,則應放到領域服務層。
從上面可以看出,許多領域或應用層服務是基於實體(Entity)和價值對象(Value Object)構建的。例如,提交訂單服務涉及操作單據(Entity)和命令(Value Object)。從這個角度看,他們的行爲類似於將領域中的潛在功能組織起來以執行特定任務的腳本。由於文章重點關注單據模型設計和介紹,此處不再贅述。
三、後記
領域驅動設計的核心目標:我們在文章開頭闡述了領域驅動設計(DDD)的關鍵目的,它旨在利用多種實用策略和技巧來提煉出一個能夠真正反映實際問題本質領域的模型,並且保護和組織好這個模型之間的相互作用以便解決領域內的問題。我們已經在文章中運用了聚合根模式、統一語言交流、防腐層模式和重構技術等方式來進行探討,然而,在實際應用中,可用於解決該問題的方法和知識遠遠不止於此。有時候,我們還需要對現有的模式進行調整和創新來克服建模過程中遇到的問題。這就需要我們的團隊技術人員全面掌握 DDD 的相關知識,同時具備精湛的建模技術和豐富的實踐經驗,還包括靈活的思維能力和敏銳的洞察力。這些品質對於我們技術人員的日常工作的開展和自身的專業發展都有着重要的意義。
領域模型協作與組織:由於文章的篇幅和主題的限制,我們並沒有在文章中詳細地探討關於領域模型之間合作與組織的問題,然而這實際上是非常重要的一部分內容。通常情況下,我們需要考慮的不僅僅是領域模型的純度,還有其性能和交易屬性。例如,領域服務如何管理領域實體,怎樣將領域服務和應用程序服務區分開,以及如何將構建程序和執行程序獨立開來等等。如果沒有具體的案例作爲參考,那麼上述的問題可能會讓人感到有些抽象,但是我們可以通過參考一些優秀的設計規範,比如提高模塊間的凝聚力和降低它們之間的耦合度,SOLID 原則,以及軟件架構的基本原則等,以此來指導我們的實踐工作。讀者可以在自己的實踐中慢慢領悟這些原則的重要性。
演進與重構:本文以業務單據系統爲例,系統初始階段可能非常簡單,直接採用三層架構即可。但隨着需求的增長和變化,簡單的系統將面臨複雜性問題。我們必須掌握每次需求變化,實踐 Martin Fowler 的兩頂帽子原則——重構 + 編寫新功能。不斷重複這個過程,系統得以逐步演進。若重構 + 編寫新功能始終圍繞領域知識統一模型進行設計,那麼這個過程就是所謂的領域驅動設計。這也是爲什麼 DDD 會如此重視那些隨着時間的推移而逐漸演化的強大領域模型。
總結:領域驅動設計是一個不斷髮展和重構的過程。在實際情況當中,我們可以採用多種方式和技術,比如說聚合根模式、統一的語言交流、防腐層模式等等,來解決領域內的問題。團隊的技術人員應該具備豐富的 DDD 的知識,同時也需要有出色的建模技巧和豐富的實踐經驗。在設計的過程中,我們還需要考慮到性能、交易屬性等方面的因素,以確保領域模型的純度。通過不斷地進行重構和添加新的功能的工作,我們就能很好地應對系統的發展和複雜性的問題。最終,領域驅動設計的目標是將投入的成本與業務需求的行爲價值保持平衡。
參考書籍
1.《重構》
2.《架構整潔之道》
3.《領域驅動設計》
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/9j61VzAo23O9Un6UU-cgpQ