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

时间:2018-01-30 22:58:14

标签: domain-driven-design cqrs event-sourcing

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

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

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

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

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

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

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的所有事件)
  • 将所有事件应用于聚合实例

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

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

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

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

结论

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

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

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

2 个答案:

答案 0 :(得分:3)

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

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

  • 客户端发送撤消命令,该命令可能会说明要撤消的版本以及它所针对的故事
  • 撤消命令处理程序以通常的方式加载Story聚合,可能来自快照和/或将聚合事件应用于聚合。
  • 在某种程度上,命令被传递给聚合(可能是从命令中提取args的方法调用,或者只是将命令直接传递给聚合)
  • 假设撤消命令有效,聚合“以某种方式返回”要保留的事件。这些是补偿性事件。
  
      
  • 计算每个发生事件的补偿事件
  •   
     

...

     

不幸的是,前一过程的第二步并非总是可行

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

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

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

您可能会认为这有点像hacky,但它看起来中等无害,并且可以显着简化聚合代码。

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

聚合的主要关注点是性能 - 如果簿记状态的大小很大,允许命令处理程序通过先前状态的简化是值得的。在任何情况下,您都应该能够在将来的任何时间在方法之间切换而不会出现任何问题(除非可能需要重建快照,否则)。

答案 1 :(得分:1)

我的2美分。

对于回滚操作,业务流程类将负责处理它。它将发布一个aggregate_modify_Generated事件,并且在该事件的另一端的投影将在接收到聚合的当前状态之后将其获取。现在,当任何聚合失败时,它应该生成一个失败事件,在接收到该事件时,业务流程类将生成一个由该投影接收到的aggregate_modify_rollback事件,并将聚合状态设置为先前获取的状态。
一台普通的投影仪可以执行此任务,因为事件将具有汇总ID。