深度剖析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案例项目的支持。

posted @ 2012-12-27 20:19  dax.net  阅读(13872)  评论(29编辑  收藏  举报