在 DDD 和 CQRS 中,正确设计具有多个聚合更新的同步读取模型

Jay*_*Jay 2 domain-driven-design cqrs

假设我有2个骨料StaffShop我有AA读模型StaffModel 包含在一个非规范化视图店铺信息(shopId,姓名,地址等)。

业务规则是在单个请求中创建 aStaff和 a Shop,所以我有一个CreateStaffService创建 Staff 并触发StaffCreatedEvent,然后CreateShopService侦听器StaffCreatedEvent,创建一个Shop,然后触发ShopCreatedEvent

在读取模型方面,我有 4 种设计同步器服务的方法:

  1. 订阅StaffCreatedEvent,创建记录StaffModel。然后订阅ShopCreatedEvent,更新StaffModel使用人员 ID上的商店信息。

  2. ShopCreatedEvent包含人员信息,同步器服务订阅事件并一次性插入完整的读取模型。但是员工信息与Shop聚合无关,是否可以将其包含在事件中?

  3. 建模StaffModelShopModel单独更新模型以响应相应的聚合事件。

  4. 包装CreateStaffServiceCreateShopService在单个事务中,触发一个StaffAndShopCreatedEvent

我个人更喜欢选项 2 和选项 4,因为选项 1 很难确保StaffCreatedEvent总是在 之前到达ShopCreatedEvent

请分享您对这个主题的想法和经验。谢谢

更新:

为了避免使用序列号的无序事件消耗,假设我使用数据库序列生成一个序列号,每次递增 1,然后假设我的订阅者消耗事件 1,所以最后处理的事件序列为 1。然后生产者按顺序发布事件 2 、事件 3 和事件 4,并且仅在当前事务成功时才发送事件。所以如果事务 2 创建了一个序号 2,但事务失败并回滚,则事件 2 没有发送,但事件 3 和事件 4 已成功发送。

在消费者端,事件 3 和事件 4 都比最后处理的事件 1 更新,事件 2 永远不会到来。所以lastProcessed + 1 == currentVersion在这种情况下检查是错误的,除非事件序列号(版本)是严格顺序的,这也很难保证。

Jos*_*ris 5

您已经定义了两个不同的聚合,它们也可以在它们自己的有界上下文中。对于事件,每个事件都应该描述一个聚合。您将看到的大多数文档和示例都显示了一个单一的聚合标识符。请记住,虽然您现在正在查看事件的创建方面,但它仍然只是一个增量 - 在这种情况下,从“无”到“有”。

许多框架处理您最关心的问题,即处理乱序事件。这是一个真正的问题,尤其是对于分布式系统。为了缓解这种情况,通常在写入端为事件提供一个顺序标识符,然后将按顺序向非规范化器提供事件。因此,如果事件 4 到达,并且只处理了事件 2,它将保留事件 4,直到处理完事件 3 之后。

这是围绕同一问题的一般性讨论:Handling out of order events in CQRS read side

听起来您正在滚动自己的框架,这可能令人生畏。我正在做同样的事情,但它更多是为了扩展知识,而不是计划在现实世界中使用。然而,我可以为您假设的是,考虑如何让事件更可预测地到达。如果您现在不考虑扩展,那么您可以通过确保将事件推送到 FIFO 队列来减轻您的很多顾虑。然后,您的同步器服务可以轮询队列,而不是订阅事件。将此与在重建聚合时对事件进行排序以进行重播相结合,您就有了一个很好的起点。这样做,您实际上不必担心乱序事件,除非您有多个进程轮询您的队列。

为确保您实际上是按顺序生成事件,您所描述的内容听起来像是域服务的一个很好的用例。您正在协调两个聚合的操作。从您的域服务中引发这些事件可帮助您确保两个聚合上的操作都已完成。

更新:

我将扩展一点以反映额外的问题。让我们先退一步。在写入方面,您永远不会持久化聚合。您正在持久化反映状态更改的事件,其中包括创建。您的目标是您将发出一个命令,该命令将提供给聚合,而聚合将从该命令创建一个事件。创建事件后,聚合会将其应用于自身,然后使其可用于命令处理程序以从聚合中检索。由于该事件是聚合改变状态所需知道的全部内容,因此我们必须将其保存到写入端。当我们再次需要该聚合时,我们只需加载与给定聚合标识符相关的所有事件,并针对聚合类重放它们。重播所有事件后,

当您的命令处理程序将事件传递到您的事件存储时,它只关心它是一个事件。在持久性方面,您可能会在特定字段中序列化事件,以及包含围绕事件的元数据的其他字段;例如聚合标识符和聚合类型(当您需要重播时,这使查询更容易)。此外,事件将有一个顺序标识符。这是放置自动递增标识符的好地方。而且,在那里保持连续性非常重要。

对于您正在做的大部分工作,您最终一次只插入一个事件。很有可能,它将构成您的大部分操作。这样做的优雅之处在于几乎没有失败的机会。由于事件只是一个增量流,因此没有参照完整性。您可能会遇到更复杂的场景,主要是通过使用 saga 或流程管理器,在这些场景中,您可以在将事件发布到事件存储之前维护多个聚合的状态。你如何做到这一点可以从简单到复杂。但是,您在这里的主要困难是确保聚合没有被发生在流程管理器/传奇范围之外的其他事件所改变。不过,这几乎超出了您所要求的范围。最后,我将提供一个很好的资源供您阅读。

回到事件流。由于您只是将事件推送到您的写入端,并且我们可以假设它们是按顺序创建的,因此在您进行预测时可能会出现乱序事件。最常见的是,这将发生在横向扩展的部署中,您有多个服务器从队列中挑选命令并分发它们。在您的事件存储将您的事件推送到您的写入端以进行持久化后,它会将其推送到事件总线,您的订阅者将在那里对其进行操作。这就是排序变得重要的地方,因为这是您的阅读面将被更新的地方。

以您的情况为例,假设事件 1 正在创建员工,事件 2 正在创建商店。如果他们以相反的顺序点击事件总线,您需要知道这一点。事件总线的目标是将事件传递到某个地方,这通常是一个队列。一个简单的 FIFO 队列在这一点上实际上会出现问题,因为您处于模仿收到的订单的情况。您的排队机制应该能够让您检查所有排队的项目,并且您需要跟踪最后处理的事件。在原始系统上,对于您的两个请求,无论是在监控队列中的什么,都知道最后处理的事件是 0。它看到 2 到达并且什么都不做。然后 1 到达,它可以将事件 1 推送给所有订阅者,将跟踪最后处理的事件的数值更新为 1,

我曾提到一个资源,其中涵盖了很多内容,那就是Microsoft 的 CQRS Journey,它是他们模式与实践系列的一部分。即使您不在 Microsoft 堆栈中,它也包含大量重要信息。最好的部分是它确实是一次“旅程”。您不仅可以获得代码示例以及为什么要这样做,而且还可以在准真实世界的应用程序中看到项目的演变。整本书可以免费下载,PDF 格式。如果您愿意,您也可以获得纸质书。(我这样做了,因为科技书籍的触感以及我发现自己翻阅它们的方式不利于电子书)。