事件采购:回滚聚合状态的正确方法

Enr*_*one 9 domain-driven-design cqrs event-sourcing

我正在寻找与在CQRS /事件采购应用程序中实现回滚功能的正确方法相关的建议.

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

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

回滚如何在现实世界的业务领域中运行

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

想象一下与可以购买订阅的某种服务相关的域:我们可以有一个代表用户订阅的聚合和一个描述费用已经与聚合实例相关联的事件(特定订阅一个客户).该事件的可能实施如下:

public class ChargeAssociatedToSubscriptionEvent: DomainEvent
{
  public Guid SubscriptionId {get; set;}
  public decimal Amount {get; set;}
  public string Description {get; set;}
  public DateTime DueDate {get; set;}
}
Run Code Online (Sandbox Code Playgroud)

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

public class AccreditationAssociatedToSubscription: DomainEvent
{
  public Guid SubscriptionId {get; set;}
  public decimal Amount {get; set;}
  public string Description {get; set;}
  public DateTime AccreditationDate {get; set;}
}
Run Code Online (Sandbox Code Playgroud)

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

为什么事情并不像看起来那么容易

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

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

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

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

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

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

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

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

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

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

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

我们想出得到我们所需要的第三种方式再次基于补偿事件的概念.基本思想是应用程序的每个事件都可以使用包含相应补偿事件的属性进行丰富.

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

结论

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

有没有人有类似的要求?有没有其他方法来实现回滚?我们是否完全忽略了这一点,并采取了解决问题的方法?

感谢您的帮助,任何建议将不胜感激.

Tom*_*omW 4

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

想必您正在遵循典型的 CQRS/ES 流程:

  • 客户端发送一个撤消命令,该命令大概说明了它想要撤消回哪个版本,以及它的目标故事是什么
  • 撤消命令处理程序以通常的方式加载故事聚合,可能从快照和/或通过将聚合的事件应用于聚合。
  • 以某种方式,命令被传递到聚合(可能是从命令中提取参数的方法调用,或者只是将命令直接传递到聚合)
  • 假设撤消命令有效,聚合会以某种方式“返回”要持续存在的事件。这些是补偿事件。
  • 计算每个发生事件的补偿事件

...

不幸的是,上述过程的第二步并不总是可行

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

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

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

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

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

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