從Alibaba-Cola到DDD,一線研發對領域驅動的思考

👉 這是一個或許對你有用的社群
🐱 一對一交流/面試小冊/簡歷最佳化/求職解惑,歡迎加入芋道快速開發平臺知識星球。下面是星球提供的部分資料:
👉這是一個或許對你有用的開源專案
國產 Star 破 10w+ 的開源專案,前端包括管理後臺 + 微信小程式,後端支援單體和微服務架構。
功能涵蓋 RBAC 許可權、SaaS 多租戶、資料許可權、商城、支付、工作流、大屏報表、微信公眾號、CRM 等等功能:
  • Boot 倉庫:https://gitee.com/zhijiantianya/ruoyi-vue-pro
  • Cloud 倉庫:https://gitee.com/zhijiantianya/yudao-cloud
  • 影片教程:https://doc.iocoder.cn
【國內首批】支援 JDK 21 + SpringBoot 3.2.2、JDK 8 + Spring Boot 2.7.18 雙版本 

1、引言

說到DDD領域驅動設計,都有點蹭熱點的感覺。這幾年後端圈子逢人必提架構,提架構必提DDD,感覺DDD的中文翻譯不像是“領域驅動設計”而是“對對對”,但是筆者作為一名研發大頭兵在寫程式碼的時候經常有種感覺“道理我都懂,但是我還是迷糊”的感覺,總是深感落地困難,在經歷了多個DDD專案落地實踐之後,筆者總結一下作為一名一線研發對領域驅動的理解,希望對各位有所幫助,此外領域驅動設計的實現並非是一套通用的準則,不同專案可能在具體落地方面略微有所變化,本文只是儘量歸納共性
基於 Spring Boot + MyBatis Plus + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/ruoyi-vue-pro
  • 影片教程:https://doc.iocoder.cn/video/

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
publicclassWorkOrder

{

     * 工單唯一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;

privateWorkOrder(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

     */

publicstatic 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;

returnnew

 WorkOrder(

new

 Date(), wordOrderId, initiatorId, content, workOrderStatus, 

null

);

    }

     * 處理工單

     */

publicvoidhandle()

{

this

.workOrderStatus = WorkOrderStatus.HANDLEING;

this

.processTime = 

new

 Date();

    }

     * 處理完結

     *

     * 

@param

 workOrderHandleResult 工單處理結果

     */

publicvoidfinish(WorkOrderHandleResult workOrderHandleResult)

{

this

.completeTime = 

new

 Date();

this

.setWorkOrderHandleResult(workOrderHandleResult);

this

.workOrderStatus = WorkOrderStatus.COMPLETED;

    }

     * 工單駁回 這裡重新啟動一個工單

     */

public WorkOrder reject()

{

if

 (!workOrderStatus.equals(WorkOrderStatus.COMPLETED)) {

returnnull

;

        }

        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
publicclassWorkOrderServiceImplimplementsWorkOrderService

{

@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
publicvoidprocessOrder(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模組中倉儲介面可以做如下定義,具體實現則由基礎設施層實現
publicinterfaceWorkOrderRepository

{  

voidsave(WorkOrder workOrder)

;  

WorkOrder get(String workOrderId)

;  
}

publicinterfaceWorkOrderEventRepository

{  

voidsendWorkOrderEvent(WorkOrderEvent workOrderEvent)

;  
}

至此領域層也是最重要的一層的內容設計完畢,其餘層的程式碼設計圍繞領域層進行程式碼填充即可。本文由於篇幅僅介紹核心層領域層的設計思路。
基於 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/yudao-cloud
  • 影片教程:https://doc.iocoder.cn/video/

總結

本文主要對DDD專案落地的一些經驗進行了分享,作為一名一線研發如果領導要求使用DDD做專案開發,對於研發而言其實最重要的就是要明白我寫一個功能到底怎麼寫,到底把類放在哪個模組才不會到導致程式碼腐壞,本文則針對這方面提出了部分建議。筆者認為,學習DDD是很有必要的,DDD的亮點是徹底貫徹了面向物件的設計思路,透過劃分領域實現了“高內聚 低耦合” 的目標,但是實際開發中如果專案並不是很大很複雜,並不是一定要套用上面的四層架構,此外即使使用DDD作為專案開發,有時也不需要應用所有的DDD概念到專案中,比如本文給的例子就沒有用到“工廠”,此外實體WorkOrder也直接充當了聚合體的角色,這是因為在需求範圍內並不需要上更多的概念來徒增程式碼複雜度。總之筆者認為沒有最好的架構,如果能透過合理編排層次實現程式碼解耦,程式碼易讀,這就是一種好設計,好架構。

歡迎加入我的知識星球,全面提升技術能力。
👉 加入方式,長按”或“掃描”下方二維碼噢
星球的內容包括:專案實戰、面試招聘、原始碼解析、學習路線。
文章有幫助的話,在看,轉發吧。
謝謝支援喲 (*^__^*)

相關文章