開篇

距離發佈上一篇該系列的文章好像已經過了快一個半月了,好吧,我託更了:sob:。一晃就已經到了3月份,在這櫻花:cherry_blossom:盛開的季節,終於得重新連載該系列了。在停更的期間時不時會收到大家關於DDD的留言和問題,一旦我有時間一定會回覆大家的問題。在此,衷心感謝大家對本系列文章的支持:smile:。

概述

在實踐領域驅動設計(DDD)的過程中,我們往往會遇到多個領域對象相互交互的情況。比如聚合根A在執行某操作之前需要得到聚合根B的某個信號(或某些數據)。如果在單體應用程序中,我們有條件和機會使得兩者進行強引用來完成操作,但是這將直接打破領域驅動設計的規範,從而使得項目不可控,再次回到 大泥球 的開發。

現在,咱們可以選取一種更純淨的方式來解決這類問題,並且還能夠更清晰的描述領域對象的活動跡象。這就是咱們今天的主題 ———— “領域事件” 。那麼到底什麼是領域事件呢?引入領域事件會爲我們已有的DDD項目帶來哪些益處?是否一定要使用領域事件呢? 本文將從不同的角度來帶大家重新認識一下“領域事件”這個概念,並且給出相應的代碼片段( 本教程的代碼片段都使用的是 C# ,當然思想是跨越任何編程語言的 :grinning:)。

什麼是領域事件

在原著 《領域驅動設計:軟件核心複雜性應對之道》 其實並沒有直接提及到關於領域事件的介紹。領域對象是在後期才被作者 Evans 提出,經過 Udi Dahan (Nservicebus作者)和 Jimmy Bogard (MetdiaR、AutoMapper作者)等專家後期的不斷實踐和演變纔有了今天的 領域事件 版本。

此處我摘錄了 《實現領域驅動設計》 書中對領域事件的描述:

領域專家所關心的發生在領域中的一些事件。

將領域中所發生的活動建模成一系列的離散事件。每個事件都用領域對象來表示,領域事件是領域模型的組成部分,表示領域中所發生的事情。

如何使用領域事件

當您一看到“事件”這個詞語的時候,您可能會一下聯繫到 C# 中的事件,那個基於委託的事件。 確實,它們之間有着共性,就比如:“當事件發生的時候,與該事件相關聯的對象都將受到波及。” 所以,如果您瞭解C#中的事件,那將幫助您更好的理解“領域事件”。

由此我們可以推導出:在領域驅動設計建模過程中, 如果發現有一項動作發生了之後,與之關聯的其他領域對象將會受到波及。 那麼該動作可能就是“領域事件”。

光從概念上來講些許有些讓人頭暈,我們來看看實際的一個例子:“當用戶將商品添加到購物車的時候,下方的推薦商品將爲他推薦同類型的商品”。 這是一個有前後發生關係的典型案例,商品被添加到了購物車就會引發推薦同類商品。 所以咱們仔細來感受一下這一個過程,抓一抓裏面的關鍵詞。“商品加入購物車” 就會導致 “推薦同類商品”。是不是和咱們上面那一段的描述有些類似了? 所以仔細觀察之後,我們可以捕獲出一個領域對象來,該對象您可能將它命名爲(ProductAddedEvent)。

爲什麼我們要將它命名爲過去時呢? 這也是印證了開頭那句話“動作發生了之後”。當該事件被捕獲了之後,就會將事件信息傳遞給“推薦商品”聚合根,執行相應處理邏輯。

那麼事件的來源是哪裏呢?“用戶點擊”,“網頁響應” 這些都不是哦! 記住,我們要深刻關心領域對象,剛纔所說的情況顯然與咱們的領域對象一點兒關係也沒有。所以我們可以很自然的將目光轉向到“購物車”,“購物車”可能就是一個聚合根,它會有一個叫做“添加商品”的行爲,當該行爲完成之後就會引發一個“商品添加完成”的事件。

經過整理之後我們可能會得到一個這樣的流程:

所以您會發現,領域事件一方面充當了描述領域信息的作用,一方面承接了不同聚合根之間的交互。 當然事件不一定只有一個,被影響的領域對象也不一定只有一個。就好比“推薦商品”受到了“商品添加完成”事件之後,它自己也能產生一個另外的領域事件傳遞給下游。

思維的轉換

到這裏您或許會感到使用領域事件和以往咱們捕獲其他對象不太一樣,比如捕獲值對象、實體等。因爲對於領域事件來說,它可能是“隱式”,我們沒有直觀的感受它的存在。

所以,請仔細的考慮這一點: 當您要使用領域事件時,您將認同您的項目需要以事件作爲中心 。 而項目中的各個領域對象都將以產生、發佈領域事件完成一系列的交互流程。

這裏我摘錄了 《領域驅動設計模式、原理與實踐》 中的一段話分享給大家:“領域事件將會在領域專家一起進行的知識提煉環節中揭示出來。揭示領域事件是如此有價值,DDD實踐者都擁有創新的知識提煉技術來進行實踐以便讓其更專注於事件,比如事件風暴。不過,使用這些創新技術會帶來新的挑戰。既然概念化的模型都是以事件爲中心的,那麼代碼也需要以事件爲中心,以便它能夠表述概念化模型。這就是領域事件設計模式所帶來的價值。”

所以在大多數時候您將感受到項目逐漸具有 EDA(事件驅動架構)的風格。而此時,您可能會聯想到DDD中的另外一種模式:事件溯源(EventSource),認爲自己必須要採用事件溯源來建立您的ddd項目。其實這並不是一定的,採用領域事件和使用事件溯源是沒有直接關係的,雖然領域事件會幫助事件溯源完成的更好。

捕獲領域事件

結合上面的介紹,您可能已經對發現領域事件有一點感覺了。當聚合與聚合之間具有交互關係時,我們往往會發現他們之間會存在某個領域事件來引發這系列行爲。

如果與領域專家交談時,發現了這樣的關鍵詞彙: “當………………”、“如果A完成之後,那麼…………”,“發生…………的時候”。 這些詞彙可能在隱式的告訴您,該處也許存在着“領域事件”對象。

內部事件 and 外部事件

在使用領域事件之前,我們必須要知道事件其實被劃分成了:“內部”和“外部”。 就正如它的描述一樣,內部的領域事件發生在邊界之內,而外部的事件發生在邊界之外(比如微服務A產生了一個事件,而微服務B會受到該事件的影響)。

在Microsoft關於ESHOP案例的指導書籍 《.NET 微服務 - 體系結構》 中,將其命名爲“領域事件和集成事件”:

該圖也形象的說明了基於一個邊界內的內部事件是如何交互的:

外部的事件往往需要一些基礎結構來實現遠程服務之間的進程間和分佈式通信,比如rabbitMQ,kafka等。本篇文章重點講解內容爲內部的領域事件,關於外部的事件將會在後期《分佈式中的領域驅動設計》系列中爲大家介紹。

可選 Or 必須

那麼是否我的DDD項目就必須使用“領域事件”呢? 也許您在網上從來沒有見到過這樣的問題,因此也沒有該問題的確切性答案。關於該問題,我個人覺得答案是“不一定”。

就像上文說的一樣,如果您開始使用領域事件,那麼就證明您的項目和思維將轉換爲“以事件作爲中心”。領域中大部分的交互都將以事件的方式來呈現。所以與其考慮“我的DDD項目就必須使用“領域事件””這個問題,還不如轉換爲:“我是否需要用事件作爲中心來考慮問題?”。

所以,該問題的答案就取決於您自己了。這也是爲什麼您會在某些DDD框架或者DDD項目中沒有發現“領域事件”的原因之一。

那麼,如果不使用事件來建模,聚合與聚合之間是如何進行交互的呢? 請看下文↓。

領域事件 VS 領域服務

我利用搜索引擎進行了大量的查找,沒有發現任何關於“領域事件” 和 “領域服務”之間的對比內容。但是我認爲這兩者卻有着很多相似的地方。 當 Evans 在初次提出領域驅動的概念時,是沒有考慮領域事件的,那麼也就意味着我們能夠通過原有的領域對象完成領域建模和業務流程。

回到剛纔那個問題,聚合與聚合之間只能通過事件完成操作嗎? 不一定。“領域服務”也承擔着領域對象與領域對象轉換的功能。

先回顧一下咱們在領域服務章節瞭解到的部分內容:

當我們發現一個操作無法賦予一個實體或者值對象,且該操作又對業務流程很重要時,我們往往需要使用領域服務

通過A和B,得到一個C。

A需要一個繁瑣的內部策略才能得到一個結果B。 (ps: A,B,C指的是領域對象中的值對象或者實體)

所以這也意味着,領域服務內部可以對多個領域對象(比如聚合根)進行操作。所以某些DDD框架將領域服務作爲完成流程操作的主要工具,允許使用者在領域服務中注入多個倉儲,從而對多個聚合根進行操作。

而“領域事件”呢,它通過發佈領域事件來達到不同領域對象的交互。

那麼到底應該使用“領域服務”還是“領域事件”呢? 先回答自己是否需要引入事件模型。如果“是”,那麼請優先考慮使用領域事件。

這是很容易讓人頭暈的兩個對象,下面我將用兩句話讓您感受他們的使用場景:

A:快遞在入庫時需要進行規格檢查,比如是否超重等

該場景,我們除了引入“快遞”這一聚合根之外,沒有引入其他領域對象。那麼此處的“檢查”操作,該行爲應該交給誰呢? 給“快遞”? 快遞自己檢查自己? 顯然不對,所以當某行爲不屬於一個實體或者值對象時,我們就需要引入一個領域服務了。

B:當快遞被投遞到營業點時,證明快遞已經到達,配送員將打電話給用戶進行派送。

該場景中,我們已經發現了有“快遞”、“營業點”、“快遞員”等領域對象,如果要完成一個“快遞到達”的用例,我們會如何操作呢? 調用"營業點"的“收納進快遞”,並且接下來是調用“快遞員”的“配送快遞”。 此處涉及到多個聚合根之間的交互,那麼是選用領域服務還是領域事件呢? 如果您基於事件建模,可以採用領域事件,反之,您可以使用領域服務。

如果您開始嘗試DDD項目,我建議您優先採用事件建模的方式。也就是說,考慮採用領域事件。將聚合根與聚合根之間的交互動作通過領域事件來傳達,而將領域對象的策略運算交由領域服務完成。更清晰的劃分它倆之間的職責。

實踐方案

實踐方案主要採用了 Jimmy Bogard 所提出的領域事件實現方案。聚合根中保持領域事件的集合,通過事件分配器將事件分配給對應的處理事件。

因此我們可以先建立幾個接口: IDomainEvent(表明該類爲領域事件)、IDomainEventHandler(用於攔截處理領域事件)、IEventDispatcher(事件分配器,將領域事件分發給處理程序)。

public interface IDomainEvent
{
}

public interface IDomainEventHandler<in TDomainEvent>
        where TDomainEvent : IDomainEvent
{
    Task HandleAysnc(TDomainEvent domainEvent, CancellationToken cancellationToken = default);
}

public interface IEventDispatcher
{
    Task DispatchAsync<TDomainEvent>(
        TDomainEvent domainEvent,
        CancellationToken cancellationToken = default) where TDomainEvent :IDomainEvent;
}

然後還需要給聚合根添加上一些方法,便於它能夠保留領域事件在實例中:

public abstract class AggregateRoot<TKey>
{
    public virtual TKey Id { get; set; }

    protected List<IDomainEvent> _domainEvents = new List<IDomainEvent>();

    public virtual void AddDomainEvent(IDomainEvent domainEvent)
        => _domainEvents.Add(domainEvent);

    public virtual void RemoveDomainEvent(IDomainEvent domainEvent)
        => _domainEvents.Remove(domainEvent);

    public List<IDomainEvent> GetDomainEvents()
        => _domainEvents;
}

最後,在倉儲進行持久化之前,通過事件分發器將保持在聚合根實例上的領域事件分發給對應的事件處理程序:

// EF Core DbContext
public class OrderingContext : DbContext
{
    public async Task<bool> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        //Get aggregateRoot
        var aggregateRoots = dbContext.ChangeTracker.Entries().ToList();
        // Dispatch Domain Events collection.
        await _eventDispatcher.DispatchAsync(aggregateRoots,cancellationToken);

        // After this line runs, all the changes (from the Command Handler and Domain
        // event handlers) performed through the DbContext will be committed
        var result = await base.SaveChangesAsync();
    }
}

由於篇幅有限,上面的實現方案只是給了大家一個思路,所以缺少了一些實現,如果您有需要可以聯繫我,我提取一個小Demo上傳至Github。

關於另外的實現方案,您可以查看 微軟Eshop教程

爲什麼選取領域事件

爲什麼我會建議您優先考慮使用領域事件呢? 爲了後期能夠更容易的拆解項目爲微服務。 假如咱們都是將聚合根之間的交互通過領域服務來完成,比如現在有一個領域服務A,它需要幫助聚合根A和聚合根B完成操作:

public class DomainServiceA
{
    DomainServiceA(IRepositoryA repositoryA,IRepositoryB repositoryB);
}

在該領域服務中,以來了聚合根A、B的存儲庫。現在A和B位於同一個服務中,這可以很好的運行。但是如果有一天,B需要被獨立出去,單獨成爲一個服務怎麼辦呢? 該領域服務不得不進行更改。

而加入我們通過領域事件來進行流轉,當聚合B被拆分出去之後,假如B需要A發佈的某個事件,那麼B只需要在自己的項目中添加一個該事件的類型就可以了,而不需要修改其他邏輯。(也許需要將內部事件轉換爲外部事件,但是核心業務代碼是不會更改的)。

所以構建項目初期,我們在選型時要進行長遠的考慮。

總結

本次我們介紹了領域驅動設計中的 領域事件 。“如果捕獲領域事件?”,“DDD是否一定需要領域事件?”相信這些問題,看到這裏您心裏已經有了自己的答案。

領域事件能夠幫助我們更好的描述領域中各個對象之間的狀態,就如同本文剛開始所提及到的觀點:“如果發現有一項動作發生了之後,與之關聯的其他領域對象將會受到波及。” 將這些提取建模爲領域事件,將對您的項目帶來很好的收益。

感覺每次講這個系列就比較嚴肅,如果您更喜歡輕鬆一些的內容可以關注我的另外一個系列《五分鐘的.NET》。

最後,偷偷說一句:創作不易,點個推薦吧.....

相關文章