長文多圖一步步講清楚:DDD理論、建模與程式碼實現全流程

2023-05-12 15:00:50


1 六個問題

1.1 為什麼使用DDD

DDD方法論核心是將問題不斷分解,把大問題分解為小問題,大業務分解小領域,簡而言之就是分而治之,各個擊破。

分而治之是指直接面對大業務我們無從下手,需要按照一定方法進行分解,分解為高內聚的小領域,使得業務有邊界清晰,而這些小領域是我們有能力處理的,這就是領域驅動設計的核心。

各個擊破是指當問題被拆分為小領域後,因為小領域業務內聚,其子領域高度相關,我們在技術維度可以對其進行詳細設計,在管理維度可以按照領域對專案進行分工。需要指出DDD不能替代詳細設計,DDD是為了更清晰地行詳細設計。

在微服務流行的網際網路行業,當業務逐漸複雜時,技術人員需要解決如何劃分微服務邊界的問題,DDD這種清晰化業務邊界的特性正好可以用來解決這個問題。


1.2 方法與目標

我們的目標是將業務劃分清晰的邊界,而DDD是達成目標的有效方法之一,這一點是需要格外注意的。DDD是方法不是目標,不需要為了使用而使用。例如業務模型比較簡單可以很容易分析的業務就不需要使用DDD,還有一些目標是快速驗證型別的專案,追求短平快,前期可能也不需要使用領域驅動設計。


1.3 整體與區域性

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

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


1.4 粒度粗與細

業務劃分粒度的粗細並沒有統一的標準,還是要根據業務需要、開發資源、技術實力等因素綜合考量。例如微服務拆分過細反而會增加開發、部署和維護的複雜度,但是拆分過粗可能會導致大量業務高度耦合,開發部署起來是挺快的,但是缺失可維護性和可延伸性,這需要根據實際情況做出權衡。


1.5 領域與資料

領域物件與資料物件一個重要的區別是值物件儲存方式。在討論領域物件和資料物件之前,我們首先討論實體和值物件這一組概念。實體是具有唯一標識的物件,而唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,並沒有唯一標識。

領域物件在包含值物件的同時也保留了值物件的業務含義,而資料物件可以使用更加鬆散的結構儲存值物件,簡化資料庫設計。

現在假設我們需要管理足球運動員資訊,對應的領域模型和資料模型應該如何設計?姓名、身高、體重是一名運動員本質屬性,加上唯一編號可以對應實體物件。跑動距離,傳球成功率,進球數是運動員比賽中的表現,這些屬性的集合可以對應值物件。

值物件在資料物件中可以用鬆散的資料結構進行儲存,而值物件在領域物件中需要保留其業務含義:



1.6 抽象與靈活

抽象的核心是找相同,對不同事物提取公因式。實現的核心是找不同,擴充套件各自的屬性和特點。例如模板方法設計模式正是用抽象構建框架,用實現擴充套件細節。

我們再回到資料模型的討論,可以發現指令碼化是一種拓展靈活性的方式,指令碼化不僅指使用groovy、QLExpress指令碼增強系統靈活性,還包括鬆散可延伸的資料結構。資料模型抽象出了姓名、身高、體重這些基本屬性,對於頻繁變化的比賽表現屬性,這些屬性值可能經常變化,甚至屬性本身也是經常變化,例如可能會加上射門次數,突破次數等,所以採用鬆散的JSON資料結構進行儲存。


2 基本概念

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

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

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

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

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

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

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


2.2 實體、值物件與聚合

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

聚合包括聚合根和聚合邊界兩個概念,聚合根可以理解為一個按照業務聚合的代理物件,一個限界上下文企圖存取另一個限界上下文內部物件,必須通過聚合根進行存取。例如產品經理作為需求收口人,任何需求應該先提給產品經理,通過產品經理整合後再提給程式設計師,而不是直接提給開發人員。


2.3 領域事件

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

通過事件互動有一個問題需要注意,通過事件訂閱實現業務只能採用最終一致性,需要放棄強一致性,可能會引入新的複雜度需要權衡。同一個程序間事件互動可以用EventBus,跨程序事件互動可以用RocketMQ等訊息中介軟體。


3 分析七大步驟

3.1 七大步驟

每個維度描述系統的一個側面,組合在一起最終描繪出整個系統,這些維度分別是:

四色分領域

用例看功能

流程三劍客

領域與資料

縱橫做設計

分層看架構

介面看對接

本文我們分析一個足球運動員資訊管理系統,這個系統大家可能也沒有做過,我們一起分析這個系統。需要說明本文著重介紹方法論的落地,業務細節難以面面俱到。


3.2 四色分領域

3.2.1 流程梳理

首先梳理業務流程,這裡有兩個問題需要考慮,第一個問題是從什麼視角去梳理?因為不同的人看到的流程是不一樣的。答案是取決於系統需要解決什麼問題,因為我們要管理運動員從轉會到上場比賽整條鏈路資訊,所以從運動員視角出發是一個合適的選擇。

第二個問題是對業務不熟悉怎麼辦?因為我們不是體育和運動專家,並不清楚整條鏈路的業務細節。答案是梳理流程時一定要有業務專家在場,因為沒有真實業務細節,無法領域驅動設計。同理在網際網路梳理複雜業務流程時,一定要有對相關業務熟悉的產品經理或者運營一起參與。

假設足球業務專家梳理出了業務流程,運動員提出轉會,協商一致後到新俱樂部體檢,體檢通過就進行簽約。進入新俱樂部後進行訓練,訓練指標達標後上場比賽,賽後參加新聞釋出會。當然實際流程會複雜很多,本文還是著重講解方法論。



3.2.2 四色建模

(1) 時標物件

四色建模第一種顏色是紅色,表示時標物件。時標物件是四色建模最重要的物件,可以理解為核心業務單據。在業務進行過程中一定要對關鍵業務留下單據,通過這些單據可以追溯出整個業務流程。

時標物件具有兩個特點:第一是事實不可變性,記錄了過去某個時間點或時間段內發生的事實。第二是責任可追溯性,記錄了管理者關注的資訊。現在我們分析本系統時標物件有哪些,需要留下哪些核心業務單據。

轉會對應轉會單據,體檢對應體檢單據,籤合同對應合同單據,訓練對應訓練指標單據,比賽對應比賽指標單據,新聞釋出會對應採訪單據。根據分析繪製如下時標物件:



(2) 參與方、地、物

這三類物件在四色建模中用綠色表示,我們以電商場景為例進行說明。使用者支付購買商家的商品時,使用者和商家是參與方。物流系統發貨時配送單據需要有配送地址物件,地址物件就是地。訂單需要商品物件,物流配送需要有貨品,商品和貨品就是物。

我們分析本例可以知道參與方包含總經理、隊醫、教練、球迷、記者,地包含訓練地址、比賽地址、採訪地址,物包含簽名球衣和簽名足球:



(3) 角色物件

在四色建模中用黃色表示,這類物件表示參與方、地、物以什麼角色參與到業務流程:



(4) 描述物件

我們可以為物件增加相關描述資訊,在四色建模中用藍色表示:



3.2.3 劃分領域

在四色建模過程中我們體會到時標物件是最重要的物件,因為其承載了業務系統核心單據。在劃分領域時我們同樣離不開時標物件,通過收斂相關時標物件劃分領域。



3.2.4 領域事件

當業務系統發生一件事情時,如果本領域或其它領域有後續動作跟進,那麼我們把這件事情稱為領域事件,這個事件需要被感知。

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

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



3.3 用例看功能

目前為止領域已經確定了,大領域已經拆分成了小領域,我們已經不再束手無策,而是可以對小領域進行用例分析了。用例圖由參與者和用例組成,目的是回答這樣一個問題:什麼人使用系統幹什麼事。

下圖表示在比賽領域,運動員視角(什麼人)使用系統進行進球統計,助攻統計,犯規統計,跑動距離統計,比賽評分統計,傳球成功率統計,受傷統計(幹什麼事),同理我們也可以選擇四色建模中其它參與者視角繪製用例圖。



include關鍵字表示包含關係。例如比賽是基用例,包含了進球統計,助攻統計,犯規統計,跑動距離統計,比賽評分統計,傳球成功率統計,受傷統計七個子用例。包含關係表示法有兩個優點:第一是可以清晰地組織子用例,第二是有利於子用例複用,例如主教練視角用例圖也包含比賽評分,那麼就可以直接指向比賽評分子用例。

extend關鍵字表示擴充套件關係。例如點球統計是進球統計的擴充套件,因為不一定可以獲得點球,所以點球統計即使不存在,也不會影響進球統計功能。黃牌統計、紅牌統計是犯規統計的擴充套件,因為普通犯規不會獲得紅黃牌,所以紅黃牌統計不存在,也不會影響犯規統計功能。

用例圖不關心實現細節,而是從一種外部視角描述系統功能,即使不瞭解實現細節的人,通過看用例圖也可以快速瞭解系統功能,這個特性規定了用例圖不宜過於複雜,能夠說明核心功能即可。


3.4 流程三劍客

用例圖是從外部視角描述系統,但是分析系統總是要深入系統內部的,其中流程檢視就是描述系統內如何流轉的檢視。活動圖、序列圖、狀態機圖是流程檢視中最重要的三種檢視,我們稱為流程三劍客。三者側重點有所不同:活動圖側重於邏輯分支,順序圖側重於互動,狀態機圖側重於狀態流轉。


3.4.1 活動圖

活動圖適合描述複雜邏輯分支,設想這樣一種業務場景,球隊需要選出一名球員成為球隊的足球先生,選拔標準如下:前場、中場、後場、門將各選出一名候選球員。前場隊員依次比較進球數、助攻數,中場隊員依次比較助攻數、搶斷數,後場隊員依次比較解圍數、搶斷數,門將依次比較撲救數、撲點數,如果所有指標均相同則抽籤。每個位置有人選之後,全體教練組投票,如果投票數相同則抽籤。

我們經常說一圖勝千言,其中一個重要原因是文字是線性的,所以表達邏輯分支能力不如流程檢視,而在流程檢視中表達邏輯分支能力最強的是活動圖。



3.4.2 順序圖

順序圖側重於互動,適合按照時間順序體現一個業務流程中互動細節,但是順序圖並不擅長體現複雜邏輯分支。

如果某個邏輯分支特別重要,可以選擇再畫一個順序圖。例如支付流程中有支付成功正常流程,也有支付失敗異常流程,這兩個流程都非常重要,所以可以用兩張順序圖體現。回到本文範例,我們可以通過順序圖體現球員從提出轉會到比賽全流程。



3.4.3 狀態機圖

假設一條資料有ABC三種狀態,從正常業務角度來看,狀態只能從A流轉到B,再從B流轉到C,不能亂序也不可逆。但是可能出現這種異常情況:資料當前狀態為A,接收非同步訊息更改狀態,B訊息由於延時晚於C訊息,最終導致狀態先改為C再改為B,那麼此時狀態就是錯誤的。

狀態機圖側重於狀態流轉,說明了哪些狀態之間可以相互流轉,再結合狀態機程式碼模式,可以解決上述狀態異常情況。回到本文範例,我們可以通過狀態機圖表示球員從提出轉會到簽約整個狀態流程。



3.5 領域與資料

上述章節從功能層面和流程層面進行了系統分析,現在需要從資料層分析系統,我們首先對比兩組概念:值物件與實體,領域物件與資料物件。

實體是具有唯一標識的物件,唯一標識會伴隨實體物件整個生命週期並且不可變更。值物件本質上是屬性的集合,沒有唯一標識。

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

現在我們需要管理足球運動員基本資訊和比賽資料,對應領域模型和資料模型應該如何設計?姓名、身高、體重是一名運動員本質屬性,加上唯一編號可以對應實體物件。跑動距離,傳球成功率,進球數是運動員比賽表現,這些屬性的集合可以對應值物件。



我們根據圖示編寫領域物件與資料物件程式碼:

// 資料物件
public class FootballPlayerDO {
    private Long id;
    private String name;
    private Integer height;
    private Integer weight;
    private String gamePerformance;
}

// 領域物件
public class FootballPlayerDMO {
    private Long id;
    private String name;
    private Integer height;
    private Integer weight;
    private GamePerformanceVO gamePerformanceVO;
}

public class GamePerformanceVO {
    private Double runDistance;
    private Double passSuccess;
    private Integer scoreNum;
}

如果需要根據JSON結構中KEY進行檢索,例如查詢進球數大於5的球員,這也不是沒有辦法。我們可以將MySQL表中資料平鋪到ES中,一條資料根據JSON KEY平鋪變成多條資料,這樣就可以進行檢索了。


3.6 縱橫做設計

複雜業務之所以複雜,一個重要原因是涉及角色或者型別較多,很難平鋪直敘地進行設計,所以我們需要增加分析維度。其中最常見的是增加橫向和縱向兩個維度,本文也著重討論兩個維度。總體而言橫向擴充套件的是思考廣度,縱向擴充套件的是思考深度,對應到系統設計而言可以總結為:縱向做隔離,橫向做編排。

我們首先分析一個下單場景做鋪墊。當前有ABC三種訂單型別,A訂單價格9折,物流最大重量不能超過8公斤,不支援退款。B訂單價格8折,物流最大重量不能超過5公斤,支援退款。C訂單價格7折,物流最大重量不能超過1公斤,支援退款。按照需求字面含義平鋪直敘地寫程式碼也並不難:

public class OrderServiceImpl implements OrderService {

    @Resource
    private OrderMapper orderMapper;

    @Override
    public void createOrder(OrderBO orderBO) {
        if (null == orderBO) {
            throw new RuntimeException("引數異常");
        }
        if (OrderTypeEnum.isNotValid(orderBO.getType())) {
            throw new RuntimeException("引數異常");
        }
        // A型別訂單
        if (OrderTypeEnum.A_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.9);
            if (orderBO.getWeight() > 9) {
                throw new RuntimeException("超過物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.FALSE);
        }
        // B型別訂單
        else if (OrderTypeEnum.B_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.8);
            if (orderBO.getWeight() > 8) {
                throw new RuntimeException("超過物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.TRUE);
        }
        // C型別訂單
        else if (OrderTypeEnum.C_TYPE.getCode().equals(orderBO.getType())) {
            orderBO.setPrice(orderBO.getPrice() * 0.7);
            if (orderBO.getWeight() > 7) {
                throw new RuntimeException("超過物流最大重量");
            }
            orderBO.setRefundSupport(Boolean.TRUE);
        }
        // 儲存資料
        OrderDO orderDO = new OrderDO();
        BeanUtils.copyProperties(orderBO, orderDO);
        orderMapper.insert(orderDO);
    }
}

上述程式碼從功能上完全可以實現業務需求,但是程式設計師不僅要滿足功能,還需要思考程式碼的可維護性。如果新增一種訂單型別,或者新增一個訂單屬性處理邏輯,那麼我們就要在上述邏輯中新增程式碼,如果處理不慎就會影響原有邏輯。

為了避免牽一髮而動全身這種情況,設計模式中的開閉原則要求我們面向新增開放,面向修改關閉,我認為這是設計模式中最重要的一條原則。

需求變化通過擴充套件,而不是通過修改已有程式碼實現,這樣就保證程式碼穩定性。擴充套件也不是隨意擴充套件,因為事先定義了演演算法,擴充套件也是根據演演算法擴充套件,用抽象構建框架,用實現擴充套件細節。標準意義的二十三種設計模式說到底最終都是在遵循開閉原則。

如何改變平鋪直敘的思考方式?這就要為問題分析加上縱向和橫向兩個維度,我選擇使用分析矩陣方法,其中縱向表示策略,橫向表示場景。



3.6.1 縱向做隔離

縱向維度表示策略,不同策略在邏輯上和業務上應該是隔離的,本範例包括優惠策略、物流策略和退款策略,策略作為抽象,不同訂單型別去擴充套件這個抽象,策略模式非常適合這種場景。本文詳細分析優惠策略,物流策略和退款策略同理。

// 優惠策略
public interface DiscountStrategy {
    public void discount(OrderBO orderBO);
}

// A型別優惠策略
@Component
public class TypeADiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.9);
    }
}

// B型別優惠策略
@Component
public class TypeBDiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.8);
    }
}

// C型別優惠策略
@Component
public class TypeCDiscountStrategy implements DiscountStrategy {

    @Override
    public void discount(OrderBO orderBO) {
        orderBO.setPrice(orderBO.getPrice() * 0.7);
    }
}

// 優惠策略工廠
@Component
public class DiscountStrategyFactory implements InitializingBean {
    private Map<String, DiscountStrategy> strategyMap = new HashMap<>();

    @Resource
    private TypeADiscountStrategy typeADiscountStrategy;
    @Resource
    private TypeBDiscountStrategy typeBDiscountStrategy;
    @Resource
    private TypeCDiscountStrategy typeCDiscountStrategy;

    public DiscountStrategy getStrategy(String type) {
        return strategyMap.get(type);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        strategyMap.put(OrderTypeEnum.A_TYPE.getCode(), typeADiscountStrategy);
        strategyMap.put(OrderTypeEnum.B_TYPE.getCode(), typeBDiscountStrategy);
        strategyMap.put(OrderTypeEnum.C_TYPE.getCode(), typeCDiscountStrategy);
    }
}

// 優惠策略執行
@Component
public class DiscountStrategyExecutor {
    private DiscountStrategyFactory discountStrategyFactory;

    public void discount(OrderBO orderBO) {
        DiscountStrategy discountStrategy = discountStrategyFactory.getStrategy(orderBO.getType());
        if (null == discountStrategy) {
            throw new RuntimeException("無優惠策略");
        }
        discountStrategy.discount(orderBO);
    }
}

3.6.2 橫向做編排

橫向維度表示場景,一種訂單型別在廣義上可以認為是一種業務場景,在場景中將獨立的策略進行串聯,模板方法設計模式適用於這種場景。

模板方法模式一般使用抽象類定義一個演演算法骨架,同時定義一些抽象方法,這些抽象方法延遲到子類實現,這樣子類不僅遵守了演演算法骨架約定,也實現了自己的演演算法。既保證了規約也兼顧靈活性,這就是用抽象構建框架,用實現擴充套件細節。

// 建立訂單服務
public interface CreateOrderService {
    public void createOrder(OrderBO orderBO);
}

// 抽象建立訂單流程
public abstract class AbstractCreateOrderFlow {

    @Resource
    private OrderMapper orderMapper;

    public void createOrder(OrderBO orderBO) {
        // 引數校驗
        if (null == orderBO) {
            throw new RuntimeException("引數異常");
        }
        if (OrderTypeEnum.isNotValid(orderBO.getType())) {
            throw new RuntimeException("引數異常");
        }
        // 計算優惠
        discount(orderBO);
        // 計算重量
        weighing(orderBO);
        // 退款支援
        supportRefund(orderBO);
        // 儲存資料
        OrderDO orderDO = new OrderDO();
        BeanUtils.copyProperties(orderBO, orderDO);
        orderMapper.insert(orderDO);
    }

    public abstract void discount(OrderBO orderBO);

    public abstract void weighing(OrderBO orderBO);

    public abstract void supportRefund(OrderBO orderBO);
}

// 實現建立訂單流程
@Service
public class CreateOrderFlow extends AbstractCreateOrderFlow {

    @Resource
    private DiscountStrategyExecutor discountStrategyExecutor;
    @Resource
    private ExpressStrategyExecutor expressStrategyExecutor;
    @Resource
    private RefundStrategyExecutor refundStrategyExecutor;

    @Override
    public void discount(OrderBO orderBO) {
        discountStrategyExecutor.discount(orderBO);
    }

    @Override
    public void weighing(OrderBO orderBO) {
        expressStrategyExecutor.weighing(orderBO);
    }

    @Override
    public void supportRefund(OrderBO orderBO) {
        refundStrategyExecutor.supportRefund(orderBO);
    }
}

3.6.3 綜合應用

上述範例業務和程式碼並不複雜,其實複雜業務場景也不過是簡單場景的疊加、組合和交織,無外乎也是通過縱向做隔離、橫向做編排尋求答案。



縱向維度抽象出能力池這個概念,能力池中包含許多能力,不同的能力按照不同業務維度聚合,例如優惠能力池,物流能力池,退款能力池。我們可以看到兩種程度的隔離性,能力池之間相互隔離,能力之間也相互隔離。

橫向維度將能力從能力池選出來,按照業務需求串聯在一起,形成不同業務流程。因為能力可以任意組合,所以體現了很強的靈活性。除此之外,不同能力既可以序列執行,如果不同能力之間沒有依賴關係,也可以如同流程Y一樣並行執行,提升執行效率。

此時可以回到本文足球運動員管理系統,如果我們採用縱橫思維,分析3.3.1足球先生選拔業務場景可以得到下圖:



縱向隔離出進攻能力池,防守能力池,門將能力池,橫向編排出前場、中場、後場、門將四個流程,在不同流程中可以任意從能力池中選擇能力進行組合,而不是編寫冗長的判斷邏輯,顯著提升了程式碼可延伸性。


3.7 分層看架構

3.7.1 維度一

第一種層次關係是指本專案在整個公司位於哪一層。持久層、快取層、中介軟體、業務中臺、服務層、閘道器層、使用者端和代理層是常見的分層架構。



3.7.2 維度二

第二種層次是指中臺和前臺的關係。一個系統在業務上通常分為三個端:面向B端使用者,面向C端使用者,面向運營使用者。面對這種情況可以劃分前臺、中臺、後臺三類應用:



第一中臺應用承載核心邏輯,暴露核心介面,中臺並不要理解所有端資料結構,而是通過client介面暴露相對穩定的資料。

第二針對面向B端、面向C端、面向運營三種端,各自拆分出一個應用,在此應用中進行轉換、適配和裁剪,並且處理各自業務。

第三什麼是大中臺、小前臺思想?中臺提供穩定服務,前臺提供靈活入口。

第四如果後續要做秒殺系統,那麼也可以理解其為一個前臺應用(seckill-front)聚合各種中臺介面。


3.7.3 維度三

第三種層次是程式碼層次結構。分層優點是每層只專注本層工作,可以類比設計模式單一職責原則,或者經濟學比較優勢原理,每層只做本層最擅長的事情。

分層缺點是層之間通訊時,需要通過介面卡,翻譯成本層或者下層可以理解的資訊,通訊成本有所增加。我認為工程分層需要從六個維度思考:

(1) 單一

每層只處理一類事情,滿足單一職責原則

(2) 降噪

資訊在每一層進行傳輸,滿足最小知識原則,只向下層傳輸必要資訊

(3) 適配

每層都需要一個介面卡,翻譯資訊為本層或者下層可以理解的資訊

(4) 縱向

縱向做隔離,同一個領域內業務要在本領域內聚

(5) 橫向

橫向做編排,應用層聚合多個領域進行業務編排

(6) 資料

資料物件儘量純淨,儘量使用基本型別

程式碼可以分為九層結構:

  • 工具層:util
  • 整合層:integration
  • 基礎層:infrastructure
  • 領域層:domain
  • 應用層:application
  • 門面層:facade
  • 使用者端:client
  • 控制層:controller
  • 啟動層:boot


3.8 介面看對接

當一個介面程式碼編寫完成後,那麼這個介面如何呼叫,輸入和輸出引數是什麼,這些問題需要在介面檔案中得到回答。介面檔案生成有兩種方式:第一種是自動生成,例如使用Swagger,第二種方式是手工生成。

自動生成優點是程式碼即檔案,還具有偵錯功能,在公司內部進行聯調時非常方便。但是如果介面是提供給外部第三方使用,那麼還是需要手工編寫介面檔案。對於一個介面的描述無外乎介面名稱、介面說明、介面協定,輸入引數、輸出引數資訊。



4 程式碼詳解

user-demo-service
    -user-demo-service-application
    -user-demo-service-boot
    -user-demo-service-client
    -user-demo-service-controller
    -user-demo-service-domain
    -user-demo-service-facade
    -user-demo-service-infrastructure
    -user-demo-service-integration
    -user-demo-service-util

4.1 util

工具層承載工具程式碼

不依賴本專案其它模組

只依賴一些通用工具包

user-demo-service-util
    -/src/main/java
        -date
            -DateUtil.java
        -json
            -JsonUtil.java
        -validate
            -BizValidator.java

4.2 infrastructure

基礎層承載資料存取和entity

同時承載基礎服務(ES、Redis、MQ)


4.2.1 專案結構

user-demo-service-infrastructure
    -/src/main/java
        -base
            -service
                -redis
                    -RedisService.java
                -mq
                    -ProducerService.java
        -player
            -entity
                -PlayerEntity.java
            -mapper
                -PlayerEntityMapper.java
        -game
            -entity
                -GameEntity.java
            -mapper
                -GameEntityMapper.java
    -/src/main/resources
        -mybatis
            -sqlmappers
                -gameEntityMapper.xml
                -playerEntityMapper.xml

4.2.2 本專案依賴

  • util

4.2.3 核心程式碼

建立運動員資料表:

CREATE TABLE `player` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `player_id` varchar(256) NOT NULL COMMENT '運動員編號',
  `player_name` varchar(256) NOT NULL COMMENT '運動員名稱',
  `height` int(11) NOT NULL COMMENT '身高',
  `weight` int(11) NOT NULL COMMENT '體重',
  `game_performance` text COMMENT '最近一場比賽表現',
  `creator` varchar(256) NOT NULL COMMENT '建立人',
  `updator` varchar(256) NOT NULL COMMENT '修改人',
  `create_time` datetime NOT NULL COMMENT '建立時間',
  `update_time` datetime NOT NULL COMMENT '修改時間',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8

運動員實體物件,gamePerformance欄位作為string儲存在資料庫,體現了資料層儘量純淨,不要整合過多業務,解析任務應該放在業務層:

public class PlayerEntity {
    private Long id;
    private String playerId;
    private String playerName;
    private Integer height;
    private Integer weight;
    private String creator;
    private String updator;
    private Date createTime;
    private Date updateTime;
    private String gamePerformance;
}

運動員Mapper物件:

@Repository
public interface PlayerEntityMapper {
    int insert(PlayerEntity record);
    int updateById(PlayerEntity record);
    PlayerEntity selectById(@Param("playerId") String playerId);
}

4.3 integration

本層呼叫外部服務,轉換外部DTO成為本專案可以理解物件。


4.3.1 專案結構

本專案呼叫使用者中心服務:

user-demo-service-integration
    -/src/main/java
        -user
            -adapter
                -UserClientAdapter.java
            -proxy
                -UserClientProxy.java
            -vo                                    // 本專案物件
                -UserSimpleAddressVO.java
                -UserSimpleContactVO.java
                -UserSimpleBaseInfoVO.java

4.3.2 本專案依賴

  • util

4.3.3 核心程式碼

(1) 外部服務

// 外部物件
public class UserInfoClientDTO implements Serializable {
    private String id;
    private String name;
    private Date createTime;
    private Date updateTime;
    private String mobile;
    private String cityCode;
    private String addressDetail;
}

// 外部服務
public class UserClientService {

    // RPC
    public UserInfoClientDTO getUserInfo(String userId) {
        UserInfoClientDTO userInfo = new UserInfoClientDTO();
        userInfo.setId(userId);
        userInfo.setName(userId);
        userInfo.setCreateTime(DateUtil.now());
        userInfo.setUpdateTime(DateUtil.now());
        userInfo.setMobile("test-mobile");
        userInfo.setCityCode("test-city-code");
        userInfo.setAddressDetail("test-address-detail");
        return userInfo;
    }
}

(2) 本專案物件

// 基本物件
public class UserBaseInfoVO {
    private UserContactVO contactInfo;
    private UserAddressVO addressInfo;
}

// 地址值物件
public class UserAddressVO {
    private String cityCode;
    private String addressDetail;
}

// 聯絡方式值物件
public class UserContactVO {
    private String mobile;
}

(3) 介面卡

public class UserClientAdapter {

    public UserBaseInfoVO convert(UserInfoClientDTO userInfo) {
        // 基礎資訊
        UserBaseInfoVO userBaseInfo = new UserBaseInfoVO();
        // 聯絡方式
        UserContactVO contactVO = new UserContactVO();
        contactVO.setMobile(userInfo.getMobile());
        userBaseInfo.setContactInfo(contactVO);
        // 地址資訊
        UserAddressVO addressVO = new UserAddressVO();
        addressVO.setCityCode(userInfo.getCityCode());
        addressVO.setAddressDetail(userInfo.getAddressDetail());
        userBaseInfo.setAddressInfo(addressVO);
        return userBaseInfo;
    }
}

(4) 呼叫外部服務

public class UserClientProxy {

    @Resource
    private UserClientService userClientService;
    @Resource
    private UserClientAdapter userIntegrationAdapter;

    // 查詢使用者
    public UserBaseInfoVO getUserInfo(String userId) {
        UserInfoClientDTO user = userClientService.getUserInfo(userId);
        UserBaseInfoVO result = userIntegrationAdapter.convert(user);
        return result;
    }
}

4.4 domain

4.4.1 概念說明

通過三組對比理解領域層:

  • 領域物件 VS 資料物件
  • 領域物件 VS 業務物件
  • 領域層 VS 應用層

(1) 領域物件 VS 資料物件

資料物件使用基本型別保持純淨:

public class PlayerEntity {
    private Long id;
    private String playerId;
    private String playerName;
    private Integer height;
    private Integer weight;
    private String creator;
    private String updator;
    private Date createTime;
    private Date updateTime;
    private String gamePerformance;
}

領域物件需要體現業務含義:

public class PlayerQueryResultDomain {
    private String playerId;
    private String playerName;
    private Integer height;
    private Integer weight;
    private GamePerformanceVO gamePerformance;
}

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

(2) 領域物件 VS 業務物件

業務物件同樣會體現業務,領域物件和業務物件有什麼不同?最大不同是領域物件採用充血模型聚合業務。

運動員新增業務物件:

public class PlayerCreateBO {
    private String playerName;
    private Integer height;
    private Integer weight;
    private GamePerformanceVO gamePerformance;
    private MaintainCreateVO maintainInfo;
}

運動員新增領域物件:

public class PlayerCreateDomain implements BizValidator {
    private String playerName;
    private Integer height;
    private Integer weight;
    private GamePerformanceVO gamePerformance;
    private MaintainCreateVO maintainInfo;

    @Override
    public void validate() {
        if (StringUtils.isEmpty(playerName)) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == height) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (height > 300) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == weight) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null != gamePerformance) {
            gamePerformance.validate();
        }
        if (null == maintainInfo) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        maintainInfo.validate();
    }
}

(3) 領域層 VS 應用層

第一個區別:領域層關注縱向,應用層關注橫向。領域層縱向做隔離,本領域業務行為要在本領域內處理完。應用層橫向做編排,聚合和編排領域服務。

第二個區別:應用層可以更加靈活組合不同領域業務,並且可以增加流控、監控、紀錄檔、許可權,分散式鎖,相較於領域層功能更為豐富。


4.4.2 專案結構

user-demo-service-domain
    -/src/main/java
        -base
            -domain
                -BaseDomain.java
            -event
                -BaseEvent.java
            -vo
                -BaseVO.java
                -MaintainCreateVO.java
                -MaintainUpdateVO.java
        -player
            -adapter
                -PlayerDomainAdapter.java
            -domain
                -PlayerCreateDomain.java          // 領域物件 
                -PlayerUpdateDomain.java
                -PlayerQueryResultDomain.java
            -event                                // 領域事件
                -PlayerUpdateEvent.java
                -PlayerMessageSender.java
            -service                              // 領域服務
                -PlayerDomainService.java
            -vo                                   // 值物件
                -GamePerformanceVO.java                
        -game
            -adapter
                -GameDomainAdapter.java        
            -domain
                -GameCreateDomain.java
                -GameUpdateDomain.java
                -GameQueryResultDomain.java
            -service
                -GameDomainService.java

4.4.3 本專案依賴

  • util
  • client

領域物件進行業務校驗,所以需要依賴client模組:

  • BizException
  • ErrorCodeBizEnum

4.4.4 核心程式碼

// 修改領域物件
public class PlayerUpdateDomain extends BaseDomain implements BizValidator {
    private String playerId;
    private String playerName;
    private Integer height;
    private Integer weight;
    private String updator;
    private Date updatetime;
    private GamePerformanceVO gamePerformance;
    private MaintainUpdateVO maintainInfo;

    @Override
    public void validate() {
        if (StringUtils.isEmpty(playerId)) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (StringUtils.isEmpty(playerName)) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == height) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (height > 300) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == weight) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null != gamePerformance) {
            gamePerformance.validate();
        }
        if (null == maintainInfo) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        maintainInfo.validate();
    }
}

// 比賽表現值物件
public class GamePerformanceVO implements BizValidator {

    // 跑動距離
    private Double runDistance;
    // 傳球成功率
    private Double passSuccess;
    // 進球數
    private Integer scoreNum;

    @Override
    public void validate() {
        if (null == runDistance) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == passSuccess) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (Double.compare(passSuccess, 100) > 0) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == runDistance) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == scoreNum) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
    }
}

// 修改人值物件
public class MaintainUpdateVO implements BizValidator {

    // 修改人
    private String updator;
    // 修改時間
    private Date updateTime;

    @Override
    public void validate() {
        if (null == updator) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
        if (null == updateTime) {
            throw new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT);
        }
    }
}

// 領域服務
public class PlayerDomainService {

    @Resource
    private UserClientProxy userClientProxy;
    @Resource
    private PlayerRepository playerEntityMapper;
    @Resource
    private PlayerDomainAdapter playerDomainAdapter;
    @Resource
    private PlayerMessageSender playerMessageSender;

    public boolean updatePlayer(PlayerUpdateDomain player) {
        AssertUtil.notNull(player, new BizException(ErrorCodeBizEnum.ILLEGAL_ARGUMENT));
        player.validate();

        // 更新運動員資訊
        PlayerEntity entity = playerDomainAdapter.convertUpdate(player);
        playerEntityMapper.updateById(entity);

        // 傳送更新訊息
        playerMessageSender.sendPlayerUpdatemessage(player);

        // 查詢使用者資訊
        UserSimpleBaseInfoVO userInfo = userClientProxy.getUserInfo(player.getMaintainInfo().getUpdator());
        log.info("updatePlayer maintainInfo={}", JacksonUtil.bean2Json(userInfo));
        return true;
    }
}

4.5 application

本層關注橫向維度聚合領域服務,引出一種新物件稱為聚合物件。因為本層需要聚合多個維度,所以需要通過聚合物件聚合多領域屬性,例如提交訂單需要聚合商品、物流、優惠券多個領域。

// 訂單提交聚合物件
public class OrderSubmitAgg {

    // userId
    private String userId;

    // skuId
    private String skuId;

    // 購買量
    private Integer quantity;

    // 地址資訊
    private String addressId;

    // 可用優惠券
    private String couponId;
}

// 訂單應用服務
public class OrderApplicationService {

    @Resource
    private OrderDomainService orderDomainService;
    @Resource
    private CouponDomainService couponDomainService;
    @Resource
    private ProductDomainService productDomainService;

    // 提交訂單
    public String submitOrder(OrderSubmitAgg orderSumbitAgg) {

        // 訂單編號
        String orderId = generateOrderId();

        // 商品校驗
        productDomainService.queryBySkuId(orderSumbitAgg.getSkuId());

        // 扣減庫存
        productDomainService.subStock(orderSumbitAgg.getStockId(), orderSumbitAgg.getQuantity());

        // 優惠券校驗
        couponDomainService.validate(userId, couponId);

        // ......

        // 建立訂單
        OrderCreateDomain domain = OrderApplicationAdapter.convert(orderSubmitAgg);
        orderDomainService.createOrder(domain);
        return orderId;
    }
}

4.5.1 專案結構

user-demo-service-application
    -/src/main/java
        -player
            -adapter
                -PlayerApplicationAdapter.java
            -agg
                -PlayerCreateAgg.java
                -PlayerUpdateAgg.java
            -service
                -PlayerApplicationService.java
        -game
            -listener
                -PlayerUpdateListener.java            // 監聽運動員更新事件

4.5.2 本專案依賴

  • util
  • domain
  • integration
  • infrastructure

4.5.3 核心程式碼

本專案領域事件互動使用EventBus框架:

// 運動員應用服務
public class PlayerApplicationService {

    @Resource
    private LogDomainService logDomainService;
    @Resource
    private PlayerDomainService playerDomainService;
    @Resource
    private PlayerApplicationAdapter playerApplicationAdapter;

    public boolean updatePlayer(PlayerUpdateAgg agg) {
        // 運動員領域
        boolean result = playerDomainService.updatePlayer(agg.getPlayer());
        // 紀錄檔領域
        LogReportDomain logDomain = playerApplicationAdapter.convert(agg.getPlayer().getPlayerName());
        logDomainService.log(logDomain);
        return result;
    }
}

// 比賽領域監聽運動員變更事件
public class PlayerUpdateListener {

    @Resource
    private GameDomainService gameDomainService;

    @PostConstruct
    public void init() {
        EventBusManager.register(this);
    }

    @Subscribe
    public void listen(PlayerUpdateEvent event) {
        // 更新比賽計劃
        gameDomainService.updateGameSchedule();
    }
}

4.6 facade + client

設計模式中有一種Facade模式,稱為門面模式或者外觀模式。這種模式提供一個簡潔對外語意,遮蔽內部系統複雜性。

client承載資料對外傳輸物件DTO,facade承載對外服務,必須滿足最小知識原則,無關資訊不必對外透出。這樣做有兩個優點:

  • 簡潔性:對外服務語意明確簡潔
  • 安全性:敏感欄位不能對外透出

4.6.1 專案結構

(1) client

user-demo-service-client
    -/src/main/java
        -base
            -dto
                -BaseDTO.java
            -error
                -BizException.java
                -BizErrorCode.java
            -event
                -BaseEventDTO.java
            -result
                -ResultDTO.java
        -player
            -dto
                -PlayerCreateDTO.java
                -PlayerQueryResultDTO.java
                -PlayerUpdateDTO.java
            -enums
                -PlayerMessageTypeEnum.java
            -service
                -PlayerClientService.java

(2) facade

user-demo-service-facade
    -/src/main/java
        -player
            -adapter
                -PlayerFacadeAdapter.java
            -impl
                -PlayerClientServiceImpl.java
        -game
            -adapter
                -GameFacadeAdapter.java
            -impl
                -GameClientServiceImpl.java

4.6.2 本專案依賴

client不依賴本專案其它模組,這一點非常重要:因為client會被外部參照,必須保證本層簡潔和安全。

facade依賴本專案三個模組:

  • domain
  • client
  • application

4.6.3 核心程式碼

(1) DTO

以查詢運動員資訊為例,查詢結果DTO只封裝強業務欄位,運動員ID、建立時間、修改時間等業務不強欄位無須透出:

public class PlayerQueryResultDTO implements Serializable {
    private String playerName;
    private Integer height;
    private Integer weight;
    private GamePerformanceDTO gamePerformanceDTO;
}

(2) 使用者端服務

public interface PlayerClientService {
    public ResultDTO<PlayerQueryResultDTO> queryById(String playerId);
}

(3) 介面卡

public class PlayerFacadeAdapter {

    // domain -> dto
    public PlayerQueryResultDTO convertQuery(PlayerQueryResultDomain domain) {
        if (null == domain) {
            return null;
        }
        PlayerQueryResultDTO result = new PlayerQueryResultDTO();
        result.setPlayerId(domain.getPlayerId());
        result.setPlayerName(domain.getPlayerName());
        result.setHeight(domain.getHeight());
        result.setWeight(domain.getWeight());
        if (null != domain.getGamePerformance()) {
            GamePerformanceDTO performance = convertGamePerformance(domain.getGamePerformance());
            result.setGamePerformanceDTO(performance);
        }
        return result;
    }
}

(4) 服務實現

本層可以參照applicationService,也可以參照domainService,因為對於類似查詢等簡單業務場景,沒有多領域聚合,可以直接使用領域服務。

public class PlayerClientServiceImpl implements PlayerClientService {

    @Resource
    private PlayerDomainService playerDomainService;
    @Resource
    private PlayerFacadeAdapter playerFacadeAdapter;

    @Override
    public ResultDTO<PlayerQueryResultDTO> queryById(String playerId) {
        PlayerQueryResultDomain resultDomain = playerDomainService.queryPlayerById(playerId);
        if (null == resultDomain) {
            return ResultCommonDTO.success();
        }
        PlayerQueryResultDTO result = playerFacadeAdapter.convertQuery(resultDomain);
        return ResultCommonDTO.success(result);
    }
}

4.7 controller

facade服務實現可以作為RPC提供服務,controller則作為本專案HTTP介面提供服務,供前端呼叫。

controller需要注意HTTP相關特性,敏感資訊例如登陸使用者ID不能依賴前端傳遞,登陸後前端會在請求頭帶一個登陸使用者資訊,伺服器端需要從請求頭中獲取並解析。


4.7.1 專案結構

user-demo-service-controller
    -/src/main/java
        -controller
            -player
                -PlayerController.java
            -game
                -GameController.java

4.7.2 本專案依賴

  • facade

4.7.3 核心程式碼

@RestController
@RequestMapping("/player")
public class PlayerController {

    @Resource
    private PlayerClientService playerClientService;

    @PostMapping("/add")
    public ResultDTO<Boolean> add(@RequestHeader("test-login-info") String loginUserId, @RequestBody PlayerCreateDTO dto) {
        dto.setCreator(loginUserId);
        ResultCommonDTO<Boolean> resultDTO = playerClientService.addPlayer(dto);
        return resultDTO;
    }

    @PostMapping("/update")
    public ResultDTO<Boolean> update(@RequestHeader("test-login-info") String loginUserId, @RequestBody PlayerUpdateDTO dto) {
        dto.setUpdator(loginUserId);
        ResultCommonDTO<Boolean> resultDTO = playerClientService.updatePlayer(dto);
        return resultDTO;
    }

    @GetMapping("/{playerId}/query")
    public ResultDTO<PlayerQueryResultDTO> queryById(@RequestHeader("test-login-info") String loginUserId, @PathVariable("playerId") String playerId) {
        ResultCommonDTO<PlayerQueryResultDTO> resultDTO = playerClientService.queryById(playerId);
        return resultDTO;
    }
}

4.8 boot

boot作為啟動層承載啟動入口


4.8.1 專案結構

所有模組程式碼均必須屬於com.user.demo.service子路徑:

user-demo-service-boot
    -/src/main/java
        -com.user.demo.service
            -MainApplication.java

4.8.2 依賴本專案

  • 所有模組

4.8.3 核心程式碼

@MapperScan("com.user.demo.service.infrastructure.*.mapper")
@SpringBootApplication
public class MainApplication {
    public static void main(final String[] args) {
        SpringApplication.run(MainApplication.class, args);
    }
}

5 文章總結

本文第一提出並回答了六個問題,第二介紹了DDD相關基本概念,第三介紹了DDD分析七大步驟,第四介紹了程式碼分層結構,希望本文對大家有所幫助。歡迎大家關注公眾號「JAVA前線」檢視更多精彩分享文章,主要包括原始碼分析、實際應用、架構思維、職場分享、產品思考等等,同時歡迎大家加我個人微信「java_front」一起交流學習。