DDD技術方案落地實踐

2023-11-07 21:01:35

1. 引言

從接觸領域驅動設計的初學階段,到實現一箇舊系統改造到DDD模型,再到按DDD規範落地的3個的專案。對於領域驅動模型設計研發,從開始的各種疑惑到吸收各種先進的理念,目前在技術實施這一塊已經基本比較成熟。在既往經驗中總結了一些在開發中遇到的技術問題和解決方案進行分享。

因為DDD的建模理論及方法論有比較成熟的教學,如《領域驅動設計》,這裡我對DDD的理論部分只做簡要回顧,如果需要了解DDD建模和基礎的理論知識,請移步相關書籍進行學習。本文主要針對我們團隊在DDD落地實踐中的一些技術點進行分享。

2. 理論回顧

理論部分只做部分提要,關於DDD建模及基礎知識相關,可參考 Eric Evans 的《領域驅動設計》一書及其它理論書籍,這裡只做部分內容摘抄。

2.1.1 名詞

領域及劃分:領域、子域、核心域、通用域、支撐域,限界上下文;

模型:聚合、聚合根、實體、值物件;

實體

是指描述了領域中唯一的且可持續變化的抽象模型,有ID標識,有生命週期,有狀態(用值物件來描述狀態),實體通過ID進行區分;

每個實體物件都有唯一的 ID。我們可以對一個實體物件進行多次修改,修改後的資料和原來的資料可能會大不相同。比如商品是商品上下文的一個實體,通過唯一的商品 ID 來標識,不管這個商品的資料如何變化,商品的 ID 一直保持不變,它始終是同一個商品。

在 DDD 裡,這些實體類通常採用充血模型,與這個實體相關的所有業務邏輯都在實體類的方法中實現。

聚合根

聚合根是實體,是一個根實體,聚合根的ID全域性唯一標識,聚合根下面的實體的ID在聚合根內唯一即可;

聚合根是聚合還原和儲存的唯一入口,聚合的還原應該保證完整性即整存整取;

聚合設計的原則

  1. 聚合是用來封裝真正的不變性,而不是簡單的將物件組合在一起;

  2. 聚合應儘量設計的小,主要因為業務決定聚合,業務改變聚合,儘可能小的拆分,可以避免重構,重新拆分

  3. 聚合之間的關聯通過ID,而不是物件參照;

  4. 聚合內強一致性,聚合之間最終一致性;

值物件

值物件的核心本質是值,與是否有複雜型別無關,值物件沒有生命週期,通過兩個值物件的值是否相同區分是否是同一個值物件;

值物件應該設計為唯讀模式, 如果任一屬性發生變化,應該重新構建一個新的值物件而不是改變原來值物件的屬性;

領域事件

在事件風暴過程中,會識別出命令、業務操作、實體等,此外還有事件。比如當業務人員的描述中出現類似「當完成…後,則…」,「當發生…時,則…」等模式時,往往可將其用領域事件來實現。領域事件表示在領域中發生的事件,它會導致進一步的業務操作。如電商中,支付完成後觸發的事件,會導致生成訂單、扣減庫存等操作。

在一次事務中,最多隻能更改一個聚合的狀態。如何一個業務操作涉及多個聚合狀態的更改,可以採用領域事件的方式,實現聚合之間的解耦;在聚合根和跨上下文之間實現最終一致性。聚合內資料強一致性,聚合之間資料最終一致性。

事件的生成和釋出:構建的事件應包含事件ID、時間戳、事件型別、事件源等基本屬性,以便事件可以無歧義地在不同上下文間傳播;此外事件還應包含具體的業務資料。

領域事件為已發生的事務,具有唯讀,不可變更性。一般接收訊息為非同步監聽,處理的後續處理需要考慮時序和重複傳送的問題。

2.1.2 聚合根、實體、值物件的區別?

從標識的角度:

聚合根具有全域性的唯一標識,而實體只有在聚合內部有唯一的本地標識,值物件沒有唯一標識;

從是否唯讀的角度:

聚合根除了唯一標識外,其他所有狀態資訊都理論上可變;實體是可變的;值物件是唯讀的;

從生命週期的角度:

聚合根有獨立的生命週期,實體的生命週期從屬於其所屬的聚合,實體完全由其所屬的聚合根負責管理維護;值物件無生命週期可言,因為只是一個值;

2.2 建模方法

2.2.1 事件風暴

事件⻛暴法類似頭腦⻛暴,簡單來說就是誰在何時基於什麼做了什麼,產⽣了什麼,影響了什麼事情。

在事件風暴的過程中,領域專家會和設計、開發人員一起建立領域模型,在領域建模的過程中會形成通用的業務術語和使用者故事。事件風暴也是一個專案團隊統一語言的過程。

2.2.2 使用者故事

使用者故事在軟體開發過程中被作為描述需求的一種表達形式,並著重描述角色(誰要用這個功能)、功能(需要完成什麼樣子的功能)和價值(為什麼需要這個功能,這個功能帶來什麼樣的價值)。

例:

作為一個「網站管理員」,我想要「統計每天有多少人存取了我的網站」,以便於「我的贊助商瞭解我的網站會給他們帶來什麼收益。

通過使用者故事分析會形成一個個的領域物件,這些領域物件對應領域模型的業務物件,每一個業務物件和領域物件都有通用的名詞術語,並且一一對映。

2.2.3 統一語言

在事件風暴和使用者故事梳理過程及日常討論中,會有越來越多的名詞冒出來,這個時候,需要團隊成員統一意見,形成名詞字典。在後續的討論和描述中,使用統一的名稱名詞來指代模型中的物件、屬性、狀態、事件、用例等資訊。

可以用Excel或者線上檔案等方式記錄儲存,標註名稱,描述和提取時間和參與人等資訊。

程式碼模型設計的時侯就要建立領域物件和程式碼物件的一一對映,從而保證業務模型和程式碼模型的一致,實現業務語言與程式碼語言的統一。

2.2.4 領域劃分及建模

DDD 核心的程式碼模型來源於領域模型,每個程式碼模型的程式碼物件跟領域物件一一對應。

通過UML類圖(通過顏色標註區分聚合根、實體、值物件等)、用例圖、時序圖完成軟體模型設計。

2.3 整潔架構(洋蔥架構)

整潔架構(Clean Architecture)是由Bob大叔在2012年提出的一個架構模型,顧名思義,是為了使架構更簡潔。

整潔架構最主要原則是依賴原則,它定義了各層的依賴關係,越往裡,依賴越低,程式碼級別越高。外圓程式碼依賴只能指向內圓,內圓不知道外圓的任何事情。一般來說,外圓的宣告(包括方法、類、變數)不能被內圓參照。同樣的,外圓使用的資料格式也不能被內圓使用。

整潔架構各層主要職能如下:

  • Entities:實現領域核心心業務邏輯,它封裝了企業級的業務規則。一個 Entity 可以是一個帶方法的物件,也可以是一個資料結構和方法集合。一般我們建議建立充血模型。

  • Use Cases:實現與使用者操作相關的服務組合與編排,它包含了應用特有的業務規則,封裝和實現了系統的所有用例。

  • Interface Adapters:它把適用於 Use Cases 和 entities 的資料轉換為適用於外部服務的格式,或把外部的資料格式轉換為適用於 Use Casess 和 entities 的格式。

  • Frameworks and Drivers:這是實現所有前端業務細節的地方,UI,Tools,Frameworks 等以及資料庫等基礎設施。

3. 落地實踐

3.1 概述

在整個DDD開發過程中,除了建模方法和理論的學習,實際技術落地還會遇到很多問題。在多個專案的不斷開發演進過程中,循序漸進的總結了很多經驗和小技巧,用於解決過往的缺憾和不足。走向DDD的路有千萬條,這些只是其中的一些可選方案,如有紕漏還請指正。

3.2 工程範例簡介

目前我們採用的是核心整體分離,如下圖所示。

b2b-baseproject-kernel 核心模組說明

其中: b2b-baseproject-kernel 為核心的Maven工程範例, b2b-baseproject-center為讀寫服務彙總的中心對外服務工程範例。

圖3-1 kernel基礎工程範例

核心Maven工程模組說明:

1. b2b-baseproject-kernel-common 常用工具類,常數等,不對外SDK暴露;
2. b2b-baseproject-kernel-export 核心對外暴露的資訊,為常數,列舉等,可直接讓外部SDK依賴並對外,減少通用知識重複定義(可選);
3. b2b-baseproject-kernel-dto 資料傳輸層,方便app層和domain層共用資料傳輸物件,不對外SDK暴露;
4. b2b-baseproject-kernel-ext-sdk 擴充套件點;(可選,不需要可直接移除)
5. b2b-baseproject-kernel-domain 領域層等(也可以不劃分子模組,按需劃分即可);
   (b2b-baseproject-kernel-domain-common 通用領域,主要為一些通用值物件;
   (b2b-baseproject-kernel-domain-ctxmain 核心領域模型,可自行調整名稱;
6. b2b-baseproject-kernel-read-app 讀服務應用層;(可選,不需要可直接移除)
7. b2b-baseproject-kernel-app 寫服務應用層;


b2b-baseproject-center 實現模組說明

圖3-2 center基礎工程範例

center Maven工程模組說明:

對外SDK
1. b2b-baseproject-sdk 對外sdk工程;
    1.1 b2b-baseproject-base-sdk 基礎sdk;
    1.2 b2b-baseproject-core-sdk 寫服務sdk;
    1.3 b2b-baseproject-svr-sdk 讀服務sdk;
基礎設施
2. b2b-baseproject-center-common 常用工具類,常數等;
3. b2b-baseproject-center-infrastructure 基礎設施實現層;
   (b2b-baseproject-center-dao 基礎設施層的資料庫存取層,也可不分,直接融合到infrastructure);
   (b2b-baseproject-center-es  基礎設施層的ES存取層,也可不分,直接融合到infrastructure);

center服務層
4. b2b-baseproject-center-service center的業務服務層;

接入層
5. b2b-baseproject-center-provider 服務接入實現;

springboot啟動
6. b2b-baseproject-center-bootstrap springboot應用啟動層;


備註:對外SDK主要考慮適配CQRS原則,將讀寫分為兩個單獨的module, 如果感覺麻煩,也可以合併為一個SDK對外,用不同的分包隔離即可。


核心和實現的關聯

使用核心和具體實現應用分離的劃分是因為前期因為有商業化衍生出了多版本開發。當然目前架構組是不建議一個核心多套實現的,而是建議一個核心加上一個主版本實現。避免因為多版本實現造成分裂,徒增開發和維護成本,改為採用設定和擴充套件點來滿足差異化需求。

目前我們開發只保持一個主版本,但是工程繼續使用核心分離的方式,即一個核心+一個主版本實現。

優點:

  1. 核心和實現程式碼完全隔離,得到一個比較乾淨存粹的核心;

  2. 雖萬不得已不建議多版本實現,但是萬一要支援多版本,可以直接複用核心;

  3. 某種意義上,是一種更合理的分離,保證了核心和實現版本的分離,各自關注各自模組的核心問題;

缺點:

  1. 聯調成本增加,每次改完需要本地install 或者推播到遠端Maven倉庫;

基於以上原因,對於小工程不必做以上分離,直接在一個Maven工程中進行依賴開發即可 ,從很多範例教學也是推薦如此。

CQRS(命令與查詢職責分離)

CQRS 就是讀寫分離,讀寫分離的主要目的是為了提高查詢效能,同時達到讀、寫解耦。而 DDD 和 CQRS 結合,可以分別對讀和寫建模。

查詢模型是一種非標準化資料模型,它不反映領域行為,只用於資料查詢和顯示;命令模型執行領域行為,在領域行為執行完成後通知查詢模型。

命令模型如何通知到查詢模型呢?如果查詢模型和領域模型共用資料來源,則可以省卻這一步;如果沒有共用資料來源,可以藉助於釋出訂閱的訊息模式通知到查詢模型,從而達到資料最終一致性。

Martin 在 blog 中指出:CQRS 適用於極少數複雜的業務領域,如果不是很適合反而會增加複雜度;另一個適用場景是為了獲取高效能的查詢服務。

對於寫少讀多的共用類通用資料服務(如主資料類應用)可以採用讀寫分離架構模式。單資料中心寫入資料,通過釋出訂閱模式將資料副本分發到多資料中心。通過查詢模型微服務,實現多資料中心資料共用和查詢。

領域與讀模型的聯絡與差異

領域模型(以聚合根為唯一入口)是承載本體變更的核心,其是對業務模型的根本建模。若聚合根為每一個普通的人體,聚合根主鍵就是身份證ID。假設人人生而自由,不受人控制,那麼當一個人接受到合理命令後進行自我屬性變更,然後對外傳送資訊。

而檢視層是人體和社會資訊的投影,就如我們的教育情況,職業情況,健康情況等一樣。是對某個時刻對本體資訊的投影。

檢視因為基於訊息傳播的特性,我們的很多檢視可能是延遲或者不一致的。事例:

1. 你已經陽了,而你的健康碼還是綠碼;
2. 你已經結婚,而戶口本還是未婚;
3. 你的結婚證上聚合了你配偶的資訊;


現實世界的不一致已經給我們帶來了很多麻煩和困擾,對於IT系統來說也是一樣。檢視的實時更新總是令人神往,但是在分散式系統中面臨諸多挑戰。而為了消除領域模型變更後各種檢視層的延遲和不一致,就需要在訊息傳播和更新時機上做一些優化。但是在業務處理上,還是需要容忍一定程度的延遲和不一致,因為分散式系統是很難做到100%的準實時和一致性的。

3.3 問題及解決方案

3.3.1 領域資源註冊中心

背景

一般來講,領域模型不持有倉庫也不不持有其他服務,是一個比較。這就造成領域模型在做一些驗證的時候,僅能進行記憶體態的驗證。對於rpc服務,以及涉及一些重複性驗證的情況,就顯得無能為力。為了更好的解決這個問題,我們採用了領域模型註冊中心,採用一個單例的類來持有這些服務;

那我們在領域模型中,從資料庫重新載入回來的領域模型,不需要通過spring進行資料封裝,就可以直接使用所依賴的服務。

基於此,這些服務必須是無狀態的,通過輸入領域模型完成資料服務。

/**
 * 租戶註冊中心
 *
 * @author david
 * @date 12/12/22
 */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Setter
public class TenantRegistry {
    /**
     * 倉庫
     */
    private TenantRepository tenantRepository;

    /**
     * 單例
     */
    private static TenantRegistry INSTANCE = new TenantRegistry();

    /**
     * 獲取單例
     *
     * @return
     */
    public static TenantRegistry getInstance() {
        return INSTANCE;
    }

}



在領域模型進行資料儲存的時候,可用獲取倉庫或者驗證服務進行資料驗證。


 /**
     * 儲存資料
     */
    public void save() {
        this.validate();
        TenantRepository tenantRepository = TenantRegistry.getInstance().getTenantRepository();
        tenantRepository.save(this);
    }


3.3.2 核心模組化

一般來講,主站因為服務的客戶量廣,需求多樣,導致功能及依賴服務也會很龐大。然後在進行商業化部署的時候,往往只需要其中10%~50%的能力,如果在部署的時候,全量的服務和領域模型載入意味著需要設定相關的底層資源和依賴,否則可能啟動異常。

核心能力模組化就顯得尤為重要,目前我們主要利用spring的條件載入實現核心模組化。如下:

/**
 * 租戶構建工廠
 *
 * @author david
 */
@Component
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantInfoFactory {
}

/**
 * 租戶應用服務實現
 *
 * @author david
 */
@Service
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantAppServiceImpl implements TenantAppService {
}

//其它相關資源類似,通過@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}") 進行動態開關;


這樣在applicaiton.yml 設定相關能力的true/false, 就可以實現相關能力的按需載入,當然這是強依賴spring的基礎能力情況下。

//appliciaton.yml 設定

b2b:
  baseproject:
    kernel:
      ability:
        tenant: true
        dict: true
        scene: true


可選進一步優化依賴

條件載入使用了spring的註解,某種意義上導致核心和spring進行了耦合。然而,專案中總有終極DDD患者,希望核心中最好連spring的依賴也去掉。這個時候,可以將spring的裝配專門抽取到一個Maven的module作為starter,由這個starter負責spring的相關的注入和依賴進行適配。對於模組化載入設定,可以繼續沿用conditional的設定,本質上差異不大。

3.3.3 倉庫層diff實踐(可選項)

本案例僅在使用關係型資料庫,且為了提升更新時效能場景適用。如果能偏向於採用支援事務的NoSQL資料庫,那麼本實踐可直接略過。

如果不是受制於關係型資料庫的更加流行的制約,在面向DDD開發之後,大家可能更偏向於NoSQL資料庫,可以將領域物件以聚合根的為整體進行整存整取,這樣可以大大的降低倉庫層存取持久化資料的開發量。而現狀是大部分專案都依賴於關係型資料庫,故而很多資料依然存在複雜的資料庫儲存關係。

如果聚合根下關聯多個實體,那麼在更新的時候,比較簡潔的方式是整體覆蓋,即使資料行沒有發生變更。有時候為了提升資料庫更新的效能,就需要按需更新,這時候就需要追蹤實體物件是否發生變更。

對實體物件的變更追蹤有兩個方式:

A -> 儲存更新前快照,使用反射工具深度對比值是否變更;
B -> 使用RecordLog 作為資料狀態跟蹤;


在過往專案中,A/B方案均採用過,A方案的程式碼侵入較少,但是需要保留更新前完整快照,使用反射情況下效能會略有影響。 B方案不需要保持更新前完整快照, 也不用反射,但是需要在需要diff的實體物件中增加RecordLog值物件標記資料是新增、修改、或者未變更。

目前我們主要採用B方案,在涉及實體變更的入口方法,順便呼叫RecordLog的更新方法,這樣在倉庫層既可以判斷是新增、修改、還是沒有發生變更。倉庫層在執行儲存的時候,則可用通過recordLog值物件的creating, updating判斷資料的狀態。

/**
 * 紀錄檔值物件,用於記錄資料紀錄檔資訊
 *
 * @author david
 * @date 2020-08-24
 */
@Getter
@Setter
@ToString
@ValueObject
public class RecordLog implements Serializable, RecordLogCompatible {

    /**
     * 建立人
     */
    private String creator;
    /**
     * 操作人
     */
    private String operator;
    /**
     * 並行版本號,不一定以第三方傳入的為準
     */
    private Integer concurrentVersion;
    /**
     * 建立時間,不一定以第三方傳入的為準
     */
    private Date created;
    /**
     * 修改時間, 不一定以第三方傳入的為準
     */
    private Date modified;

    /**
     * 建立中
     */
    private transient boolean creating;
    /**
     * 修改中
     */
    private transient boolean updating;

    /**
     * 建立時構建
     *
     * @param creator
     * @return
     */
    public static RecordLog buildWhenCreating(String creator) {
        return buildWhenCreating(creator, new Date());
    }

    /**
     * 建立時構建,傳入建立時間
     *
     * @param creator
     * @param createTime
     * @return
     */
    public static RecordLog buildWhenCreating(String creator, Date createTime) {
        RecordLog recordLog = new RecordLog();
        recordLog.creator = creator;
        recordLog.created = createTime;
        recordLog.modified = createTime;
        recordLog.operator = creator;
        recordLog.concurrentVersion = 1;
        recordLog.creating = true;
        return recordLog;
    }

    /**
     * 更新
     *
     * @param operator
     */
    public void update(String operator) {
        setOperator(operator);
        setModified(new Date());
        setUpdating(true);
        concurrentVersion++;
    }

}



// 實體變更的時候,需要同步標記recordLog

public class TenantInfo implements AggregateRoot<TenantIdentifier> {

 /**
     * 失效資料
     *
     * @param operator
     */
    public void invalid(String operator) {
        setStatus(StatusEnum.NO);
        recordLog.update(operator);

    }

    /**
     * 釋出
     *
     * @param operator
     */
    public void publish(String operator) {
        setBusinessStatus(TenantBusinessStatusEnum.PUBLISH);
        recordLog.update(operator);
    }
    


      /**
     * 儲存到倉庫
     *
     * @param tenantInfo
     */
    @Override
    @Transactional
    public void save(TenantInfo tenantInfo) {
        TenantInfoPO tenantInfoPO = TenantInfoAssembler.convertToPO(tenantInfo);
        RecordLog recordLog = tenantInfo.getRecordLog();
        //建立diff判斷
        if (recordLog.isCreating()) {
            tenantInfoMapper.insert(tenantInfoPO);
        } else if (recordLog.isUpdating()) {            //更新diff判斷
            UpdateWrapper<TenantInfoPO> updateWrapper = new UpdateWrapper<>();
            updateWrapper.lambda().eq(TenantInfoPO::getTenantId, tenantInfoPO.getTenantId());
            tenantInfoMapper.update(tenantInfoPO, updateWrapper);
        }
        //將領域事件轉換為taskPo, 並在一個事務之中儲存到資料庫,以便保證最終被消費
        tenantInfo.publish(localTaskEventFactory.buildEventPersistenceAdapter(event -> TaskAssembler.tenantEventToTaskPO(event)));
    }


3.3.4 讀服務設計

一個完整的領域服務,只是寫入沒有讀取是不夠的,只寫不讀會出現資訊黑洞,導致領域變更無法被外部感知和使用。如前面所述,讀服務是面向檢視的,其需要的是容易檢索(索引服務),寬表(冗餘關聯資訊),摘要資訊。且讀服務不對源資料進行修改,無需進行加鎖更注重響應快速。

目前核心能相對標準化的讀服務,主要針對聚合根進行基本的詳情檢索,如通過聚合根主鍵返回基本檢視資訊、列表檢索等;其他個性化客製化化的查詢引數和響應結果可以依據需求自行設計和擴充套件,如果是比較客製化的查詢服務,可以不必落地到核心之中。

在b2b-baseproject-kernel工程的 read-app 模組中,我們定義了讀服務的介面和約束返回物件,則在實現的center工程中,主要實現底層的讀倉庫和SDK接入層即可(可通過ES, 關係型資料庫, redis 等來提供底層的檢索服務)。

讀服務介面:

/**
 * 租戶應用查詢服務
 *
 * @author david
 **/
public interface TenantInfoQueryService {

    /**
     * 通過租戶code查詢
     *
     * @param req
     * @return
     */
    TenantConstraint getTenantByCode(GetTenantByCodeReq req);
}

/**
 * 通過租戶編碼查詢租戶資訊請求
 *
 * @author david
 */
@Setter
@Getter
@ToString
public class GetTenantByCodeReq implements Serializable, Verifiable {
    /**
     * 租戶編碼
     */
    private String tenantCode;

    @Override
    public void validate() {
        Validate.notEmpty(tenantCode, CodeDetailEnum.TENANT);
    }
}





/**
 * 範例租戶讀服務約束介面
 *
 * @author david
 * @date 4/15/22
 */
public interface TenantConstraint extends RecordLogCompatible {
    /**
     * 租戶id
     */
    Long getTenantId();

    /**
     * 租戶id,編碼
     */
    Integer getTenantCode();
  
    // ...
}



/**
 * 租戶應用查詢服務核心實現
 *
 * @author david
 **/
@Service
public class TenantInfoQueryServiceImpl implements TenantInfoQueryService {

    //租戶讀倉庫
    @Resource
    private TenantReadRepo tenantReadRepo;

    /**
     * 通過租戶id查詢
     *
     * @param req
     * @return
     */
    @Override
    public TenantConstraint getTenantByCode(GetTenantByCodeReq req) {
        req.validate();
        return tenantReadRepo.getTenantByCode(req.getTenantCode());
    }
  
  //...
}


3.3.5 領域事件釋出

如果不依賴binlog和事務性訊息元件, 為了保證領域事件一定被傳送出去,就需要依賴本地事務表。我們將領域物件儲存和領域事件釋出任務記錄在一個事務中得以執行。在領域事件推播訊息中介軟體MQ中,在資料庫儲存完畢後,先主動傳送一次(容許失敗),如果傳送失敗再等待定時排程掃描事件表重新傳送。如下圖所示:

一般情況下,領域事件都是在業務操作的時候產生,此時我們將領域事件暫存到註冊中心。待入庫的時候,在一個事務包裹中進行儲存。釋出者如下所示,如果聚合根需要使用此釋出者事件註冊服務,只需要實現此Publisher介面即可。因為內部使用了WeakHashMap 作為容器,如果當前物件不再被應用,之前註冊的事件列表會被自動回收掉。

/**
 * 描述:釋出者介面
 *
 */
public interface Publisher {
    /**
     * 容器
     */
    Map<Object, List<DomainEvent>> container = Collections.synchronizedMap(new WeakHashMap<>());

    /**
     * 註冊事件
     *
     * @param domainEvent
     */
    default void register(DomainEvent domainEvent) {
        List<DomainEvent> domainEvents = container.get(this);
        if (Objects.isNull(domainEvents)) {
            domainEvents = Lists.newArrayListWithCapacity(2);
            container.put(this, domainEvents);
        }
        domainEvents.add(domainEvent);
    }

   /**
     * 獲取事件列表
     *
     * @return
     */
    default List<DomainEvent> getEventList() {
        return container.get(this);
    }
  
 // 更多程式碼...略
  
}


簡化方案

如果一些簡單的應用,不需要使用MQ訊息佇列進行事件中轉,也可以將本地事件表的傳送狀態作為任務處理狀態。這樣可以簡化一些網路開銷,如在一個應用內,藉助guava的EventBus元件完成訊息釋出-訂閱機制。即簡化為:訂閱處理器如果全部執行成功,才更新訊息表為已傳送(也可以認為是已執行)。

在實際開發中,實際上我們很多領域事件都是基於此簡化方案進行處理的,因領域事件的部分處理功能簡單,使用簡化方案能節省很多開發時間和程式碼量。

3.3.6 SAGA事務

概述

採用DDD之後,雖然還是可以從應用層採用基礎的事務性程式設計保證本地資料庫的事務性。然而當處於微服務架構模式,我們的業務常常需要多個跨應用的微服務協同,採用事務進行一致性保證就顯得鞭長莫及。

即使不採用DDD程式設計, 我們過往已經開始採用Binlog(MySQL的主從同步機制)或者事務性訊息來實現最終一致性。在越來越流行的微服務架構趨勢下(應用資源的分散式特性),通過傳統的事務ACID(atomicity、consistency、isolation、durability)保證一致性已經很難,現在我們通過犧牲原子性(atomicity)和隔離性(Isolation),轉而通過保證CD來實現最終一致性。

解決分散式事務,有許多技術方案如:兩階段提交(XA)、TCC、SAGA。

關於分散式事務方案的優缺點,有很多論文和技術文章,為什麼選擇SAGA ,正如 Chris Richardson在《微服務架構設計模式》中所述:

  1. XA對中介軟體要求很高,跨系統的微服務更是讓XA鞭長莫及;XA和分散式應用天生不匹配;

  2. TCC 對每一個參與方需要實現(Try-confirm-cancel)三步,侵入性較大;

  3. SAGA是一種在微服務架構中維護資料一致性的機制,它可以避免分散式事務帶來的問題。通過非同步訊息來協調一系列本地事務,從而維護多個服務直接的資料一致性;

  4. SAGA理論部分, 可以參考:分散式事務:SAGA模式Pattern: Saga

SAGA 理論

1987年普林斯頓大學的Hector Garcia-Molina和Kenneth Salem發表了一篇Paper Sagas,講述的是如何處理long lived transaction(長活事務)。Saga是一個長活事務可被分解成可以交錯執行的子事務集合。其中每個子事務都是一個保持資料庫一致性的真實事務。 論文地址:sagas

Saga的組成

  • 每個Saga由一系列sub-transaction Ti 組成; (每個Ti是保證原子性提交);

  • 每個Ti 都有對應的補償動作Ci,補償動作用於復原Ti造成的結果; (Ti如果驗證邏輯且唯讀,可以為空補償,即不需要補償);

  • 每一個Ti操作在分散式系統中,要求保證冪等性(可重複請求而不產生髒資料);

    Saga的執行順序有兩種:

    • T1, T2, T3, ..., Tn (理想狀態,直接成功);

    • T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n (向前恢復模式,一般為業務失敗);

Saga補償範例: 如果在一個事務處理中,Ti為發郵件, Saga不會先儲存草稿等事務提交時再傳送,而是立刻傳送完成。 如果任務最終執行失敗, Ti已發出的郵件將無法復原,Ci操作是補發一封郵件進行復原說明。

SAGA有兩種主要的模式,協同式、編排式。

A 事件協同式SAGA(Event choreography)

把Saga的決策和執行順序邏輯分佈在Saga的每個參與方中,他們通過相互發訊息的方式來溝通。

在事件編排方法中,第一個服務執行一個事務,然後釋出一個事件,該事件被一個或多個服務進行監聽,這些服務再執行本地事務並行布(或不釋出)新的事件。當最後一個服務執行本地事務並且不釋出任何事件時,意味著分散式事務結束,或者它釋出的事件沒有被任何 Saga 參與者聽到都意味著事務結束。

① 優點:

  • 避免中央協調器單點故障風險;

  • 當涉及的步驟較少服務開發簡單,容易實現;

② 缺點:

  • 服務之間存在迴圈依賴的風險;

  • 當涉及的步驟較多,服務間關係混亂,難以追蹤調測;

  • 參與方需要彼此感知上下耦合關聯性,無法做到服務單元化;

B 命令編排式SAGA(Order Orchestrator)

中央協調器(Orchestrator,簡稱 OSO)以命令/回覆的方式與每項服務進行通訊,全權負責告訴每個參與者該做什麼以及什麼時候該做什麼。

① 優點:

  • 服務之間關係簡單,避免服務間迴圈依賴,因為 Saga 協調器會呼叫 Saga 參與者,但參與者不會呼叫協調器。

  • 程式開發簡單,只需要執行命令/回覆(其實回覆訊息也是一種事件訊息),降低參與者的複雜性。

  • 易維護擴充套件,在新增新步驟時,事務複雜性保持線性,回滾更容易管理,更容易實施和測試。

② 缺點:

  • 中央協調器處理邏輯容易變得龐大複雜,導致難以維護。

  • 存在協調器單點故障風險。

命令編排式SAGA範例—— 非訂單聚合提票開票申請

Saga在發票開票申請的案例如下所示,提票申請被拆分為2個主要的SAGA協調器。

① 在接收到【母申請單已經建立事件】即觸發生成協調器1排程——開票申請SAGA協調器, 用於引數驗證、訂單鎖定、佔用應開金額和數量、最後按開票規則拆分為多個子申請單(一個子申請單對一張實際的發票)。在多個子申請單完成建立後, 會發布【子申請單已建立】事件。

② 在接收到【子申請單已經建立事件】即觸發生成協調器2排程——子申請單提票SAGA協調器, 用於子申請單預佔流水記錄、提交財務開票、接收財務狀態同步子申請單狀態。

​ 使用編排式Saga, 對每一個步驟的呼叫也不一定是同步的,也可以傳送處理請求後掛起協調處理器,等待非同步訊息通知。通過訊息中介軟體如MQ收到某個步驟的處理結果訊息,然後再恢復協調器的繼續排程。假設Saga事務的每個步驟都是非同步的,那麼編排式協調器和事件協調器就非常類同,唯一的好處是整個業務處理的訊息收發均要通過Saga協調器作為中樞。當前在哪一步驟,下一步要做什麼可以由SAGA協調器統一支配。

​ 對於一個比較複雜的長活事務,從業務的完整性和排查問題的方便性考慮,我們推薦使用Saga編排式事務來收斂業務的排程複雜度,以免在訊息傳送接收網路中迷失。編排式事務有時候類似一個狀態機,當前任務執行到哪個步驟,哪個狀態能夠被儲存和復原,且條理性更加清晰。

​ 在編排式Saga事務中,我們需要使用到eventSource類似的事件記錄,以便記錄每一個步驟的執行情況和部分上下文資訊。除了手動建表之外(目前我們採用的方案),也有很多成熟的框架可供選擇,如:alibaba的seata,微服務架構設計模式推薦的eventuate 。

風險:

當然在使用saga中,還需要考慮隔離性缺失帶來的風險,尤其是在交易和金融環節。這不是saga能直接解決的問題,這需要通過語意鎖(未提交資料加欄位鎖,防止髒讀)、交換式更新、版本檔案、重讀值等方案進行處理。

4. 參考資料

4.1 參考書籍

Domain-Driven Design《領域驅動設計》--Eric Evans

MicroServices Patterns《微服務架構設計模式》 -- Chirs Richardson

《DDD 實戰課》 -- 歐創新

_4.2_網路資料

領域模型核心概念:實體、值物件和聚合根

聚合(根)、實體、值物件精煉思考總結

DDD(Domain-Driven Design)領域驅動設計在網際網路業務開發中的實踐

DDD落地實踐

https://www.jianshu.com/p/91bfc4f21caa

https://www.jianshu.com/p/4a0d89dd7c20

領域驅動設計(2) 領域事件、DDD分層架構

https://my.oschina.net/lxd6825/blog/5485465

saga分散式事務_本地事務和分散式事務-tencent

作者:京東零售 張世彬

來源:京東雲開發者社群 轉載請註明來源