事件溯源:回滚聚合状态的正确方法

Event Sourcing: proper way of rolling back aggregate state

我正在寻找与在 CQRS/event-sourcing 应用程序中实现回滚功能的正确方法相关的建议。

此应用程序允许一组编辑人员编辑和更新一些社论内容,例如社论新闻。我们实现了用户界面,以便每个字段都具有自动保存功能,现在我们希望为我们的用户提供撤消他们所做的操作的可能性,以便可以将社论新闻回滚到以前的已知状态。
基本上我们想实现类似于 Microsoft Word 和类似文本编辑器中的撤消命令的功能。在后端,社论新闻是我们域中定义的聚合实例,名为 Story.

我们已经讨论了实现回滚的一些想法,我们正在寻找基于类似项目中的真实经验的建议。以下是我们对此功能的考虑。

回滚在现实世界业务领域中的工作原理

首先,我们都知道,在现实世界的业务领域中,我们所说的回滚是通过某种形式的补偿事件[=80=获得的].

想象一个与可以购买订阅的某种服务相关的域:我们可以有一个表示用户订阅的聚合和一个描述费用已与聚合实例相关联的事件(其中一位客户的特定订阅)。该事件的一种可能实现方式如下:

public class ChargeAssociatedToSubscriptionEvent: DomainEvent
{
  public Guid SubscriptionId {get; set;}
  public decimal Amount {get; set;}
  public string Description {get; set;}
  public DateTime DueDate {get; set;}
}

如果费用错误地与订阅相关联,可以通过与相同订阅相关联且金额相同的认证来修复错误,从而使费用的影响完全平衡并且用户拿回钱。换句话说,我们可以定义以下补偿事件:

public class AccreditationAssociatedToSubscription: DomainEvent
{
  public Guid SubscriptionId {get; set;}
  public decimal Amount {get; set;}
  public string Description {get; set;}
  public DateTime AccreditationDate {get; set;}
}

所以如果用户被错误地收取了 50 美元的费用,我们可以通过向用户订阅 50 美元的认证来补偿错误:这样聚合的状态已经回滚到以前的状态。

为什么事情并不像看起来那么简单

根据前面的讨论,回滚似乎很容易实现。如果您在聚合修订 B 处有一个故事聚合的实例,并且您想将其回滚到之前的聚合修订,例如 A(A < B),您只需执行以下步骤:

  • 检查事件存储并获取修订版 A 和 B 之间的所有事件
  • 为每个发生的事件计算补偿事件
  • 以相反的顺序将补偿事件应用于聚合

不幸的是,前面过程的第二步并不总是可行的:给定一个通用域事件,并不总是可以计算其补偿事件,因为信息量事件中包含的内容不足以做到这一点。也许可以明智地定义所有事件,使它们包含足够的信息来计算相应的补偿事件,但在我们应用程序的当前状态下,有几个事件无法计算补偿事件,我们会更愿意避免改变我们事件的形式。

基于状态比较的可能解决方案

克服补偿事件问题的第一个想法是通过比较聚合的当前状态与目标状态来计算回滚聚合所需的最小事件集 .算法基本如下:

  • 获取当前状态下的聚合实例(称之为 B)
  • 通过仅应用事件存储中持久保存的前 n 个事件来获取目标状态的聚合实例(称之为 A)(我们的存储库允许通过指定聚合 ID 和所需的时间点来做到这一点)实现聚合)
  • 比较两个实例并计算要应用于状态 B 中的聚合以将其状态更改为 A 的最小事件集
  • 将计算的事件应用于聚合

基于事件重放的更智能方法

解决回滚到聚合先前状态的问题的另一种方法可能是当聚合在特定时间点具体化时,执行与聚合存储库相同的操作。为了做到这一点,我们应该定义一个事件,比如说 StoryResettedEvent,它的作用是通过完全清空它来重置聚合的状态,并执行以下步骤:

  • 将 StoryResettedEvent 应用于我们的聚合,以便其状态被清空
  • 获取我们正在处理的聚合的前 n 个事件(从第一个保存的事件到目标状态 A 的所有事件)
  • 将所有事件应用于聚合实例

我看到这种方法的主要问题是清空聚合状态的事件:它似乎有些人为,不是具有业务意义的真正域事件,而是实现回滚功能的技巧。

第三种方式:在事件存储中每次保存事件时持久化补偿事件

我们想出的第三种方法是再次基于补偿事件的概念。基本思想是 应用程序的每个事件都可以用包含相应补偿事件的 属性 来丰富

在引发事件的代码中,可以立即计算要引发的事件的补偿事件(基于聚合的当前状态和事件的形状),以便该事件可以通过这种方式保存在事件存储中的信息来丰富。通过这样做,补偿事件事件总是可用的,准备好在回滚请求的情况下使用。此解决方案的缺点是必须修改每个域事件,并且我们必须计算并保存在事件存储中的补偿事件中只有一小部分对实际回滚有用(其中大部分永远不会被使用)。

结论

我认为解决问题的最佳选择是使用基于状态比较的算法(第一个提出的解决方案),但我们仍在评估要做什么。

有没有人有过类似的需求?还有其他方法可以实现回滚吗?我们是否完全忽略了重点并采用了错误的方法来解决问题?

感谢您的帮助,我们将不胜感激。

如何生成补偿事件应该是 Story 聚合的关注点(毕竟,这是事件溯源中聚合的要点——它只是特定流的命令验证器和事件生成器)。

推测您正在遵循典型的 CQRS/ES 流程:

  • 客户端发送撤消命令,大概说明了它想要撤消回哪个版本,以及它针对哪个故事
  • 撤消命令处理程序以通常的方式加载 Story 聚合,或者可能从快照 and/or 通过将聚合的事件应用到聚合。
  • 以某种方式将命令传递给聚合(可能是从命令中提取参数的方法调用,或者直接将命令传递给聚合)
  • 集合"returns" 事件以某种方式持续存在,假设撤消命令有效。这些是补偿事件。
  • compute the compensation event for each of the occurred events

...

Unfortunately, the second step of the previous procedure is not always possible

为什么不呢?聚合已经传递了所有以前的事件,那么它还没有什么需要呢?聚合不仅会看到您要回滚的事件,它还必须处理该聚合的所有事件。

您实际上有两个选择 - 通过让命令处理程序以某种方式提供帮助来减少聚合需要做的簿记,或者整个过程由聚合在内部管理。

命令处理程序提供帮助: 命令处理程序从命令中提取用户想要回滚到的版本,然后除了创建当前聚合之外,还重新创建该版本的聚合(以通常的方式应用事件)。然后将旧的聚合和命令一起传递给聚合的undo方法,这样聚合就可以更容易地进行状态比较。

您可能认为这有点 hacky,但它似乎适度无害,并且可以显着简化聚合代码。

聚合是独立的: 当事件应用于聚合时,它会向其状态添加任何簿记,以便在收到撤消命令时能够计算补偿事件。这可能是补偿事件的映射,预先计算,可能恢复到的每个先前状态的列表(以允许状态比较),聚合已处理的事件列表(因此它可以计算先前的状态本身撤消方法)或任何它需要的东西,它只是将其存储在内存中状态(和快照状态,如果适用)。

聚合自行完成的主要问题是性能 - 如果簿记状态的大小很大,允许命令处理程序传递先前状态的简化将是值得的。无论如何,您应该能够在未来的任何时间在这些方法之间切换而不会出现任何问题(除非可能需要重建您的快照,如果您有快照的话)。

我的 2 美分。

对于回滚操作,编排class 将负责处理它。它将发布一个 aggregate_modify_generated 事件,另一端的投影将在收到该事件后获取聚合的当前状态。现在,当任何聚合失败时,它应该生成一个失败事件,在接收到它时,编排 class 将生成一个 aggregate_modify_rollback 事件,该事件将由该投影接收,并将聚合状态设置为先前获取的状态.
一台普通的投影仪就可以完成这项任务,因为事件将具有聚合 ID。