深度剖析Byteart Retail案例:領域事件(Domain Events)


在最近的一次代碼簽入中,Byteart Retail已經可以支持領域事件(Domain Events)的定義和處理了。在這篇文章中,我將詳細介紹領域事件機制在Byteart Retail案例中的具體實現。

在進行領域建模的時候,我們就已經知道保證領域模型純凈度的必要性。簡而言之,領域模型中的各個對象都應該是POCO(POJO)對象,而不應向其添加任何與技術架構相關的內容。Udi Dahan曾經說過:“The main assertion being that you do *not* need to inject anything into your domain entities. Not services. Not repositories. Nothing.”。因此,在之前有朋友提出過,是否可以在Domain Model中訪問倉儲?現在看來,答案是否定的。那么Domain Service呢?當然也不行。順便提一下,在當前版本的Byteart Retail中的Domain Service訪問了倉儲,這是一個不太合理的做法,在下個版本中我將進行改進。那么,如果在某些業務需求下,需要訪問這些技術層面的東西,又該怎么辦呢?比如當系統管理員完成銷售訂單的發貨操作時,希望向客戶發送一份電子郵件。此時就要用到領域事件。

領域事件是應用系統中眾多事件的一種分類。企業級應用程序事件大致可以分為三類:系統事件、應用事件和領域事件。領域事件的觸發點在領域模型(Domain Model)中,故以此得名。通過使用領域事件,我們可以實現領域模型對象狀態的異步更新、外部系統接口的委托調用,以及通過事件派發機制實現系統集成。在進行實際業務分析的過程中,如果在通用語言中存在“當a發生時,我們就需要做到b。”這樣的描述,則表明a可以定義成一個領域事件。領域事件的命名一般也就是“產生事件的對象名稱+完成的動作的過去式”的形式,比如:訂單已經發貨的事件(OrderDispatchedEvent)、訂單已被收貨和確認的事件(OrderConfirmedEvent)等。在當前的Byteart Retail案例的源代碼中,就引入了這兩種領域事件。事實上針對該案例而言,還有很多地方可以使用領域事件,比如當客戶地址變更時,可以通過事件處理器來更新所有該事件發生前所有未發貨訂單的客戶收貨地址等。當然,為了簡單起見,案例僅演示了上述兩種事件。

另外,領域事件本身具有自描述性。它不僅能夠表述系統發生了什么事情,而且還能夠描述發生事件的動機。例如AddressChangedEvent可以衍生出兩個派生類:ContactMovedEvent和AddressCorrectedEvent,雖然這兩種事件都會導致地址信息的變更,但它們所表述的動機是不同的:前者體現了地址變更是因為聯系人的地址發生了改變,而后者則體現了地址變更是因為地址信息原本是錯的,現在被更正過來了。

現在,我們開始逐步討論領域事件在Byteart Retail案例中的實現方式。

定義一個領域事件

通常,我們會為領域事件定義一個接口(IDomainEvent接口),所有實現了該接口的類型都被認為是一個領域事件的類型。為了能夠向事件處理器等事件管理機構提供完善的信息,我們可以在這個接口中設置一些屬性,比如事件發生的時間戳、事件來源以及事件的ID值等等,當然這些內容都是根據具體的項目需求而定的。在Byteart Retail案例中,又定義了一個抽象類(DomainEvent類),該類實現了IDomainEvent接口,同時在這個類中提供了一個帶參構造函數,它接受一個代表事件來源(Event Source)的領域實體作為參數,因此,在整個Byteart Retail中約定,所有領域事件類型都繼承於DomainEvent類型,以便強制每個類型都需要提供一個相同參數類型的帶參構造函數。這樣做的好處是,每當開發人員初始化一個領域事件,都必須設置其產生的事件來源,在開發上達成了一種契約,有效地降低了錯誤的產生。

比如,上文所提到的OrderDispatchedEvent定義如下:

/// <summary>
/// 表示當針對某銷售訂單進行發貨時所產生的領域事件。
/// </summary>
public class OrderDispatchedEvent : DomainEvent
{
    #region Ctor
    /// <summary>
    /// 初始化一個新的<c>OrderDispatchedEvent</c>類型的實例。
    /// </summary>
    /// <param name="source">產生領域事件的事件源對象。</param>
    public OrderDispatchedEvent(IEntity source) : base(source) { }
    #endregion

    #region Public Properties
    /// <summary>
    /// 獲取或設置訂單發貨的日期。
    /// </summary>
    public DateTime DispatchedDate { get; set; }
    #endregion
}

在這個事件定義中,構造函數接受一個IEntity類型的參數,以表示產生當前事件的實體對象,此外,它還包含了訂單發貨的日期信息。

領域事件的派發和處理

處理領域事件的機制稱為“事件處理器(Event Handler)”,而領域事件的派發,我們則是通過“事件聚合器(Event Aggregator)”實現的。接下來,我們討論這兩個部分的具體實現過程。

事件處理器(Event Handler)

事件處理器的任務是處理捕獲的事件,它的職責是相對單一的:只需要對傳入的信息進行處理即可。因此,在實現上我們可以將其定義為一個泛型接口,例如在Byteart Retail中,它被定義為IDomainEventHandler<TDomainEvent>接口,TDomainEvent類型參數指定了事件處理器所能夠處理的領域事件的類型。一般情況下,該接口只提供一個Handle方法,該方法接受一個類型為TDomainEvent的對象(即領域事件實例)作為參數。所有實現了該接口的類型都被認為是能夠處理特定類型領域事件的事件處理器。與領域事件的設計相同,在Byteart Retail中,還提供了一個名為DomainEventHandler<TDomainEvent>的泛型抽象類,該類直接實現了IDomainEventHandler<TDomainEvent>接口,同時實現了一個異步事件處理的方法:HandleAsync。同理,為了達成開發規范,在Byteart Retail中,所有領域事件處理器都應該繼承於DomainEventHandler<TDomainEvent>抽象類,並實現其中的抽象方法:Handle方法。由於模板方法模式的支持,開發人員無需考慮異步事件處理的實現(即HandleAsync方法會創建一個用於異步任務處理的Task對象,來執行Handle方法所定義的操作)。

此外,為了簡化編程模型,Byteart Retail還支持基於委托的事件處理器。這個設計其實並不是必須的,但在Byteart Retail中,為了簡化事件訂閱的操作,還是引入了這樣一種基於委托的事件處理器。在某些情況下,事件處理邏輯會比較簡單,比如僅僅是在捕獲到某個事件時更新領域對象的狀態,那么對於這樣一些應用場景,開發人員就無需為每一個相對簡單的事件處理邏輯定義一個單獨的事件處理器類型,而只需要讓委托的匿名方法來訂閱和處理事件即可,這樣做不僅簡潔而且便於單體測試。有關事件處理器如何去訂閱領域事件,我們將在下一小節“事件聚合器”中討論。還是先讓我們來看看Byteart Retail中是如何實現這種基於委托的事件處理器的。

在Byteart Retail中,有一個特殊的領域事件處理器,它與其它領域事件處理器一樣,也繼承於DomainEventHandler<TDomainEvent>泛型抽象類,但它的特殊性在於,它會在構造函數中接受一個Action<TDomainEvent>類型的委托作為參數,於是,通過一種類似裝飾器模式的方式,將Action<TDomainEvent>委托“裝飾”成DomainEventHandler<TDomainEvent>類型的對象:

/// <summary>
/// 表示代理給定的領域事件處理委托的領域事件處理器。
/// </summary>
/// <typeparam name="TEvent"></typeparam>
internal sealed class ActionDelegatedDomainEventHandler<TEvent> : DomainEventHandler<TEvent>
    where TEvent : class, IDomainEvent
{
    #region Private Fields
    private readonly Action<TEvent> eventHandlerDelegate;
    #endregion

    #region Ctor
    /// <summary>
    /// 初始化一個新的<c>ActionDelegatedDomainEventHandler{TEvent}</c>實例。
    /// </summary>
    /// <param name="eventHandlerDelegate">用於當前領域事件處理器所代理的事件處理委托。</param>
    public ActionDelegatedDomainEventHandler(Action<TEvent> eventHandlerDelegate)
    {
        this.eventHandlerDelegate = eventHandlerDelegate;
    }
    #endregion
    
    // 其它函數和屬性暫時忽略
}
    

在此類中Handle方法的實現就非常簡單了:

/// <summary>
/// 處理給定的事件。
/// </summary>
/// <param name="evnt">需要處理的事件。</param>
public override void Handle(TEvent evnt)
{
    this.eventHandlerDelegate(evnt);
}

這種做法的優點是,可以將基於委托的事件處理器當成是普通的事件處理器類型,從而統一了事件訂閱和事件派發的接口定義。

需要注意的是,對於ActionDelegatedDomainEventHandler而言,實例之間的相等性並不是由實例本身決定的,而是由其所代理的委托決定的,這對於事件處理器對事件的訂閱,以及事件聚合器對事件的派發,都有着重要的影響。根據這個分析,我們就需要重載Equals方法,使用Delegate.Equals方法來判定兩個委托的相等性。在Byteart Retail中,IDomainEventHandler<TDomainEvent>接口還實現了IEquatable接口,因此,只需要重載IEquatable接口中定義的Equals方法即可:

/// <summary>
/// 獲取一個<see cref="Boolean"/>值,該值表示當前對象是否與給定的類型相同的另一對象相等。
/// </summary>
/// <param name="other">需要比較的與當前對象類型相同的另一對象。</param>
/// <returns>如果兩者相等,則返回true,否則返回false。</returns>
public override bool Equals(IDomainEventHandler<TEvent> other)
{
    if (ReferenceEquals(this, other))
        return true;
    if ((object)other == (object)null)
        return false;
    ActionDelegatedDomainEventHandler<TEvent> otherDelegate = 
        other as ActionDelegatedDomainEventHandler<TEvent>;
    if ((object)otherDelegate == (object)null)
        return false;
    // 使用Delegate.Equals方法判定兩個委托是否是代理的同一方法。
    return Delegate.Equals(this.eventHandlerDelegate, otherDelegate.eventHandlerDelegate);
}

現在我們已經定義好了事件處理器接口以及相關的類,同時也根據需要實現了幾個簡單的事件處理器(具體代碼請參考Byteart Retail案例中ByteartRetail.Domain.Events.Handlers命名空間下的類)。接下來我們要讓領域模型能夠在業務需要的地方觸發領域事件,並讓這些事件處理器能夠對獲得的事件進行處理。在Byteart Retail案例中,這部分內容是使用“事件聚合器”實現的。

事件聚合器(Event Aggregator)

事件聚合器是一種企業應用架構模式,其作用主要是聚合領域模型中的事件處理器,以便事件在觸發的時候,被聚合的事件處理器能夠對事件進行處理。在Byteart Retail中,事件聚合器的結構如下:

image

在這個設計中,事件聚合器提供了三種接口:Publish、Subscribe和Unsubscribe。Subscribe接口的主要作用是,向事件聚合器注冊指定類型事件的處理器,那么對於事件處理器而言,它就是在偵聽(訂閱)某個事件的發生;而Unsubscribe的作用則正好相反:它會解除某個事件處理器對指定類型事件的偵聽,也就是當事件被觸發時,不再偵聽該事件的事件處理器將不會執行處理任務;至於Publish接口就非常簡單了:領域模型使用Publish接口直接向事件聚合器派發事件,事件聚合器在觀察到事件發生時,將處理權轉交給偵聽了該事件的處理器。事件聚合器的引入,使得事件能夠被一次派發,多處處理,為應用程序的領域事件處理架構提供了擴展性的同時,也簡化了事件訂閱過程。

在Byteart Retail中,事件聚合器是一個靜態類,之所以不設計成實例類,是因為我們無法將其以任何形式注射到領域模型中,更不可能讓領域對象提供一個參數為EventAggregator類型的構造函數。這一點與保持領域模型的純凈度有關。Event Aggregator的具體實現代碼,請參考ByteartRetail.Domain.Events命名空間下的DomainEventAggregator類。接下來,我們將領域事件的產生、訂閱、派發和處理的過程總結一下。

領域事件的訂閱、派發和處理

首先,在領域模型參與業務邏輯之前,應用程序架構需要對所需處理的領域事件進行訂閱。回顧一下,面向DDD的經典分層架構中,應用層的職責是協調各組件(比如事務、倉儲、領域模型等)的任務執行,因此領域事件的訂閱也應該在應用層服務被初始化的時候進行。具體到Byteart Retail案例中,就是在應用服務(Application Service)的構造函數中進行。

以OrderServiceImpl類型(該類型位於ByteartRetail.Application.Implementation命名空間下)為例,在構造函數中我們擴展了一個參數:一個IDomainEventHandler<OrderDispatchedEvent>類型的數組,進而在構造函數中,通過使用DomainEventAggregator類,對傳入的事件處理器進行訂閱操作:

public OrderServiceImpl(IRepositoryContext context,
    IShoppingCartRepository shoppingCartRepository,
    IShoppingCartItemRepository shoppingCartItemRepository,
    IProductRepository productRepository,
    IUserRepository customerRepository,
    ISalesOrderRepository salesOrderRepository,
    IDomainService domainService,
    IDomainEventHandler<OrderDispatchedEvent>[] orderDispatchedDomainEventHandlers)
    :base(context)
{
    this.shoppingCartRepository = shoppingCartRepository;
    this.shoppingCartItemRepository = shoppingCartItemRepository;
    this.productRepository = productRepository;
    this.userRepository = customerRepository;
    this.salesOrderRepository = salesOrderRepository;
    this.domainService = domainService;
    this.orderDispatchedDomainEventHandlers.AddRange(orderDispatchedDomainEventHandlers);

    foreach (var handler in this.orderDispatchedDomainEventHandlers)
        DomainEventAggregator.Subscribe<OrderDispatchedEvent>(handler);
    DomainEventAggregator.Subscribe<OrderConfirmedEvent>(orderConfirmedEventHandlerAction);
    DomainEventAggregator.Subscribe<OrderConfirmedEvent>(orderConfirmedEventHandlerAction2);
}

構造函數中最后兩行是對與OrderConfirmedEvent相關的事件處理委托進行訂閱,以演示基於委托的事件處理器的實現方式。這兩個委托在OrderServiceImpl類型中,以只讀字段(readonly field)的形式進行定義:

private readonly Action<OrderConfirmedEvent> orderConfirmedEventHandlerAction = e =>
    {
        SalesOrder salesOrder = e.Source as SalesOrder;
        salesOrder.DateDelivered = e.ConfirmedDate;
        salesOrder.Status = SalesOrderStatus.Delivered;
    };

private readonly Action<OrderConfirmedEvent> orderConfirmedEventHandlerAction2 = _ =>
    {
        
    };

orderConfirmedEventHandlerAction2的定義無非也就是一個演示而已(演示接下來要討論的事件處理器退訂),因此我也沒有在這個匿名方法里填寫任何處理邏輯。至於構造函數的IDomainEventHandler<OrderDispatchedEvent>數組參數,則是通過Unity注入的,修改一下服務端的web.config文件即可:

SNAGHTMLbb5949e

接下來,在應用層完成操作后,需要解除事件處理器對事件的訂閱(即退訂),為了實現這個功能,我修改了IApplicationServiceContract的接口定義,並讓ApplicationService類繼承於DisposableObject類,之后,在WCF服務上,設置其InstanceContextMode為PerSession,也就是每當WCF客戶端建立一次與服務端的連接時,創建一次服務實例,而當客戶端關閉並撤銷連接時,銷毀服務實例。於是,在完成了這些結構調整后,每當一次WCF會話完成后,ApplicationService的Dispose方法就會被調用。那么每個應用層服務的具體實現(OrderServiceImpl、ProductServiceImpl、UserServiceImpl、PostbackServiceImpl)只需根據自己的需要重載Dispose方法,即可在Dispose方法中解除事件處理器對事件的訂閱:

protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        foreach (var handler in this.orderDispatchedDomainEventHandlers)
            DomainEventAggregator.Unsubscribe<OrderDispatchedEvent>(handler);
        DomainEventAggregator.Unsubscribe<OrderConfirmedEvent>(orderConfirmedEventHandlerAction);
        DomainEventAggregator.Unsubscribe<OrderConfirmedEvent>(orderConfirmedEventHandlerAction2);
    }
}

最后,領域事件的觸發就非常簡單了:直接調用DomainEventAggregator.Publish即可。整個過程大致可以用下面的序列圖描述:

image

至此,我們已經大致了解了Byteart Retail案例中領域事件部分的設計與實現,回顧一下,這些內容包括:領域事件的定義、事件處理器、事件聚合器,以及這些組件之間的相互協作關系。讀者朋友如果能夠仔細閱讀本案例的源代碼,相信還能了解到更多深層次的細節問題。然而,事情還沒有結束,我們還需要把討論范圍擴大到一個更高的層次:應用事件(Application Event)。雖然它已經超出領域事件的范圍,但我還是要在本文中對其進行介紹,因為這個概念很容易造成開發人員對事件類別的混淆。

還有什么問題嗎?

在本文最開始的時候提出了一個簡單的應用場景:“當系統管理員完成銷售訂單的發貨操作時,希望向客戶發送一份電子郵件”,這種需求是最常見不過的了。雖然“完成銷售訂單的發貨”被定義成一個領域事件(事實上它也就是一個領域事件),但處理電子郵件發送的邏輯,卻並不是領域事件處理器的任務。通過分析不難得知,領域事件處理器對領域事件的處理,在於整個事務被提交之前。領域事件處理器可以以一種更為復雜的方式來獲取或設置領域對象的狀態,但對於與事務相關的事件處理過程,領域事件處理器就不是一個很好的選擇。試想,如果在領域事件處理器中將電子郵件發送出去了,而接下來的事務提交卻失敗了,於是就造成了客戶所收到的訂單狀態與實際狀態不符的情形。

正確的做法應該是,在領域事件被觸發時,將其記錄下來,當執行事務提交時,將已記錄的領域事件轉換成應用事件,並派發到事件總線。這個派發過程可以是同步的,也可以是異步的。接下來的電子郵件發送邏輯就由偵聽該事件總線的事件處理器負責執行。這里牽涉到一個分布式事務處理的問題。對於“發送電子郵件”這樣的功能,我想,對分布式事務處理的要求應該也沒有那么明顯:數據庫事務提交成功后,直接讓基礎結構層組件發送電子郵件就可以了,如果發送電子郵件失敗,也完全無需回滾數據庫事務。大不了客戶抱怨說沒有收到郵件,系統管理員通過事件日志對發送郵件的功能進行排錯即可。但對於某些應用事件,比如客戶訂房成功后,系統就會將訂房成功的事件發送到支付系統,支付系統在多次嘗試付款失敗后,就需要完成房間退訂邏輯,以防止房間被無限制占用,在這些場景下,分布式事務處理就有着一定的必須性(當然你也可以說讓支付系統無限制地重試,或者說找Sales Rep進行7x24的跟蹤排錯來解決事務問題,但我們暫時先不考慮這些解決方案)。

Byteart Retail考慮了這些問題存在的可能性,在事件系統和倉儲部分大致進行了以下改動:

  1. 引入事件總線系統(IBus接口),應用事件處理器可以偵聽該接口來接收需要處理的應用事件;應用層同樣可以使用該接口來派發應用事件
  2. 實現了一個面向Event Dispatcher的事件總線,通過使用Event Dispatcher,Byteart Retail的事件總線可以支持Sequential、Parallel以及ParallelNoWait三種不同的事件派發方式(詳見代碼中的注釋內容)
  3. 更改了AggregateRoot抽象類的實現,引入了存儲領域事件的部分
  4. 更改了RepositoryContext抽象類的實現,在Commit方法中,不僅執行了倉儲本身的提交事務(新的DoCommit方法),而且還會將存儲在聚合根中的領域事件派發到事件總線。事件總線定義了其本身是否支持分布式事務處理,RepositoryContext會根據這個設置來決定是否需要啟用Distributed Transaction Coordinator(不過貌似Message Queue的解決方案中,也只有MSMQ能夠支持MS DTC)

詳細的實現部分,我就不在這里一一敘述了,請讀者朋友們自己閱讀本案例的源代碼,尤其是ByteartRetail.Events和ByteartRetail.Events.Handlers命名空間下的類型代碼。

執行效果

本文最后,就讓我們一起看一下領域事件部分的執行效果。以系統管理員發貨為例,按理系統會產生一個OrderDispatchedEvent領域事件,領域模型通過領域事件處理器更新訂單的發貨日期和狀態,與此同時,會將產生的領域事件暫存在聚合根中。當訂單更新被提交時,被保存的領域事件將被派發到事件總線,進而郵件發送處理器會捕獲到這個事件並發送郵件給客戶。

首先,啟動Byteart Retail的WCF服務和ASP.NET MVC應用程序,用daxnet/daxnet賬戶登錄,並在賬戶設置中確保該賬戶的電子郵件地址設置正確。然后,使用該賬戶在系統中任意購買一件商品,完成下單后,退出系統,並用admin/admin賬戶登錄,在“管理”->“銷售訂單管理”頁面中,找到剛剛收到的訂單,並點擊“發貨”按鈕進行發貨處理:

image

在成功完成發貨操作后,可以看到該訂單的發貨日期和當前狀態會隨之改變:

image

檢查daxnet賬戶的郵箱,發現我們已經收到了一封“訂單已發貨”的通知郵件(當然為了演示的目的,該通知郵件相對比較簡單,開發人員可以根據自己的實際情況,豐富郵件的內容,以達到實際項目的需要):

image

總結

本文針對Byteart Retail案例,給出了一個較為可行的領域事件框架的設計方案。文中介紹了與事件相關的各類組件及其實現方式,並探討了在實現過程中遇到的現實問題(比如分布式事務處理)。在文章的最后,我們回到了Byteart Retail案例,演示了領域事件所產生的界面效果。其實,事件是一個非常復雜的,卻又非常重要的系統架構組成元素,僅事件的派發和處理部分,就能牽涉到非常多的技術要點,比如:事件處理工作流、異步派發、並行處理、消息路由等等。Byteart Retail作為一個案例程序,無法涵蓋這些技術的方方面面,這就需要開發人員集思廣益,根據自己項目的實際情況進行分析,總結出一套更為合理的(或者說是更適合自己項目的)設計方案。

關於Byteart Retail源代碼

我在上文中簡單地提到過,我的基於.NET的領域驅動設計框架(Apworks)和Byteart Retail案例源代碼都被搬到了GitHub。以下是Byteart Retail案例的源代碼主頁地址:

https://github.com/daxnet/ByteartRetail

使用Git的朋友,可以使用以下命令直接將代碼克隆到本地:

git clone https://github.com/daxnet/ByteartRetail.git

最后感謝大家對我的博客、Apworks以及Byteart Retail案例項目的支持。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM