《結合DDD講清楚編寫技術方案的七大維度》再討論

語言: CN / TW / HK

歡迎大家關注公眾號「JAVA前線」檢視更多精彩分享文章,主要包括原始碼分析、實際應用、架構思維、職場分享、產品思考等等,同時歡迎大家加我個人微信「java_front」一起交流學習

1 六大原則

1 前文回顧

我在之前文章《結合DDD講清楚編寫技術方案七大維度》介紹了從零到一使用DDD方法論搭建專案的七個步驟:

  • 四色分領域
  • 用例看功能
  • 流程三劍客
  • 領域與資料
  • 縱橫做設計
  • 分層看架構
  • 介面看對接

四色分領域介紹了使用四色分析法將一個整體需求拆分為不同領域,這是DDD方法論核心思想。四色分析法同樣可以用在子域或者限界上下文中,直到拆分出可以得心應手處理之邊界為止。

用例看功能介紹了當領域劃分完成之後,使用用例圖描述系統功能。用例圖不關心實現細節,而是從外部視角描述系統功能,即使不瞭解實現細節的人,通過用例圖也可以快速瞭解系統功能。

流程三劍客介紹了使用活動圖、順序圖、狀態機圖三種流程型別的圖示描述系統,三種圖各有特點:活動圖著重描述邏輯分支,順序圖著重描述時間線索,狀態機圖著重描述狀態流轉。

領域與資料介紹瞭如何區分領域模型和資料模型。二者重要區別是值物件儲存方式。領域模型在包含值物件同時,也保留了值物件的業務含義,資料模型可以使用更加鬆散的結構儲存值物件,簡化資料庫設計。

縱橫做設計介紹了縱向做隔離,橫向做編排。複雜業務之所以複雜,一個重要原因是涉及角色或者型別較多,很難平鋪直敘地進行設計,所以我們需要增加分析維度。其中最常見的是增加橫向和縱向兩個維度。

分層看架構介紹了系統架構分為兩個層次,第一種層次指本專案在整個公司位於哪一層。持久層、快取層、中介軟體、業務中臺、服務層、閘道器層、客戶端和代理層是常見分層架構。第二種層次指專案程式碼結構,一般可以分為介面層,訪問層,業務層,領域層,整合層和基礎層。

介面看對接介紹了一個介面程式碼編寫完成後,這個介面如何呼叫,輸入和輸出引數是什麼,這些問題需要在介面文件中得到回答。

本文沿用上文中足球運動員管理系統,主要從兩個維度對上文進行擴充,第一個維度是將DDD中一些概念與上文進行對映,例如領域、子域、限界上下文、實體、值物件、聚合與領域事件。第二個維度是展示DDD專案結構層次。

 

2 領域、子域與限界上下文

2.1 核心概念

這三個詞雖然不同但是實際上都是在描述範圍這個概念。正如牛頓三定律有其適用範圍,程式中變數有其作用域一樣,DDD方法論也會將整體業務拆分成不同範圍,在同一個範圍內進行才可以進行分析和處理。

上文例項中領域是足球,子域包括合同、醫療、訓練、比賽、採訪,合同子域可以分為兩個限界上下文:轉會和簽約,醫療子域可以分為兩個限界上下文:體檢和傷病。

領域可以劃分子領域,子域可以再劃分子子域,限界上下文字質上是一種子子域,那麼在業務分解時一個業務模組到底是領域、子域還是限界上下文?

這取決於看待這個模組的角度。你認為整體可能是別人的區域性,你認為的區域性可能是別人的整體,叫什麼名字不重要,最重要的是按照高內聚原則將業務高度相關的模組收斂。

 

2.2 限界上下文

限界上下文(Bounded contenxt)比較難理解,我們可以四個維度分析:

第一個維度是限界上下文字身含義。限界表示了規定一個邊界,上下文表示在這個邊界內使用相同語義物件。例如goods這個詞,在商品邊界內被稱為商品,但是快遞邊界內被稱為貨物。

第二個維度是子域與限界上下文關係。子域可以對應一個,也可以對應多個限界上下文。如果子域劃分足夠小,那麼就是限界上下文。如果子域可以再細分,那麼可以劃分多個限界上下文。

第三維度是服務如何劃分。子域和限界上下文都可以作為微服務,這裡微服務是指獨立部署的程式程序,具體拆分到什麼維度是根據業務需要、開發資源、維護成本、技術實力等因素綜合考量。如果按照子域進行微服務劃分可以拆分為:

  • 基礎服務:player-core-service
  • 合同服務:contract-core-service
  • 醫療服務:medical-core-service
  • 訓練服務:training-core-service
  • 比賽服務:game-core-service
  • 採訪服務:interview-core-service

如果按照限界上下文進行微服務劃分,合同和醫療服務可以再拆分:

  • 基礎合同服務:contract-base-service
  • 轉會合同服務:contract-transfer-service
  • 簽約合同服務:contract-signing-service
  • 基礎醫療服務:medical-base-service
  • 傷病醫療服務:medical-injury-service
  • 體檢醫療服務:medical-exam-service

第四個維度是互動維度。在同一個限界上下文中實體物件和值物件可以自由交流,在不同限界上下文中必須通過聚合根進行交流。聚合根可以理解為一個按照業務聚合的代理物件。

例如產品經理作為需求收口人,任何需求應該先提給產品經理,通過產品經理整合後再提給程式設計師,而不是直接提給開發人員。

 

3 實體、值物件與聚合

領域模型分為三類:實體、值物件和聚合。實體是具有唯一標識的物件,唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,沒有唯一標識。

聚合包括聚合根和聚合邊界兩個概念,聚合根可以理解為一個按照業務聚合的代理物件,一個限界上下文企圖訪問另一個限界上下文內部物件,必須通過聚合根進行訪問。

 

3.1 資料維度

領域模型與資料模型一個重要的區別是值物件儲存方式。領域物件在包含值物件的同時也保留了值物件的業務含義,而資料物件可以使用更加鬆散的結構儲存值物件,簡化資料庫設計。

如果需要管理足球運動員基本資訊和比賽資料,對應領域模型和資料模型應該如何設計?姓名、身高、體重是一名運動員本質屬性,加上唯一編號可以對應實體物件。

跑動距離,傳球成功率,進球數是運動員比賽表現,這些屬性的集合可以對應值物件。

3.2 程式碼維度

3.2.1 資料物件

PO(Persistent Object)直接與資料庫互動:

public class FootballPlayerPO {
    // 運動員ID
    private Long id;
    // 運動員姓名
    private String name;
    // 運動員身高
    private Integer height;
    // 運動員體重
    private Integer weight;
    // 比賽表現(JSON)
    private String gamePerformance;
    // 建立人
    private String creator;
    // 修改人
    private String updator;
    // 建立時間
    private Date createTime;
    // 修改時間
    private Date updateTime;
}

 

3.2.2 值物件

VO(Value Object)本質上是屬性之集合,其不具有唯一標識:

public class GamePerformanceVO {
    // 跑動距離
    private Double runDistance;
    // 傳球成功率
    private Double passSuccess;
    // 進球數
    private Integer scoreNum;
}

public class MaintainVO {
    // 建立人
    private String creator;
    // 修改人
    private String updator;
    // 建立時間
    private Date createTime;
    // 修改時間
    private Date updateTime;
}

 

3.2.3 實體物件

Entity具有唯一標識,這個唯一標識會伴隨實體物件整個生命週期:

public class FootballPlayerEntity {
    // 運動員ID
    private Long id;
    // 運動員姓名
    private String name;
    // 運動員身高
    private Integer height;
    // 運動員體重
    private Integer weight;
    // 比賽表現值物件
    private GamePerformanceVO gamePerformanceVO;
}

 

3.2.4 聚合物件

Agg(Aggregate)可以理解為一個按照業務聚合的代理物件,任何訪問本限界上下文物件必須經過聚合。實踐維度可以理解為充血模型版本BO,聚合物件中可以編寫業務邏輯:

public class FootballPlayerSimpleResultAgg {
    // 運動員ID
    private Long playerId;
    // 運動員姓名
    private String playerName;
}

public class FootballPlayerReadAgg implements BizValidator {
    // 運動員ID
    private Long playerId;
    // 頁數
    private Integer pageNum;
    // 條數
    private Integer size;

    @Override
    public void validate() {
        AssertUtil.notNull(playerId, new BizError);
        AssertUtil.notBigger(size, 100, new BizError);
    }
}

public class FootballPlayerWriteAgg implements BizValidator {
    // 操作型別
    private Integer maintainType;
    // 維護資訊
    private MaintainVO maintainInfo;
    // 運動員資訊
    private FootballPlayerEntity playInfo;

    @Override
    public void validate() {
        AssertUtil.notNull(maintainType, new BizError);
        AssertUtil.notNull(maintainInfo, new BizError);
        AssertUtil.notNull(playInfo, new BizError);
        if(maintainType == MaintainEnum.CREATE.getType()) {
            AssertUtil.notNull(maintainInfo.getCreator(), new BizError);
            AssertUtil.notNull(maintainInfo.getCreateTime(), new BizError);
        }
        if(maintainType == MaintainEnum.UPADTE.getType()) {
            AssertUtil.notNull(maintainInfo.getUpdator(), new BizError);
            AssertUtil.notNull(maintainInfo.getUpdateTime(), new BizError);
        }
    }
}

 

3.2.5 資料傳輸物件

DTO(Data Transfer Object)用於接收或傳輸外部資料,只應該暴露必要資訊:

public class FootballPlayerCreateDTO {
    // 運動員姓名
    private String name;
    // 運動員身高
    private Integer height;
    // 運動員體重
    private Integer weight;
    // 跑動距離
    private Double runDistance;
    // 傳球成功率
    private Double passSuccess;
    // 進球數
    private Integer scoreNum;
    // 建立人
    private String creator;
    // 建立時間
    private Date createTime;
}

public class FootballPlayerUpdateDTO {
    // 運動員ID
    private Long id;
    // 運動員姓名
    private String name;
    // 運動員身高
    private Integer height;
    // 運動員體重
    private Integer weight;
    // 跑動距離
    private Double runDistance;
    // 傳球成功率
    private Double passSuccess;
    // 進球數
    private Integer scoreNum;
    // 修改人
    private String updator;
    // 修改時間
    private Date updateTime;
}

public class FootballPlayerQueryDTO {
    // 運動員ID
    private Long playerId;
    // 頁數
    private Integer pageNum;
    // 條數
    private Integer size;
}

public class FootballPlayerSimpleResultDTO {
    // 運動員ID
    private Long playerId;
    // 運動員姓名
    private String playerName;
}

 

4 領域事件

當某個領域發生一件事情時,如果其它領域有後續動作跟進,我們把這件事情稱為領域事件,這個事件需要被感知。

球員比賽受傷,這是比賽域事件,但是醫療和訓練域是需要感知的,那麼比賽域發出一個事件,醫療和訓練域會訂閱。球員比賽取得進球,這也是比賽域事件,但是訓練和合同域也會關注這個事件,所以比賽域也會發出一個比賽進球事件,訓練和合同域會訂閱。

通過事件互動有一個問題需要注意,通過事件訂閱實現業務只能採用最終一致性,需要放棄強一致性,可能會引入新的複雜度需要權衡。

同一個程序間事件互動可以用EventBus,跨程序事件互動可以用RocketMQ等訊息中介軟體。

 

5 程式碼結構

5.1 六層結構

DDD程式碼實現方案不盡相同,我認為不能為使用DDD而是使用DDD,而是應該根據實際情況選擇當前最合適的方案。但是無論是什麼方案都需要遵循合理分層這個原則:

(1) API

介面層:提供面向外部介面宣告、DTO

(2) controller

訪問層:提供HTTP訪問入口

(3) service

業務層:領域層和業務層都包含業務,業務層可以組合不同領域業務,並且可以實現流控、監控、日誌、許可權功能,相較於領域層更豐富

(4) domain

領域層:提供Entity、VO、Agg、事件,聚合物件使用充血模型

(5) integration

整合層:訪問外部限界上下文服務,解析為本限界上下文聚合物件

(6) infrastructure

基礎層:提供PO、持久化能力

 

5.2 程式碼例項

如果player-core-service作為maven parent,那麼其具有以下maven module和分包:

> player-core-service
  > player-core-api
    > dto
    > facade
  > player-core-controller
    > controller
      > adapter1 (DTO > Agg)
  > player-core-service
    > bizService
      > adapter2 (Agg > PO)
    > facadeService
      > adapter3 (Agg > DTO)
  > player-core-domain
    > vo
    > entity
    > agg
    > event
  > player-core-integration
    > proxy
      > adapter4 (DTO > Agg)
  > player-core-infrastructure
    > po
    > mapper

 

5.3 如何取捨

上述專案有六層結構,那麼必然帶來層次間呼叫物件互相轉換這個問題:

adapter1接收外部請求(DTO)需要轉換成(Agg)
adapter2處於業務層(操作資料庫)(Agg)需要轉換成(PO)
adapter3處於對外業務層(暴露RPC)(Agg)需要轉換成(DTO)
adapter4處於整合層(訪問外部RPC)(DTO)需要轉換成(Agg)

物件轉換會帶來兩個問題:第一個是程式碼複雜度增加,第二個是有一定效能損耗。這也是分層結構必須要付出之代價。

因為每層物件看似相同(具有相同屬性或者結構)但是語義和角色完全不同,每一層可以為物件新增本層之特性,相較於使用一個物件貫穿始終,可擴充套件性顯著提升。

 

6 文章總結

第一章節回顧《結合DDD講清楚編寫技術方案七大維度》這篇文章並且提出擴充套件兩個維度:概念對映與程式碼結構,第二三四章節對應擴充套件第一個維度概念對映,第五章節對應擴充套件第二個維度程式碼結構,希望本文對大家有所幫助。

歡迎大家關注公眾號「JAVA前線」檢視更多精彩分享文章,主要包括原始碼分析、實際應用、架構思維、職場分享、產品思考等等,同時歡迎大家加我個人微信「java_front」一起交流學習