領域驅動設計(DDD)實踐之路(二):事件驅動與CQRS

本文首發於 vivo互聯網技術 微信公衆號 
連接: https://mp.weixin.qq.com/s/Z3...
做者:wenbo zhang

【領域驅動設計實踐之路】系列往期精彩文章:html

領域驅動設計(DDD)實踐之路(一)》 主要講述了戰略層面的DDD原則。java

這是「領域驅動設計實踐之路」系列的第二篇文章,分析瞭如何應用事件來分離軟件核心複雜度。探究CQRS爲何普遍應用於DDD項目中,以及如何落地實現CQRS框架。固然咱們也要警戒一些失敗的教訓,利弊分析之後再去抉擇正確的應對之道。spring

1、前言:從物流詳情開始

你們對物流跟蹤都不陌生,它詳細記錄了在什麼時間發生了什麼,而且數據做爲重要憑證是不可變的。我理解其背後的價值有這麼幾個方面:業務方能夠管控每一個子過程、知道目前所處的環節;另外一方面,當須要追溯時候僅僅經過每一步的記錄就能夠回放整個歷史過程。sql

我在以前的文章中提出過「軟件項目也是人類社會生產關係的範疇,只不過咱們所創造的勞動成果看不見摸不着而已」。因此咱們能夠借鑑物流跟蹤的思路來開發軟件項目,把複雜過程拆解爲一個個步驟、子過程、狀態,這和咱們事件劃分是一致的,這就是事件驅動的典型案例。數據庫

2、領域事件

領域事件(Domain Events)是領域驅動設計(Domain Driven Design,DDD)中的一個概念,用於捕獲咱們所建模的領域中所發生過的事情。編程

領域事件自己也做爲通用語言(Ubiquitous Language)的一部分紅爲包括領域專家在內的全部項目成員的交流用語。設計模式

好比在前述的跨境物流例子中,貨品達到保稅倉之後須要分派工做人員進行分揀分包,那麼「貨品已到達保稅倉」即是一個領域事件。微信

首先,從業務邏輯來講該事件關係到整個流程的成功或者失敗;同時又將觸發後續子流程;而對於業務方來講,該事件也是一個標誌性的里程碑,表明本身的貨品就快配送到本身手中。網絡

因此一般來講,一個領域事件具備如下幾個特徵:較高的業務價值,有助於造成完整的業務閉環,將致使進一步的業務操做。這裏還要強調一點,領域事件具備明確的邊界。架構

好比:若是你建模的是餐廳的結帳系統,那麼此時的「客戶已到達」便不是你關心的重點,由於你不可能在客戶到達時就當即向對方要錢,而「客戶已下單」纔是對結帳系統有用的事件。

一、建模領域事件

在建模領域事件時,咱們應該根據限界上下文中的通用語言來命名事件及屬性。若是事件由聚合上的命令操做產生,那麼咱們一般根據該操做方法的名字來命名領域事件。

對於上面的例子「貨品已到達保稅倉」,咱們將發佈與之對應的領域事件

GoodsArrivedBondedWarehouseEvent(固然在明確的界限上下文中也能夠去掉聚合的名字,直接建模爲ArrivedBondedWarehouseEvent,這都是命名方面的習慣)。

事件的名字代表了聚合上的命令方法在執行成功以後所發生的事情,換句話說待定項以及不肯定的狀態是不能做爲領域事件的。

一個行之有效的方法是畫出當前業務的狀態流轉圖,包含前置操做以及引發的狀態變動,這裏表達的是已經變動完成的狀態因此咱們不用過去時態表示,好比刪除或者取消,即表明已經刪除或者已經取消。

而後對於其中的節點進行事件建模。以下圖是文件雲端存儲的業務,咱們分別對預上傳、上傳完成確認、刪除等環節建模「過去時」事件,PreUploadedEvent、ConfirmUploadedEvent、RemovedEvent。

二、領域事件代碼解讀

package domain.event;

import java.util.Date;
import java.util.UUID;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */
public class DomainEvent {

    /**
     * 領域事件還包含了惟一ID,
     * 可是該ID並非實體(Entity)層面的ID概念,
     * 而是主要用於事件追溯和日誌。
     * 若是是數據庫存儲,該字段一般爲惟一索引。
     */
    private final String id;

    /**
     * 建立時間用於追溯,另外一方面無論使用了
     * 哪一種事件存儲都有可能遇到事件延遲,
     * 咱們經過建立時間可以確保其發生順序。
     */
    private final Date occurredOn;

    public DomainEvent() {
        this.id = String.valueOf(UUID.randomUUID());
        this.occurredOn = new Date();
    }
}

在建立領域事件時,須要注意2點:

  • 領域事件自己應該是不變的(Immutable);
  • 領域事件應該攜帶與事件發生時相關的上下文數據信息,可是並非整個聚合根的狀態數據。例如,在建立訂單時能夠攜帶訂單的基本信息,而對於用戶更新訂單收貨地址事件AddressUpdatedEvent事件,只須要包含訂單、用戶以及新的地址等信息便可。
public class AddressUpdatedEvent extends DomainEvent {
    //經過userId+orderId來校驗訂單的合法性;
    private String userId; 
    private String orderId;
    //新的地址
    private Address address;
    //略去具體業務邏輯
}

三、領域事件的存儲

事件的不可變性與可追溯性都決定了其必需要持久化的原則,咱們來看看常見的幾種方案。

3.1單獨的EventStore

有的業務場景中會建立一個單獨的事件存儲中心,多是Mysql、Redis、Mongo、甚至文件存儲等。這裏以Mysql舉例,business_code、event_code用來區分不一樣業務的不一樣事件,具體的命名規則能夠根據實際須要。

這裏須要注意該數據源與業務數據源不一致的場景,咱們要確保當業務數據更新之後事件可以準確無誤的記錄下來,實踐中儘可能避免使用分佈式事務,或者儘可能避免其跨庫的場景,不然你就得想一想如何補償了。千萬要避免,用戶更新了收貨地址,可是AddressUpdatedEvent事件保存失敗。

總的原則就是對分佈式事務Say No,不管如何,我相信方法總比問題多,在實踐中咱們總能夠想到解決方案,區別在於該方案是否簡潔、是否作到了解耦。

# 考慮是否須要分表,事件存儲建議邏輯簡單
CREATE TABLE `event_store` (
  `event_id` int(11) NOT NULL auto increment,
  `event_code` varchar(32) NOT NULL,
  `event_name` varchar(64) NOT NULL,
  `event_body` varchar(4096) NOT NULL,
  `occurred_on` datetime NOT NULL,
  `business_code` varchar(128) NOT NULL,
  UNIQUE KEY (`event id`)
) ENGINE=InnoDB COMMENT '事件存儲表';

3.2 與業務數據一塊兒存儲

在分佈式架構中,每一個模塊都作的相對比較小,準確的說是「自治」。若是當前業務數據量較小,能夠將事件與業務數據一塊兒存儲,用相關標識區分是真實的業務數據仍是事件記錄;或者在當前業務數據庫中創建該業務本身的事件存儲,可是要考慮到事件存儲的量級必然大於真實的業務數據,考慮是否須要分表。

這種方案的優點:數據自治;避免分佈式事務;不須要額外的事件存儲中心。固然其劣勢就是不能複用。

四、領域事件如何發佈

4.1 由領域聚合發送領域事件

/*
* 一個關於比賽的充血模型例子
* 貧血模型會構造一個MatchService,咱們這裏經過模型來觸發相應的事件
* 本例中略去了具體的業務細節
*/
public class Match {
    public void start() {
        //構造Event....
        MatchEvent matchStartedEvent = new MatchStartedEvent();
        //略去具體業務邏輯
        DefaultDomainEventBus.publish(matchStartedEvent);
    }

    public void finish() {
        //構造Event....
        MatchEvent matchFinishedEvent = new MatchFinishedEvent();
        //略去具體業務邏輯
        DefaultDomainEventBus.publish(matchFinishedEvent);
    }

    //略去Match對象基本屬性
}

4.2 事件總線VS消息中間件

微服務內的領域事件能夠經過事件總線或利用應用服務實現不一樣聚合之間的業務協同。即微服務內發生領域事件時,因爲大部分事件的集成發生在同一個線程內,不必定須要引入消息中間件。但一個事件若是同時更新多個聚合數據,按照 DDD「一個事務只更新一個聚合根」的原則,能夠考慮引入消息中間件,經過異步化的方式,對微服務內不一樣的聚合根採用不一樣的事務

3、Saga分佈式事務

一、Saga概要

咱們看看如何使用 Saga 模式維護數據一致性?

Saga 是一種在微服務架構中維護數據一致性的機制,它能夠避免分佈式事務所帶來的問題。

一個 Saga 表示須要更新的多個服務中的一個,即Saga由一連串的本地事務組成。每個本地事務負責更新它所在服務的私有數據庫,這些操做仍舊依賴於咱們所熟悉的ACID事務框架和函數庫。

模式:Saga

經過使用異步消息來協調一系列本地事務,從而維護多個服務之間的數據一致性。

請參閱(強烈建議):https://microservices.io/patterns/data/saga.html

Saga與TCC相比少了一步Try的操做,TCC不管最終事務成功失敗都須要與事務參與方交互兩次。而Saga在事務成功的狀況下只須要與事務參與方交互一次, 若是事務失敗,須要額外進行補償回滾。

  • 每一個Saga由一系列sub-transaction Ti 組成;
  • 每一個Ti 都有對應的補償動做Ci,補償動做用於撤銷Ti形成的結果;

能夠看到,和TCC相比,Saga沒有「預留」動做,它的Ti就是直接提交到庫。

Saga的執行順序有兩種:

  • success:T1, T2, T3, ..., Tn ;
  • failure:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n;

因此咱們能夠看到Saga的撤銷十分關鍵,能夠說使用Saga的難點就在於如何設計你的回滾策略。

二、Saga實現

經過上面的例子咱們對Saga有了初步的體感,如今來深刻探討下如何實現。當經過系統命令啓動Saga時,協調邏輯必須選擇並通知第一個Saga參與方執行本地事務。一旦該事務完成,Saga協調選擇並調用下一個Saga參與方。

這個過程一直持續到Saga執行完全部步驟。若是任何本地事務失敗,則 Saga必須以相反的順序執行補償事務。如下幾種不一樣的方法可用來構建Saga的協調邏輯。

2.1 協同式(choreography)

把 Saga 的決策和執行順序邏輯分佈在 Saga的每個參與方中,它們經過交換事件的方式來進行溝通。

( 引用於《微服務架構設計模式》相關章節)

  1. Order服務建立一個Order併發布OrderCreated事件。
  2. Consumer服務消費OrderCreated事件,驗證消費者是否能夠下訂單,併發布ConsumerVerified事件。
  3. Kitchen服務消費OrderCreated事件,驗證訂單,在CREATE_PENDING狀態下建立故障單,併發布TicketCreated事件。
  4. Accounting服務消費OrderCreated事件並建立一個處於PENDING狀態的Credit CardAuthorization。
  5. Accounting服務消費TicketCreated和ConsumerVerified事件,向消費者的信用卡收費,併發布信用卡受權失敗事件。
  6. Kitchen服務使用信用卡受權失敗事件並將故障單的狀態更改成REJECTED。
  7. 訂單服務消費信用卡受權失敗事件,並將訂單狀態更改成已拒絕。

2.2 編排式(orchestration)

把Saga的決策和執行順序邏輯集中在一個Saga編排器類中。Saga 編排器發出命令式消息給各個 Saga 參與方,指示這些參與方服務完成具體操做(本地事務)。相似於一個狀態機,當參與方服務完成操做之後會給編排器發送一個狀態指令,以決定下一步作什麼。

( 引用於《微服務架構設計模式》相關章節)

咱們來分析一下執行流程

  1. Order Service首先建立一個Order和一個建立訂單控制器。以後,路徑的流程以下:
  2. Saga orchestrator向Consumer Service發送Verify Consumer命令。
  3. Consumer Service回覆Consumer Verified消息。
  4. Saga orchestrator向Kitchen Service發送Create Ticket命令。
  5. Kitchen Service回覆Ticket Created消息。
  6. Saga協調器向Accounting Service發送受權卡消息。
  7. Accounting服務部門使用卡片受權消息回覆。
  8. Saga orchestrator向Kitchen Service發送Approve Ticket命令。
  9. Saga orchestrator向訂單服務發送批准訂單命令。

2.3 補償策略

以前的描述中咱們說過Saga最重要的是如何處理異常,狀態機還定義了許多異常狀態。如上面的6就會發生失敗,觸發AuthorizeCardFailure,此時咱們就要結束訂單並把以前提交的事務進行回滾。這裏面要區分哪些是校驗性事務、哪些是須要補償的事務。

 一個Saga由三種不一樣類型的事務組成:可補償性事務(能夠回滾,所以有一個補償事務);關鍵性事務(這是 Saga的成敗關鍵點,好比4帳戶代扣);以及可重複性事務,它不須要回滾並保證可以完成(好比6更新狀態)。

在Create Order Saga 中,createOrder()、createTicket()步驟是可補償性事務且具備撤銷其更新的補償事務。

verifyConsumerDetails()事務是隻讀的,所以不須要補償事務。authorizeCreditCard()事務是這個 Saga的關鍵性事務。若是消費者的信用卡能夠受權,那麼這個Saga保證完成。approveTicket()和approveRestaurantOrder()步驟是在關鍵性事務以後的可重複性事務。

認真拆解每一個步驟、而後評估其補償策略尤其重要,正如你看到的,每種類型的事務在對策中扮演着不一樣的角色。

4、CQRS

前面講述了事件的概念,又分析了Saga如何解決復瑣事務,如今咱們來看看CQRS爲何在DDD中普遍被採用。除了讀寫分離的特徵之外,咱們用事件驅動的方式來實踐Command邏輯能有效下降業務的複雜度。

當你明白如何建模事件、如何規避復瑣事務,明白何時用消息中間件、何時採用事件總線,才能理解爲何是CQRS、怎麼正確應用。

( 圖片來源於網絡)

下面是咱們項目中的設計,這裏爲何會出現Read/Write Service,是爲了封裝調用,service內部是基於聚合發送事件。由於我發如今實際項目中,不少人都會第一時間問我要XXXService而不是XXX模型,因此在DDD沒有徹底普及的項目中建議你們採起這種居中策略。這也符合我們的解耦,對方依賴個人抽象能力,然而我內部是基於DDD仍是傳統的流程代碼對其是無關透明的。

咱們先來看看事件以及處理器的時序關係。

這裏仍是以文件雲端存儲業務爲例,下面是一些處理器的核心代碼。註釋行是對代碼功能、用法以及擴展方面的解讀,請認真閱讀。

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @Description: 事件註冊邏輯
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DomainRegistry {

    private Map<String, List<DomainEventHandler>> handlerMap =
        new HashMap<String, List<DomainEventHandler>>();

    private static DomainRegistry instance;

    private DomainRegistry() {
    }

    public static DomainRegistry getInstance() {
        if (instance == null) {
            instance = new DomainRegistry();
        }
        return instance;
    }

    public Map<String, List<DomainEventHandler>> getHandlerMap() {
        return handlerMap;
    }

    public List<DomainEventHandler> find(String name) {
        if (name == null) {
            return null;
        }
        return handlerMap.get(name);
    }

    //事件註冊與維護,register分多少個場景根據業務拆分,
    //這裏是業務流的核心。若是多個事件須要維護先後依賴關係,
    //能夠維護一個priority邏輯
    public void register(Class<? extends DomainEvent> domainEvent,
                         DomainEventHandler handler) {
        if (domainEvent == null) {
            return;
        }
        if (handlerMap.get(domainEvent.getName()) == null) {
            handlerMap.put(domainEvent.getName(), new ArrayList<DomainEventHandler>());
        }
        handlerMap.get(domainEvent.getName()).add(handler);
        //按照優先級進行事件處理器排序
        。。。
    }
}

文件上傳完畢事件的例子。

package domain.handler.event;

import domain.DomainRegistry;
import domain.StateDispatcher;
import domain.entity.meta.MetaActionEnums;
import domain.event.DomainEvent;
import domain.event.MetaEvent;
import domain.repository.meta.MetaRepository;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

/**
 * @Description:一個事件操做的處理器
 * 咱們混合使用了Saga的兩種模式,外層事件交互;
 * 對於單個複雜的事件內部採起狀態流轉實現。
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

@Component
public class MetaConfirmUploadedHandler implements DomainEventHandler {

    @Resource
    private MetaRepository metaRepository;

    public void handle(DomainEvent event) {
        //1.咱們在當前的上下文中定義個ThreadLocal變量
        //用於存放事件影響的聚合根信息(線程共享)

        //2.固然若是有須要額外的信息,能夠基於event所
        //攜帶的信息構造Specification從repository獲取
        // 代碼示例
        // metaRepository.queryBySpecification(SpecificationFactory.build(event));

        DomainEvent domainEvent = metaRepository.load();

        //此處是咱們的邏輯
        。。。。

        //對於單個操做比較複雜的,可使用狀態流轉進一步拆分
        domainEvent.setStatus(nextState);
        //在事件觸發以後,仍須要一個狀態跟蹤器來解決大事務問題
        //Saga編排式
        StateDispatcher.dispatch();
    }

    @PostConstruct
    public void autoRegister() {
        //此處能夠更加細分,註冊在哪一類場景中,這也是事件驅動的強大、靈活之處。
        //避免了if...else判斷。咱們能夠有這樣的意識,一旦你的邏輯裏面充斥了大量
        //switch、if的時候來看看本身註冊的場景是否能夠繼續細分
        DomainRegistry.getInstance().register(MetaEvent.class, this);
    }

    public String getAction() {
        return MetaActionEnums.CONFIRM_UPLOADED.name();
    }

    //適用於先後依賴的事件,經過優先級指定執行順序
    public Integer getPriority() {
        return PriorityEnums.FIRST.getValue();
    }
}

事件總線邏輯

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;
import java.util.List;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DefaultDomainEventBus {

    public static void publish(DomainEvent event, String action,
                               EventCallback callback) {

        List<DomainEventHandler> handlers = DomainRegistry.getInstance().
            find(event.getClass().getName());
        handlers.stream().forEach(handler -> {
            if (action != null && action.equals(handler.getAction())) {
                Exception e = null;
                boolean result = true;
                try {
                    handler.handle(event);
                } catch (Exception ex) {
                    e = ex;
                    result = false;
                    //自定義異常處理
                    。。。
                } finally {
                    //write into event store
                    saveEvent(event);
                }

                //根據實際業務處理回調場景,DefaultEventCallback能夠返回
                if (callback != null) {
                    callback.callback(event, action, result, e);       
                }
            }
        });
    }
}

5、自治服務和系統

DDD中強調限界上下文的自治特性,事實上,從更小的粒度來看,對象仍然須要具有自治的這四個特性,即:最小完備、自我履行、穩定空間、獨立進化。其中自我履行是重點,由於不強依賴外部因此穩定、由於穩定纔可能獨立進化。這就是六邊形架構在DDD中較爲廣泛的緣由。

( 圖片來源於網絡)

6、結語

本文所講述的事件、Saga、CQRS的方案都可以單獨使用,能夠應用到你的某個method、或者你的整個package。項目中咱們並不必定要實踐一整套CQRS,只要其中的某些思想解決了咱們項目中的某個問題就足夠了。

也許你如今已經磨刀霍霍,準備在項目中實踐一下這些技巧。不過咱們要明白「每個硬幣都有兩面性」,咱們不只看到高擴展、解耦的、易編排的優勢之外,仍然要明白其所帶來的問題。利弊分析之後再去決定如何實現纔是正確的應對之道。

  • 這類編程模式有必定的學習曲線;
  • 基於消息傳遞的應用程序的複雜性;
  • 處理事件的演化有必定難度;
  • 刪除數據存在必定難度;
  • 查詢事件存儲庫很是有挑戰性。

不過咱們仍是要認識到在其適合的場景中,六邊形架構以及DDD戰術將加速咱們的領域建模過程,也迫使咱們從嚴格的通用語言角度來解釋一個領域,而不是一個個需求。任何更強調核心域而不是技術實現的方式均可以增長業務價值,並使咱們得到更大的競爭優點。

附:參考文獻

  1. Pattern: Saga
  2. 分佈式事務:Saga模式
  3. 書籍:《微服務架構設計模式》

更多內容敬請關注 vivo 互聯網技術 微信公衆號

注:轉載文章請先與微信號:Labs2020 聯繫。

相關文章
相關標籤/搜索