如何运用领域驱动设计 - 领域事件

开篇

距离发布上一篇该系列的文章好像已经过了快一个半月了,好吧,我托更了??。一晃就已经到了3月份,在这樱花??盛开的季节,终于得重新连载该系列了。在停更的期间时不时会收到大家关于DDD的留言和问题,一旦我有时间一定会回复大家的问题。在此,衷心感谢大家对本系列文章的支持??。

概述

在实践领域驱动设计(DDD)的过程中,我们往往会遇到多个领域对象相互交互的情况。比如聚合根A在执行某操作之前需要得到聚合根B的某个信号(或某些数据)。如果在单体应用程序中,我们有条件和机会使得两者进行强引用来完成操作,但是这将直接打破领域驱动设计的规范,从而使得项目不可控,再次回到大泥球的开发。

现在,咱们可以选取一种更纯净的方式来解决这类问题,并且还能够更清晰的描述领域对象的活动迹象。这就是咱们今天的主题 ———— “领域事件”。那么到底什么是领域事件呢?引入领域事件会为我们已有的DDD项目带来哪些益处?是否一定要使用领域事件呢? 本文将从不同的角度来带大家重新认识一下“领域事件”这个概念,并且给出相应的代码片段(本教程的代码片段都使用的是C#,当然思想是跨越任何编程语言的??)。

什么是领域事件

在原著 《领域驱动设计:软件核心复杂性应对之道》 其实并没有直接提及到关于领域事件的介绍。领域对象是在后期才被作者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》。

最后,偷偷说一句:创作不易,点个推荐吧.....

如何运用领域驱动设计 - 领域事件