《实现领域驱动设计》 (美)弗农著 8章 领域事件

何时、为什么使用领域事件

领域专家所关心的发生在领域中的一些事件。
将领域中所发生的活动建模成一系列的离散事件。每个事件都用领域对象来表领域事件是领域模型的组成部分,表示领域中所发生的事情。

有时,从领域专家的话中,我们看不出领域事件的迹象,但是业务需求依然有可能需要领域事件。领域专家有可能意识不到这些需求,只有在跨团队讨论之后他们才能意识到这些。发生这样的事情往往是由于领域事件需要发布到外部系统中,比如发布到另一个限界上下文中(2)。由于这样的事件由订阅方处理,它将对本地和远程上下文产生深远的影响。

当领域事件到达目的地之后无论是本地系统还是外部系统———我们通常都将领域事件用于维护事件的一致性。这是有意而为之的,并且是根据设计而来的。这样可以消除两阶段提交(全局事务),还可以支持聚合(10)原则。聚合的其中一个原则是,在单个事务中,只允许对一个聚合实例进行修改,由此产生的其他改变必须在单独的事务中完成。因此,本地限界上下文中的其他聚合实例便可以通过。领域事件的方式予以同步。另外,领域事件还可以用于使远程依赖系统与本地系统保持一致。本地系统和远程系统的解耦有助于提高双方协作服务的可伸缩性。

图8.1向我们展示了领域事件的产生、存储、分发和使用。领域事件既可以由本地限界上下文所消费,也可以由外部的限界上下文消费。
在这里插入图片描述

建模领域事件

让我们看看敏捷项目管理上下文中的一条需求:

允许将每一个待定项提交到冲刺中。只有在待定项位于发布计划中时,才能进行提交。如果待定项已经提交到了另外的冲刺中,必须先将其回收才能进行新的提交。提交待定项时,通知对应的冲刺和相关兴趣方。

在建模领域事件时,我们应该根据限界上下文中的通用语言来命名事件及其属性。如果事件由聚合上的命令操作产生,那么我们通常根据该操作方法的名字来命名领域事件。对于上面的例子,当我们向一个冲刺提交待定项时,我们将发布与之对应的领域事件:

命令方法:BacklogItem#commitTo(Sprint aS print)
事件输出:BacklogItemCommitted

事件的名字表明了聚合上的命令方法在执行成功之后所发生的事情:“待定项提交完毕。”当然,我们还可以创建更详细的事件名字,比如Backlog Item Committed To Sprint。但是, 在Scrum的通用语言中, 待定项只能提交到冲刺中。换句话说,待定项是不能提交到发布中的。因此,使用原先的Backlog Item Committed已经足够了, 并且更加简捷。如果你倾向件命名,也是可以的,这只是一个选择问题。

在聚合发布事件时,请注意我们应该使事件的名字反映过在有了正确的事件的名字反映过去发生的事情,即该事件并不是当前发生的,而是先前发生的。

在有了正确的事件名后,我们还需要什么样的事件属性呢?首先,我们需要一个时间戳来表示事件发生的时间,在Java中,可以使用java.util.data类来表示。
在这里插入图片描述所有的领域事件都将实现Domain Event接口, 该接口定义了一个occurred On()方法:

在这里插入图片描述
接下来,团队成员还需要考虑其他有意义的属性。考虑一下,是谁导致了领域事件的产生。这通常包括产生该领域事件的聚合和其他参与操作的聚合,也有可能是其他任何类型的数据属性。

分析之后,我们得到以下BacklogItemCommitted事件:
在这里插入图片描述团队成员认为BacklogItem和Sprint的身份标识对于BacklogItemCommitted事件来说是最关键的。BacklogItem
发起方, 而Sprint则是事件的参与方。当然,他们还讨论了更多的话题。该需求特别指出,当BacklogItem被提交到Sprint之后,该Sprint应该得到通知。因此,位于同一个限界上下文中的事件订阅方应该及时地通知sprint,但前提条件是BacklogItemCommitted事件中存在SprintId。

此外,在一个多租户环境中.记录Tenamld也是有必要的,虽然Tenamld不会作为参数传给命令方法.但是它却是本地和远程限界上下文所必需的。在本地上下文中,我们需要 Tenantld来查询Backlogltem和Sprint。同样,在远程上下文中,我们需要Tenantld来查出领域事件的作用对象。

在这里插入图片描述在这里插入图片描述团队成员意识到,这种方式还存在一 个小问题。Sprim如何处理更新事务呢?我 们可以让消息处理器来处理事务。但是,无 论如何我们都需要相应地重构代码。最好的 方式是将事务处理委派给应用服务(14),
这是一种很自然的选择,同时这种方式能够很好地融入六边形架构(4)中。如此一来,代码将 变成:
在这里插入图片描述创建具有聚合特征的领域事件
有时,领域事件并不由聚合中的命令方法产生,而是直接由客户方所发出的请 求产生。此时,领域事件可以建模成一个聚合,并且可以拥有自己的资源库。但是,又 由于领域事件表示的是发生在过去的事情,因此资源库是不能对事件进行删除的。

和聚合一样,由这种方式所创建的事件应该成为模型结构的一部分。因此,它 们不再仅仅表示过去发生的事情。

此时的领域事件依然应该设计成不变的,但是它们将拥有唯一标识。对于领 域事件而言,我们可以使用事件属性来表示唯一标识。然而,即便事件的唯一标识 可以由一组属性来决定,最好的方式还是釆用生成的唯一标识,请参考实体(5)。 这样,如果设计有变化,我们依然可以保证事件的唯一性。

由这种方式所创建的事件可以通过消息设施进行分发,同时又可以将其添加 到资源库中。客户方可以通过调用领域服务(7)来创建事件,然后将其添加到资 源库中,再通过消息设施进行发布。在这种情况下,资源库和消息设施必须使用相
同的持久化实例(数据源),或者使用全局事务(即XA和两阶段提交),以此来保 证对事件的成功提交。

在消息设施成功存储事件之后,它将异步地将事件发送给消息队列监听器、话 题订阅方或者Actor Model1中的Actor等。如果消息设施所使用的存储和模型所使用 的存储是分离的,并且消息设施不支持全局事务,那么在啁用领域服务时,事件必 须已经存在于消息存储中。消息转发组件将对消息存储中的每一个事件进行处理, 然后通过消息设施将事件发布出去。对此,我们将在本章后续内容做详细讨论。

身份标识
这里,我们再讨论一下领域事件为什么需要唯一标识。有时,我们需要对不同 的事件进行区分。在创建、发布事件的限界上下文中,我们几乎没有理由对不同事 件进行比较。但是,如果我们的确需要对不同的事件进行比较,我们应该怎么办 呢?再者,如果此时的事件被设计成了聚合,我们又该怎么办呢?

对于领域事件来说,使用属性来表示唯一标识似乎已经足够了,就像值对象 一样。使用事件的名字、产生事件的聚合标识和事件时间戳已经足以对不同的事 件进行区分了。

当领域事件被建模成了聚合;或者我们需要对不同的事件进行比较,但是事件 的属性又不足以区分事件时,我们便需要为事件创建唯一标识。当然,还有其他的原 因。

当我们需要将领域事件发布到外部限界上下文中时,为事件创建唯一标识也 是有必要的。在有些情况下,单条消息可能会被多次分发,比如,在消息设施确定 消息发出之前,消息发布器便瘫痪了。

不管是什么原因导致了对消息的重新分发,消息订阅方都需要检查出重复的 消息,并且将其忽略掉。为了达到这样的目的,有些消息设施在消息头中加入了唯 一性的消息标识,此时我们自己的领域模型是不能生成这样的标识的。即便消息 设施不会自动地向消息中加人唯一标识,消息的发送方也会向事件本身或者消息 中加入这样的标识信息。不管釆用哪种方法,远程的订阅方都有机会知道一条消 息是否是重复发送的。

有必要为领域事件提供equals()和liasliCode()方法吗?有,但是通常来说,只布 当事件用于本地限界上下文中时,我们才这么做。对于通过消息设施发送的事件, 有时订阅方接收的并不是事件对象本身,而是以XML、jSON或键值对等表示的事件数据。另一方面,当一个事件被设计成聚合并且保存在资源库中时,那么事件应 该为这些数据展现形式提供相应的方法支持。

从领域模型中发布领域事件

我们应该避免将领域模型暴露给任何类型的消息中间件。这些消息中间件只 存在于基础设施层中。虽然有时领域模型会间接地与基础设施层打交道,但是它 们绝不会显式地耦合起来。我们所釆用的方法将彻底地避免对基础设施的使用。

发送方
也许使用领域事件最常见的便是,由聚合创建一个事件,然后将其发布出去。 此时的发送方位于模型的某个模块(9)中,但是它并没有表达出多少领域概念,而 是向聚合中添加了一个简单的服务,该服务用于通知订阅方所发生的领域事件。

订阅方
由仆么组件向领域事件注册订阅方呢?通常来说,这种功能由应用服务(14) 完成,有时也由领域服务完成。订阅方可以是任何类型的组件,只要它和发布事件 的聚合位丁相同的线程中,并且在发布事件之前可以完成注册即可。这意味着,事 件订阅方是在使用领域模型的方法执行流中进行注册的。

向远程限界上下文发布领域事件

有多种方法可以将本地限界上下文中产生的事件发送到远程限界上下文中。首 先,可以使用消息机制。需要明确的是,这里讨论的概念要比先前的发布-订阅概念 宽泛得多。这里我们讨论的是那些轻量级的发布-订阅机制无法处理的情况。

存在多种消息组件,它们通常称为中间件。在开源社区有ActiveMQ、RabbitM Q、Akka、NserviceBus和MassTransit等。另外还存在很多商业化的消息中间件产 品。当然,我们也可以通过REST资源的方式自己实现一套消息机制,此时,作为订 阅客户方的自治系统将与消息的发布系统彻底分离,他们所请求的每一条消息通知 都是先前没有处理过的。以上所有的消息系统都釆用发布-订阅模式[Gamma et al.] ,它们都有各自的优缺点。各个幵发团队可以根据自身的预算、功能需求和质量需 求而采用最适合自己的消息系统。

在不同的限界上下文之间釆用这些消息系统时,我们必须保证最终一致性。在 一个模型中的改变可能需要很长一段时间才能反映到另一个模型中。此外,根据 各个系统的吞吐量和它们对其他系统的影响程度,在某个时间点,所有交互系统 作为一个整体有可能根本就无法达到最终一致tit。

消息设置的一致性
对于最终一致性,我们至少需要在两种存储之间保持最终一致性•.领域模型 所使用的持久化存储和消息设施所使用的持久化存储。这样保证了在持久化领域 模型时,相应的领域事件也总能够得以发布。如果这两者没有得到同步,有可能导 致模型处于不正确的状态。

那么,我们如何保证领域模型存储和事件存储之间一致性呢?

在领域模型的持久化存储中,创建一个特殊的存储区域(比如一张数据库表) ,该区域用于存储领域事件。这便是一个事件存储(Event Store),对此我们 将在本章G面予以讨论。这种方式和方式1相似,但是,此时的事件存储区域 不再由消息机制所拥有和控制,而是你的限界上下文。同时,你需要创建一个 消息外发组件将事件存储中的所有消息通过消息机制发送出去。这种方式的 优点在于:模型修改和事件提交可以同时位于单个本地事务中。另一个额外 的优点是,我们可以发布基于REST的事件通知。使用这种方式时,消息机制 所使用的消息存储是完全私有的。在将领域事件保存到事件存储之后,我们 需要使用一个消息中间件来发送消息。因此,这种方式的缺点是,我们可能需要定制开发一个消息转发组件来发送消息,同时客户方需要对消息进行消重 处理(请参考“事件存储”)。

自治服务和系统
远程系统有可能不可用或者处于超负荷状态,此时RPC可能会影响客户方的 成功调用。随着RPC API的增加,这种风险也将随之增大。因此,避免对RPC的使 用可以大大地简化系统之间的依赖,并且可以减少由远程系统不可用所带来的彻 底请求失败。

在与远程系统交互时,客户方可以不用主动地发起请求调用,而是可以通过异 步的消息来达到更高层次的独立性——自治性。当携带远程限界上下文中领域事 件的消息抵达之后,本地上下文将对该事件做出相应的处理,比如调用本地聚合 上的命令方法等。但是,这并不意味着我们只是简单地将消息中的对象复制到自己 的业务系统中。诚然,数据复制是不可避免的,比如我们至少需要复制远程上下文 中聚合的唯一标识。然而,我们儿乎没有可能对远程上下文所传来的对象进行整 体复制。如果发生了这样的建模错误,请参考限界上下文(2)和上下文映射图(3) ,这两个章节向我们解释了这样做为什么是错误的,并且如何避免这些错误。事实 上,在领域事件设计正确的情况下,它们极少会携带远程上下文中的某个对象的所有 信息。

领域事件将携带有限的命令参数和聚合状态,这些信息足以使作为订阅方的 限界上下文做出相应的操作。否则,该事件在领域范围之内的契约应该进行修改, 结果将导致一个新的事件契约版本,或者一个完全不同的事件。

有时,RPC是不可避免的。有些遗留系统可能只向客户方提供了RPC的调用 方式。另外,有时将一个外部限界上下文中的概念翻译成本地上K文中的概念是 存在困难的,而从不同事件中抽取信息以达到这样的翻译B的又会增加复杂度。 如果你希望尽可能全面地将外部模型复制到本地模型中,那么此时便可以考虑使 用RPC。当然,这不能成为一种优选的解决方案,我建议尽暈不要使用RPC。如果 RPC确实是不可避免的,此时要么釆用RPC’,要么可以说服外部模型的团队简化 他们的设计。应该承认的是,后一种方法是非常困难的。

事件存储
以下是SaaSOvmion团队在身份与访问上下文中的实现代码,它保 证了所有的领域事件都能得到保存。
在这里插入图片描述下面是EventStore的Append()方法:
在这里插入图片描述这里的store()方法将对DomainEvent实例进行序列化,然后将其用于创建新 的StoredEvent实例,最后将该StoredEvent保存到事件存储中。以下是StoredEvent 类的部分代码:
在这里插入图片描述所有的StoredEvent对象都将持久化到MySQL数据库中。此时,数据库应该为 序列化后的事件数据保留足够的存储空间,这里我们使用了具有65,000字符宽度 的varchar来保存序列化数据,这对于当前的事件实例来说已经足够了。
在这里插入图片描述

转发存储事件的架构风格

以REST资源的方式发布事件通知
客户方通过HTTP的GET方法来请求所谓的当前日志(Current Log)。这里的 当前H志表示所发布事件通知的最新版本。客户方所接收到的当前日志包含了若 干数量的事件通知,通知数量不能超过标准上限。在本书的例子中,我们将每个当 前日志所包含的通知数量设成20。客户方将依次遍历当前日志中所有的事件通知, 从中找出那些还没有被本地限界上下文所处理的事件通知。

存档日志没有什么神秘的。它只是表明:一个存档日志不能再被其所在的系统修改。同 时,这也告诉客户方:无论他们请求多少次存档日志,所获得的数据都是相同的。
另一方面,对于当前日志来说,在事件通知的数量达到最大上限之前,都是可以修改 的。当日志中的事件通知达到上限之后,当前日志将被存档。当然,修改当前日志的唯一方 法便是向其中加入新的事件通知。
在事件加入到日志中之后,该事件便不能再修改了,这主要是为了向客户方做出保证。

在这里插入图片描述

通过消息中间件发布事件通知

在釆用REST发布事件通知时,我们需要自己处理很多细节,而在釆用消息中 间件时,比如RabbitMQ,我们便不用去处理这些细节了,消息中间件将为我们处 理。此外,消息系统同时支持发布-订阅的事件通知方式和消息队列方式。在这两 种方式中,消息系统都是通过“推送”的方式来发送事件通知消息的。

考虑一下将事件存储中的事件通过消息中间件发布出去的情形。我们将采用 发布-订阅的方式,RabbitMQ称为扇出交换器(Fanout Exchange)。我们需要一系 列的组件依次完成以下操作:

  1. 对于某个扇出交换器来说,从事件存储中查找出所有还没有被发布的领域事 件对象,再将这些对象按照唯一标识升序排列。
  2. 依次遍历这些领域事件对象,并将它们发送给扇出交换器。
  3. 当消息系统成功发布事件通知之后,在扇出交换器中对该领域事件进行跟 踪。

我们不会等待订阅方的接收确认信号。当消息系统通过扇出交换器发布消息 曰寸,订阅系统有可能处于停机状态。每一个订阅系统都需要自己负责处理所接收到。

实现

发布事件通知的核心行为位于应用服务 NotificationService中。这样团队可以自己管理事务。此外.需 要强调的是,事件通知是一个应用程序级别上的关注点,而 不是领域的关注点.即便这些事件通知是源自于领域模型的 也是如此。

没有必要为NotificationService创建一个独立接口 .此时,对事件通知的发布只有一个实现. 因此SaaSOvation的团队成员们决定采用尽量简单的方式。另 外,每一个简单的类都有一个公有的接口:
在这里插入图片描述前两个方法用于查找NotificationLog实例,这些实例将以REST资源的方式提供给客户 方。第三个方法将单个Notification实例通过消息机制发布出去。团队成员们首先将实现前两 个查询方法,再实现第三个方法。

发布NotificationLog
回想一下,存在两种类型的通知日志——当前日志和存档日志。因此,NotificationService为每种类型的日志都提供了查询方法:
在这里插入图片描述由于当前日志可能一直处于改变状态,在每次请求时都需要重新计算日志标 识。计算代码如下:
在这里插入图片描述另一方面,对于存档日志来说,我们只需要一个NotificationLogld用于表示通 知的标识范围即可。回想一下,事件通知的标识是通过文本的方式来表示的,并且 表示了一个范围,比如21-40。因此,NotificationLogld的构造函数可以通过以下方式实现:
在这里插入图片描述最后,在Web层,我们发布当前日志和存档日志:
在这里插入图片描述发布基于消息的事件通知
在这里插入图片描述上面的publishNotification()方法首先获取到一个PublishedMessageTracker对 象。该对象的作用是持久化已经被发布的事件:
在这里插入图片描述请注意,PublishedMessageTracker并不属于领域模型,而是属于应用程序。该对象拥有一个唯一标trackedd。属性type描述了事件所要发布到的话题繩道 (topic/channel)。而mostRecentPublishedMessageld则表示了所发布DomainEvent的 唯一标识,该DomainEvent将被序列化成StoredEvent,然后再进行持久化。因此, 它维护了最近发布的事件实例的eventld。在所有的Notification消息发送完毕之 后,publishNotifications()方法将保存PublishedMessageTracker,其中含有最近发布事件的唯一标识。

事件标识eventld和type属性使得我们可以在不同时间将同一个事件通知发布 到任意数量的话题/通道中。我们只需要创建一个新的PublishedMessageTracker, 其中的type属性表示了话题/通道的名称,然后从第一个StoredEvent开始发布。以 下是 publishedMessageTracker()方法:
在这里插入图片描述再考虑以下用于消息发布的方法:
在这里插入图片描述事件消重

事件消重在有些环境中,消息系统可能多次向订阅方发送消息,在这种情况 下,我们便需要对事件进行消重。有多种原因可能导致消息的重复发送。其中一种 是:

  1. RabbitMQ将一条新建的消息发送到一个或多个订阅方。
  2. 订阅方处理该消息。
  3. 在订阅方发回确认信号之前,订阅方失败。
  4. RabbitMQ重新发送消息。

另一可能便是:当从事件存储中发送消息时,消息系统并不与事件存储共享持 久化机制,而全局的XA事务又没有控制事件存储和消息系统之间的原子提交。本 章前面的“通过消息中间件发布事件通知”一节便是这种情形。以下描述了重复发 送消息的情形:

  1. NotificationService查找并发布3个先前未被发布的Notification实例,然后通 过 PublishedMessageTracker更新发送记录。
  2. RabbitMQ接收到所有3条消息,并准备将它们发送给订阅方。
  3. 但是,应用服务器出现故障,NotificationServcie出现问题,造成对 PublishedMessageTracker的修改并未得到提交。
  4. RabbitMQ将消息发送给订阅方。
  5. 应用服务器的故障解除,消息发布过程重新启动,NotificationService继续发 送未发布的事件,其中也包括那3条未被PublishedMessageTracker记录的事 件。
    
  6. RabbitMQ将所接收到的事件发送给订阅方,于是先前那3条消息便出现了重 复。

幂等操作
幂等操作即进行多次重复操作和只进行一次操作所产生的结果相同。

处理重复消息的一种方式便是将汀阅方的处理过程变成幂等操作过程。订阅 方对消息的处理对于其自己的领域模型来说应该是幂等的。设计幂等领域对象的 问题在于:太凼难、太不实用、甚至是不可能的。另外,如果我们试图将事件本身设 计成幂等操作,这也会给我们带来很多麻烦。首先,消息的发送方必须完全了解所 有消息接收方的业务场景,其次,如果接收方由丁延迟、重试等原因而导致了错误 的消息接收顺序,那么这也将带来问题。

当领域对象无法满足幂等操作的要求时,我们可以转而将订阅方/接收方设计 成幂等的。比如,消息接收方在接收到重复的消息时可以拒绝处理。首先,我们必 须确认所使用的消息系统是否支持这样的功能。如果不是,接收方必须自己跟踪 哪些消息已经被处理过了。一种方式便是在订阅方的持久化机制中保存消息的话 题/交换器名称和一个唯一的消息1D——就像Published MessageTrackeWif釆用的 方式一样。然后,在处理消息之前,我们荇先对已经处理的消息进行查询。如果发 现所接收到的消息已经被处理过,那么订阅方可以简单地将其忽略掉。对消息的 跟踪并不是领域模型的一部分,而只是一个技术上的解决方案。

在使用常用的消息中间件产品时,只保存最近处理的消息是不够的,因为消息 的到达可能是无序的。因此,如果一个消重查询在检查那些ID小于最近一次所处 理消息的ID的消息时,它有可能忽略掉一部分消息。另外,我们需要考虑的是,有 时我们可能会忽略掉那些已经处理过的并且过期的消息,比如那些位于数据库垃 圾回收过程中的消息。

在使用基于REST的事件通知时,消重并不是一个多大的问题。接收方只需要 保存最近处理的消息通知标识,因为此时的接收方只会处理那些发生在最近处理 消息之后的消息。每一个通知日志中的消息顺序和通知标识顺序是相反的。

在两种情况下——消息中间件订阅方和基于REST的消息客户方——我们都 应该保证:对跟踪信息的修改和本地模型状态的修改必须一同提交。否则,对模 型的修改和对跟踪信息的修改将无法达到一致。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值