領域驅動模型DDD(二)——領域事件的訂閱/釋出實踐

阿波羅的手發表於2022-04-07

前言

憑良心來說,《微服務架構設計模式》此書什麼都好,就是選用的業務過於龐大而導致程式碼連貫性太差,我作為讀者來說對於其中採用的自研框架看起來味同嚼蠟,需要花費的學習成本實在是難以想象,不僅要對書中的內容進行了解,還要去學習作者框架用法,最可惡的是官方文件還寫得十分簡潔。

不要跟我說《微服務架構設計模式》是一本概念性著作,對於我這水平一般的"實用派"來說在理解概念的雛形後最希望的就是通過書中的實現案例確認自己的理解是否正確,最後再鞏固記憶嘗試使用。

如果你讀了第一章可以看到,在該章節中我一直強調萬不可在閱讀文章時帶入“正進行程式設計的思維”——即如何具體到程式碼塊的編寫或是相關技術的採用。

這個勸誡與我此章採用程式碼講解並不衝突,我舉例的程式碼堅持從簡:一是我水平和時間不足以讓我做出像書本一樣專業的企業案例;二是我認為過度繁瑣的業務會讓讀者深陷程式碼解讀而非吸取思想理念的困境中。從利用現實生活中的相似物比喻引導讀者粗略理解概念,到簡略的程式碼框架明瞭地讓讀者知道“某種思維”的大致應用方向,這一切始終圍繞著本部落格撰寫的核心觀點——“架構的設計”的閱讀過程是思維視角的抽象傳播,而非細緻到可以“一招鮮吃遍天”的具體程式碼。

貧血模型、充血模型

本著說話就要說完的原則,我還是決定在此章加入這一小節。主要是因為領域驅動模型中重要的巨集觀架構到“領域的劃分”、“限界上下文”,細微業務邏輯到“事件”、“聚合”、“命令”等。然而在通常開發過程中Java的Spring框架、Golang的Beggo框架,大多在有意無意暗示開發者使用貧血模式,在平常專案後端我們一般使用三層架構進行業務開發:Repository + Entity、Service + BO(Business Object)、Controller + VO(View Object),Entity類和Repository類負責資料訪問, Bo類和Service類負責處理業務邏輯, Vo類和Controller類屬於介面層。(估計某些簡單專案甚至連到這一步細分都沒有)

貧血模式其最直觀的表現就在於:領域物件裡中只有GET和SET方法,至於業務邏輯都塞入Service類中,物件BO類中的所有屬性就只是用來做資料庫和真實世界的資料之間的傳遞介質。

@Data
public class TicketBO {
    //單個訂單的商品形成的Set集合,參考淘寶購物車勾選多個商品後合計支付的訂單
    private Set<Order> orders;
    //所有價格
    private BigDecimal realAmount;
    //優惠
    private BigDecimal discounts;
    //訂單建立時間
    private LocalDateTime orderCreateTime;
    //修改訂單
    private LocalDateTime orderChangeTime;
    //訂單完成時間
    private LocalDateTime finishCreateTime;
}

而在充血模式下資料和對應的業務邏輯被封裝到物件類中,Service層僅負責業務邏輯與儲存之間的流程編排(從DB中獲取資料,傳遞資料,最後儲存資料),並不參與任何的業務邏輯。在以下程式碼裡,我將使用者未優惠下的支付金額與實際支付金額的業務計算直接置入類中,形成典型的物件導向程式設計風格。

@Data
public class TicketBO {
    //單個訂單的商品形成的Set集合,參考淘寶購物車勾選多個商品後合計支付的訂單
    private Set<Order> orders;
    //所有價格
    private BigDecimal realAmount;
    //優惠扣減價格
    private BigDecimal discounts;
    //訂單建立時間
    private LocalDateTime orderCreateTime;
    //修改訂單
    private LocalDateTime orderChangeTime;
    //訂單完成時間
    private LocalDateTime finishCreateTime;

    //未使用優惠券情況下應付應付總額
    public BigDecimal getAllAmount(){
        return orders.stream().map(Order::getPrice).reduce(BigDecimal.ZERO,BigDecimal::add);
    }

    //使用優惠券後實際應付總額
    public BigDecimal getRealAmount(){
        BigDecimal allAmount = getAllAmount();
        return allAmount.subtract(discounts);
    }
}

讀者可能會產生疑問,這兩種開發模式落實到程式碼層面,區別不就是一個將業務邏輯放到 Service 類中,一個將業務邏輯放到 Domain 領域模型(上面的BO類)中嗎?為什麼基於貧血模型的傳統開發模式,就不能應對複雜業務系統的開發?

實際上,除了我們能看到的程式碼層面的區別之外(一個業務邏輯放到 Service 層,一個放到領域模型中),還有一個非常重要的區別,那就是兩種不同的開發模式會導致不同的開發流程。

傳統的程式碼業務流程開發中,很多程式設計師最終面向的不過是CRUD程式設計,即在Service類中將資料處理好後儲存到資料庫中。拿以上圖程式碼為例進行假設:我們要讓使用者支付的金額包括郵費,如果採用的時貧血模式那我們可以直接在BO中新增一個郵費屬性。但是到修改Service類時,我們就得對先前每一個涉及金額的計算程式碼塊(秒殺節日訂單、正常購買訂單、預約購買訂單)重寫裡面的的金額計算方式,這不僅降低了複用性,後期隨著業務擴充也更容易陷入“牽一髮動全身”的泥沼中。

而採用基於DDD的充血模式時,因為預先定義好了領域模型所包含的屬性和方法,此後的領域模型相當於可複用的業務中間層。當出現新功能需求的開發,都基於之前定義好的這些領域模型來完成。還是新增郵費屬性,我們可以直接在BO類中編寫計算出新增郵費後的實際應付總額,而所涉及金額的Service類基本可以不做任何變更。

領域事件的訂閱/釋出

上一小節已經對貧血模式、充血模式做了舉例說明,下面我將會以充血模式為基礎編寫一個很基礎的下單的業務邏輯。

何為領域事件?

既然被稱之為事件,那事件的發生必定是存在觸發條件的:因為A的產生所以需要變更B。這個概念希望各位能夠切記,以免與後面才講的命令混淆。

釋出領域事件並非任何時候都是必要的,一是在發生重大變更或時才應該釋出領域事件,二是在聚合在被建立時才應該釋出領域事件。

如何判斷是否屬於重大變更,拿以下兩個例子對比:

①餐館選單菜品變更導致食材庫需要更換購買的食材;

②餐館選單價格漲價導致使用者購買時需要多支付金額;

對於①來說選單與食材庫是兩個獨立的聚合,而食材庫無法自動感知選單菜品是否發生變化(你修改選單菜品資料庫資料是無法影響食材庫的資料庫資料),所以在不主動通知的情況下,食材庫就會一直按照原本的食材單購買原材料。因此當選單菜品發生變更,從業務上來說會出現 Menu Change 事件,而 Menu Change 事件發生過程中一定需要建立新的 Menu 物件,此外業務上影響了食材 Ingredient 聚合,所以需要釋出領域事件。

對於②來說選單價格的降漲會同步影響到訂單應支付金額,不需要手動再去提醒訂單“選單漲價了,你也要跟著再計算一次價格哦”,因為對於訂單金額來說只要根據使用者下單的菜品查詢資料庫的價格後求和便可,根本不必時刻關注價格是否變動。

那價格增降什麼時候需要事件釋出呢?各位可以參考京東或淘寶的“降價簡訊提醒”功能。因為商品與簡訊功能時兩個獨立的聚合,簡訊模組在資料層面上無法自動感知商品價格的變動,所以就需要商品價格主動釋出領域事件告訴簡訊模組你要傳送通知簡訊告訴使用者訂閱的商品降價了。從業務上來說就出現了 Commodity Change 事件,而 Commodity Change 的發生才會觸發簡訊的傳送。

綜上,我用了大量的篇幅對何時需要領域事件釋出進行解釋,主要希望各位不要錯以為任何發生變動的情況都要進行釋出,希望各位能夠諒解我的囉嗦。

接下來我將會以“使用者預約時間對商品進行下單支付,當預約時間到了後通知訂單自動建立”為例,編寫一段簡陋的事件訂閱/釋出程式碼讓各位能夠進一步理解事件訂閱/釋出的過程。

具體程式碼

注意:領域事件遵從的是訂閱/釋出而不是釋出/接收。曾經我在閱讀理解這一塊知識的時候很想當然地誤解了字面含義,它們最大的區別是順序問題,前者是先訂閱再發布,而後者是先發布再接收。我並非是為了咬文嚼字,這其中細微的差距希望各位能夠多讀幾遍並結合程式碼才能領悟得到。

編寫事件訂閱程式碼:

public interface DomainEventSubscriber<T> {
   //如何處理事件
    void handleEvent(final T aDomainEvent);
   //訂閱事件型別
    Class<T> subscribedToEventType();
}

編寫事件釋出程式碼:

public class DomainEventPublisher {
    private static final ThreadLocal<DomainEventPublisher> instance = new ThreadLocal<DomainEventPublisher>() {
        @Override
        protected DomainEventPublisher initialValue() {
            return new DomainEventPublisher();
        }
    };

    //做一個判斷是否正在釋出
    private boolean publishing;

    //訂閱方列表
    @SuppressWarnings("rawtypes")
    private List subscribers;

    public static DomainEventPublisher instance() {
        DomainEventPublisher domainEventPublisher = instance.get();
        return domainEventPublisher;
    }

    @SuppressWarnings("rawtypes")
    private List subscribers() {
        return this.subscribers;
    }

    //設定釋出狀態
    private void setPublishing(boolean flag) {
        this.publishing = flag;
    }

    @SuppressWarnings("rawtypes")
    private void setSubscribers(List subscriberList) {
        this.subscribers = subscriberList;
    }

    //檢視當前是否有訂閱集合
    @SuppressWarnings("rawtypes")
    public boolean hasSubscribers() {
        return subscribers() != null;
    }

    //如果當前訂閱集合為空則建立一個新的集合
    @SuppressWarnings("rawtypes")
    private void ensureSubscribersList() {
        if (!this.hasSubscribers()) {
            this.setSubscribers(new ArrayList());
        }
    }

    //如果當前沒有在進行釋出,則進行訂閱集合判斷後將新的訂閱者加入集合列表
    @SuppressWarnings("unchecked")
    public <T> void subscribe(DomainEventSubscriber<T> aSubscriber) {
        if (!this.publishing) {
            this.ensureSubscribersList();
            this.subscribers().add(aSubscriber);
        }
    }

    //此處的<T> 表示傳入引數有泛型,<T>存在的作用,是為了保證引數中能夠出現T這種資料型別
    public <T> void publish(T useDomainEvent) {
        //如果沒有正在釋出訊息同時候訂閱列表不為空
        if (!this.publishing && hasSubscribers()) {
            try {
                this.setPublishing(true);
                //獲取當前正在釋出訊息的領域事件類名
                Class<?> publishClass = useDomainEvent.getClass();
                //獲取當前所有訂閱者
                List<DomainEventSubscriber<T>> allSubscribers = this.subscribers();
                //遍歷所有訂閱者列表
                for (DomainEventSubscriber<T> subscriber : allSubscribers) {
                    //返回對應的領域事件類
                    Class<T> subscribedToType = subscriber.subscribedToEventType();
                    //如果釋出的領域事件型別與訂閱列表的型別匹配上,則將事件交給對應的處理器進行處理
                    if (subscribedToType.toString().equals(publishClass.toString()) ) {
                        subscriber.handleEvent(useDomainEvent);
                    }
                }
            }finally {
                //處理完後告知釋出訊息事件已經結束
                this.setPublishing(false);
            }
        }
    }
}

領域事件一般包括後設資料,例如事件ID和時間戳,為了便於擴充先簡單訂閱領域事件的介面:

//定義領域事件介面
public interface DomainEvent {
    String id();

    Date occurredOn();

    default Date getCreatEventTime(){
        return occurredOn();
    }

    default String type(){
        return getClass().getSimpleName();
    }

    default String getType(){
        return type();
    }
}

根據聚合編寫與聚合相關的事件:

@Data
public abstract class TicketDomainEvent implements DomainEvent{
    //補充到聚合根資訊中
    private String orderId;

    private Date occurredOn;

    @Override
    public String id() {
        return this.orderId;
    }

    @Override
    public Date occurredOn() {
        return this.occurredOn;
    }
}

根據具體業務的需求和上下文環境定製特定的事件:

//在訂單建立時可以根據現實需求補充上下文資訊,如果沒有為空就可以
@Data
public class CustomerTicketCreateEvent extends TicketDomainEvent {
    //基礎訂單資訊
    private Ticket ticket;

    //面向客戶的訂單資訊,額外新增收穫地址
    private String address;

    public CustomerTicketCreateEvent(Ticket ticket, String address){
        this.address = address;
        this.ticket = ticket;
    }
}

充血模式下的物件實體類:

@Data
public class Ticket {
    //實際應支付價格
    private BigDecimal realAmount;
    //優惠
    private BigDecimal discounts;
    private Set<Order> orders;
    //訂單建立時間
    private LocalDateTime orderCreateTime;
    //修改訂單
    private LocalDateTime orderChangeTime;
    //訂單完成時間
    private LocalDateTime finishCreateTime;

    //未優惠情況下應付應付總額
    public BigDecimal getAllAmount(){
        return orders.stream().map(Order::getPrice).reduce(BigDecimal.ZERO,BigDecimal::add);
    }

    //價格優惠後實際應付總額
    public BigDecimal getRealAmount(){
        BigDecimal allAmount = getAllAmount();
        return allAmount.subtract(discounts);
    }
}

我們在這裡做一個業務假設,例如預約購買,一旦某件商品上線,就通知我們自動建立訂單進行支付,模擬Service的虛擬碼:

//模擬MVC三層模型中的Service
public class BuyService {
    //預定服務,定時任務到了,釋出建立訂單的事件
    public void reservation(Ticket ticket){
        Ticket ticket = new Ticket();
        //優惠價格
        ticket.setDiscounts(new BigDecimal(20));
        //收穫地址
        String address = "M78星雲光之國成化大道消防隊對面";

        DomainEventPublisher.instance().publish(new CustomerTicketCreateEvent(ticket,address));
    }
}

訂單自動建立模擬Service的虛擬碼:

public class TicketService {
    public void saveTicket(){
        DomainEventPublisher.instance().subscribe(new DomainEventSubscriber<CustomerTicketCreateEvent>() {

            @Override
            public void handleEvent(CustomerTicketCreateEvent domainEvent) {
                System.out.println("收貨地址是:"+ domainEvent.getAddress());
                System.out.println("goods was purchased");
                //將頂到儲存到資料庫,可以增加與db資料庫的增刪查改操作
                System.out.println("db has save!!");
            }

            @Override
            public Class<CustomerTicketCreateEvent> subscribedToEventType() {
                return CustomerTicketCreateEvent.class;
            }
        });
    }
}

可以看到上面的TicketService類中並未對資料進行更多的邏輯處理,只負責業務邏輯與儲存之間的流程編排,充血模式使得各個層級負責的業務分明。(至於BuyService因為要模擬釋出方,所以就沒有遵從)。

最後還記得我上面說的,先訂閱再發布嗎?以下就很明顯地體現出來:

@SpringBootTest
class DrivenApplicationTests {

    @Test
    void contextLoads() {
        //先註冊訂閱列表,再進行釋出。順序顛倒控制檯顯示為空
        TicketService ticketService = new TicketService();
        ticketService.saveTicket();

        BuyService buyService = new BuyService();
        buyService.reservation();
    }
}

GitHub原始碼:https://github.com/1148973713/Domain-Driven

結語

本博主程式設計水平一般,不能在閒餘時間編寫特別複雜的業務流程向各位一一詳解領域事件釋出/訂閱的流程,我希望通過簡單明瞭的案例讓各位不陷入程式碼理解困難的情況下窺得進入領域驅動設計的門檻,如果有什麼意見或建議希望各位能夠在評論區指出。後續應該會更新Saga事務一致性相關的內容,希望各位持續關注並耐心等待。

相關文章