1、引言
說到DDD領域驅動設計,都有點蹭熱點的感覺。這幾年後端圈子逢人必提架構,提架構必提DDD,感覺DDD的中文轉譯不像是「領域驅動設計」而是「對對對」,但是筆者作為一名研發大頭兵在寫程式碼的時候經常有種感覺「道理我都懂,但是我還是迷糊」的感覺,總是深感落地困難,在經歷了多個DDD計畫落地實踐之後,筆者總結一下作為一名一線研發對領域驅動的理解,希望對各位有所幫助,此外 領域驅動設計的實作並非是一套通用的準則,不同計畫可能在具體落地方面略微有所變化,本文只是盡量歸納共性
2、計畫分層
搭建一個DDD計畫,在最開始就要定義好各個模組的職責和POM依賴關系,筆者所在公司一般利用alibaba-Cola的maven骨架快速建立DDD計畫結構,cola官網給出了兩張圖,可以作為一般DDD計畫的分層指南:
下面我將對上圖中的每一層進行詳細解釋,以及我們在日常開發的時候應該把什麽樣的程式碼放到這一層裏面。
2.1 domain層(核心)
領域驅動設計,從名字來看最重要的就是領域,領域,在我看來就是邊界。在早期做小計畫的時候,建包的時候往往建一個叫做entities的包,這個包裏面放的是都是一些物件,這些物件有兩個特點:
(1)類除了內容就是get/set,都是簡單類 (2)這個包或者模組下面只會有常量或者簡單類,不會存放介面,這個包或者模組裏面完全沒有「行為」
但是如果仔細想想這個分包存在不合理之處。不合理之處在於貧血模型過多,大量的類只有內容沒有行為,這個分包就是一個存放簡單類的大集合,這種不合理之處在於和物件導向設計的思路相悖。
總的來說在DDD的設計理論下,這一層是毫無疑問最重要的一層,領域劃分有一整套的方法論,領域劃分的好不好直接決定了後期系統的可維護性高不高,這裏筆者看了很多講解DDD領域劃分的文章,給我的感覺是看起來很有用,名詞都很高端,但是實際還是用不好,筆者認為領域建模是需要大量訓練的,不是說了解了幾個名詞例如「事件風暴」、「故事地圖」,「用例分析」,「從戰略到戰術」就自然成為領域大師了的。
概括的說這一層包含下述要素:
具體來說這一層適合承載下述內容:
事件:領域內部發生了某些事件,需要對相關的領域進行事件傳播,傳播的就是事件,比如一個下單動作,就會發送一個下單事件,告知倉儲領域釘選庫存,所以需要定義一些事件物件。
實體和值物件:
值物件:
值物件用於描述領域裏某個方面而本身沒有概念標識(無ID)的物件。值物件 不可變性:值物件一旦建立完成後就不可以修改;唯一方法是重新建立一個物件(所以值物件咱們不需要定義set方法)
可替換性:內容相同的兩個值物件是完全等價的,在使用是可相互替換
實體:
實體是重要的領域概念,實體的3個主要特征:ID: 唯一的身份標識。ID相同代表實體是同一個 Lifecycle: 實體的生命周期具有連續性 Status: 生命期經歷不同的狀態(例如常見的訂單狀態)
聚合:一般由多個實體和值物件組成,具備行為
工廠:一個聚合體內部有多個實體和值物件這樣的話建立聚合體就需要一個工廠方法來簡化復雜的建立過程。
服務介面:領域內部一定有各種活動比如我們的訂單域,一定會有下單、結算、支付等服務介面,這些介面的實作也是在這一層實作的,但是domain層其實用不依賴其他任何層,所以這和個服務介面的實作都是無狀態的。
儲存介面:聚合體有行為,但是它對實體和值物件的修改需要具備持久化的能力,所以這裏需要定義數據持久化的部份介面,儲存聚合根的狀態。(這裏只定義介面實作交給基礎設施層來實作)
領域存取介面:COLA架構推薦領域與領域之間的數據存取應該解耦,需要使用閘道器來進行存取
總結:DDD的domain層需要具備下述特點
不依賴其他任何模組
這個模組可以有業務邏輯,但是這些業務邏輯是脫離基礎設施的,也就是說這些邏輯不依賴於你使用Mysql或者Oracle而改變邏輯實作,具體使用那種技術中介軟體,這是基礎設施層的職責。
這個模組的類不是都是貧血模型,是具有行為的。
這裏我還想補充一下這一層的建包規範,更推薦按照下圖右邊的方式分包,領域建議做隔離,否則時間一個整個原本很順暢的呼叫鏈路就逐步會腐化成網狀混亂呼叫。
2.2 infra層 (基礎設施層,和中介軟體打交道)
對於基礎設施層,理想情況下就是如果整個計畫需要換用另一套技術方案,比如把MQ從RocketMQ換成Kafka,把Mysql換成Oracle,其他層應該毫無感知,只需重寫這一層即可,也就是說這一層是純技術向的一層,也就是說 領域層專註於業務,基礎設施層專註於技術 。註意的是 這一層只是對領域層的介面實作,所以只需要依賴領域層
這一層則一般承載下述內容
數據存取層的全部程式碼,例如 mybatis的mapper介面,xml檔
工具類
實作領域層定義的倉儲介面、服務介面、閘道器介面
模型轉換,MapStruts
Spring Configuration配置類
2.3 app層 (業務層)
套用層(Application Layer):主要負責獲取輸入,組裝上下文,呼叫領域層做業務處理,如果需要的話,發送訊息通知等。層次是開放的,套用層也可以繞過領域層,直接存取基礎實施層;app層依賴了領域層也依賴了基礎設施層,所以在實際開發中往往扮演「協調者」的角色,對於這一層往往存放下述內容
透過呼叫基礎設施層的能力或者領域層的介面完成Client層(如果有)定義的所有介面的程式碼實作
透過呼叫基礎設施層的能力或者領域層的介面完成Controller介面或者HSF介面的實際邏輯的實作
這一層同時依賴基礎設施和domain層,所以這裏應該是寫的是與技術無關但是與核心業務強相關的邏輯。
2.4 adapter層
適配層(Adapter Layer):負責對前端展示(web,wireless,wap)的路由和適配,對於傳統B/S系統而言,adapter就相當於MVC中的controller; 這一層應該只依賴app層 。這一層的邏輯大部份都是呼叫app層的方法來實作。
以多次ddd計畫實踐下來這一層 邏輯非常輕 ,一般情況下這一層會存放下述內容
Controller介面
統一例外處理類
參數檢驗(@Validated)
controller的攔截器(Interceptor) 過濾器等
定時任務
業務自訂切面
這一層的分包其實沒有什麽規律,一般按照實際業務場景來,筆者經歷一個三端計畫,也就是說伺服端同時對接車機、app、H5三個展示端,所以分包就按照端型別分包
2.5 client 層
這一層一般並不是必須的,這層的目的是打包時打出一個輕量級jar包,然後類似Dubbo這種框架,可以直接依賴然後進行RPC呼叫,也就是說這一層主要目的就是提供當前計畫對外介面的SDK,所以 這一層也是獨立的,不與其他任何模組進行依賴 。所以這個模組應該以介面和DTO為主,具體實作應該在APP層來實作。
所以哪些適合定義在這一層呢:
RPC 請求的響應模型和請求模型即DTO
RPC 請求的介面定義
部份服務消費者需要使用到的列舉
對計畫服務消費者側的錯誤碼
分包的話,這一層也比較簡單,可以參考下圖進行分包
筆者所參與的多個DDD計畫,有時候會將Client層和Adapter層直接合並,所以正如本節開頭所說的這層並不是必須的
2.6 start模組
這個模組比較簡單,就是存放springBoot計畫的啟動類和配置檔。這個模組有三個作用
突出顯示啟動類的位置
這個模組會依賴其他所有模組,所以很適合做maven打包入口
因為依賴了其他所有模組所以適合在此模組編寫單元測試程式碼
可以在這個啟動類上添加全域配置 (類似:@EnableXXX @ComponentScan ...)
相信透過我上面這些描述,你可能還是會覺得虛無縹緲,下面我們將舉一個例子,以例子來說明上面的幾個重要概念以及怎麽落地DDD
現在有個對客側的工單管理系統,需求背景是這樣的
某個系統在交付給幾個主力客戶之後,為了保證服務品質,客戶在發現產品問題之後可以在工單系統中發起問題工單,使用者填寫工單內容以後,發起工單處理流程,工單根據工單型別會有不同的人進行處理填寫處理結果,處理完成之後需要發起工單的人點選確認或者駁回,駁回之後需要工單處理人員再次處理直到工單被客戶點選確認之後完成工單處理。
使用者的需求可能描述的就是這麽簡單且模糊,接下來我們要從這些描述裏面抽象出來關鍵部份
(1)工單:這是一個明顯的實體,因為有明顯的生命周期和狀態 (2)工單內容:這是一個值物件,大致包含了工單型別和工單文字描述 (3)工單狀態:這是一個值物件(從技術層面上來說就是個列舉) (4)工單的處理結果:這是一個值物件,包括處理人、處理時間、處理結果描述等資訊 (5)工單發起人和處理人:值物件
接下來分析這個需求活動中有哪些行為 (1)發起工單 (2)處理工單 (3) 駁回工單
根據上面的資訊我們可以初步定義下面的工單實體,受制於篇幅限制不適合張貼太多程式碼,值物件已經在程式碼註釋中標註
@Data
public class WorkOrder {
* 工單唯一id
*/
private String workOrderId;
* 父工單id
*/
private String parentOrderId;
* 發起人id
*/
private String initiatorId;
private Date startTime;
private Date processTime;
private Date completeTime;
private Date rejectTime;
* 工單內容: 值物件:包含 圖片、工單型別、文字內容等
*/
private WorkOrderContent content;
* 工單處理狀態: 值物件 列舉
*/
private WorkOrderStatus workOrderStatus;
* 工單處理結果: 值物件,包含發起人,處理人,處理結果,處理意見等內容
*/
private WorkOrderHandleResult workOrderHandleResult;
private WorkOrder(Date startTime, String workOrderId, String initiatorId, WorkOrderContent content, WorkOrderStatus workOrderStatus, WorkOrderHandleResult workOrderHandleResult) {
this.workOrderId = workOrderId;
this.initiatorId = initiatorId;
this.content = content;
this.workOrderStatus = workOrderStatus;
this.workOrderHandleResult = workOrderHandleResult;
this.startTime = startTime;
}
* 發起工單
*
* @return WorkOrder
*/
public static WorkOrder start(String initiatorId, WorkOrderContent content) {
DateTimeFormatter chargeSeqDateFormatter = DateTimeFormatter.ofPattern("yyMMddHHmmssSSS");
String wordOrderId = "WorkOrderID" + chargeSeqDateFormatter.format(LocalDateTime.now())
+ new DecimalFormat("000").format(new SecureRandom().nextInt(999));
WorkOrderStatus workOrderStatus = WorkOrderStatus.INITIAL;
return new WorkOrder(new Date(), wordOrderId, initiatorId, content, workOrderStatus, null);
}
* 處理工單
*/
public void handle() {
this.workOrderStatus = WorkOrderStatus.HANDLEING;
this.processTime = new Date();
}
* 處理完結
*
* @param workOrderHandleResult 工單處理結果
*/
public void finish(WorkOrderHandleResult workOrderHandleResult) {
this.completeTime = new Date();
this.setWorkOrderHandleResult(workOrderHandleResult);
this.workOrderStatus = WorkOrderStatus.COMPLETED;
}
* 工單駁回 這裏重新啟動一個工單
*/
public WorkOrder reject() {
if (!workOrderStatus.equals(WorkOrderStatus.COMPLETED)) {
return null;
}
String parentWorkOrderId = workOrderId;
Date startTime = getStartTime();
DateTimeFormatter chargeSeqDateFormatter = DateTimeFormatter.ofPattern("yyMMddHHmmssSSS");
String newWorkOrderId = "WorkOrderID" + chargeSeqDateFormatter.format(LocalDateTime.now())
+ new DecimalFormat("000").format(new SecureRandom().nextInt(999));
WorkOrder workOrder = new WorkOrder(startTime, workOrderId, initiatorId, content, WorkOrderStatus.HANDLEING, null);
workOrder.setParentOrderId(parentOrderId);
workOrder.setRejectTime(new Date());
return workOrder;
}
}
發起工單的時候明顯還會有對應的事件發生,比如工單狀態變更的時候需要進行站內信或者信件通知,可以做如下設計
之後我們可以設計服務介面如下:
@Service
public class WorkOrderServiceImpl implements WorkOrderService {
@Autowired
WorkOrderRepository workOrderRepository;
@Autowired
WorkOrderEventRepository workOrderEventRepository;
@Override
public WorkOrder startOrder(String userId, WorkOrderContent workOrderContent) {
WorkOrder order = WorkOrder.start(userId, workOrderContent);
workOrderRepository.save(order);
workOrderEventRepository.sendWorkOrderEvent(new WorkOrderEvent(order.getWorkOrderId(), order.getWorkOrderStatus().getValue()));
return order;
}
@Override
public void processOrder(WorkOrderHandleResult workOrderHandleResult, String handlerId, String workOrderId) {
WorkOrder workOrder = workOrderRepository.get(workOrderId);
workOrder.handle();
workOrderEventRepository.sendWorkOrderEvent(new WorkOrderEvent(workOrder.getWorkOrderId(), workOrder.getWorkOrderStatus().getValue()));
workOrderRepository.save(workOrder);
}
}
服務介面層的程式碼實作依賴倉儲介面,在domain模組中倉儲介面可以做如下定義,具體實作則由基礎設施層實作
public interface WorkOrderRepository {
void save(WorkOrder workOrder);
WorkOrder get(String workOrderId);
}
public interface WorkOrderEventRepository {
void sendWorkOrderEvent(WorkOrderEvent workOrderEvent);
}
至此領域層也是最重要的一層的內容設計完畢,其余層的程式碼設計圍繞領域層進行程式碼填充即可。本文由於篇幅僅介紹核心層領域層的設計思路。
總結
本文主要對DDD計畫落地的一些經驗進行了分享,作為一名一線研發如果領導要求使用DDD做計畫開發,對於研發而言其實最重要的就是要明白我寫一個功能到底怎麽寫,到底把類放在哪個模組才不會到導致程式碼腐壞,本文則針對這方面提出了部份建議。筆者認為,學習DDD是很有必要的,DDD的亮點是徹底貫徹了物件導向的設計思路,透過劃分領域實作了「高內聚 低耦合」 的目標,但是實際開發中如果計畫並不是很大很復雜,並不是一定要套用上面的四層架構,此外即使使用DDD作為計畫開發,有時也不需要套用所有的DDD概念到計畫中,比如本文給的例子就沒有用到「工廠」,此外實體WorkOrder也直接充當了聚合體的角色,這是因為在需求範圍內並不需要上更多的概念來徒增程式碼復雜度。總之筆者認為沒有最好的架構,如果能透過合理編排層次實作程式碼解耦,程式碼易讀,這就是一種好設計,好架構。
如喜歡本文,請點選右上角,把文章分享到朋友圈
如有想了解學習的技術點,請留言給若飛安排分享
因公眾號更改推播規則,請點「在看」並加「星標」 第一時間獲取精彩技術分享
·END·
相關閱讀:
作者:自然吸氣發動機
來源:https://juejin.cn/post/7310038698338533439
版權申明:內容來源網路,僅供學習研究,版權歸原創者所有。如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!
架構師
我們都是架構師!
關註 架構師(JiaGouX),添加「星標」
獲取每天技術幹貨,一起成為牛逼架構師
技術群請 加若飛: 1321113940 進架構師群
投稿、合作、版權等信箱: [email protected]