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

17

我正在寻求与在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;}
}

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

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
你确定在这种情况下使用ES是正确的选择吗?当你处理撤销操作时,Memento模式会浮现在脑海中。此外,使用ES时,补偿由领域定义,并且另一个领域事件被添加。从领域的角度来看,撤销主要是一个UI功能,只是另一个文本更改。 - MikeSW
@MikeSW感谢您回复我的帖子。我们不能将撤销处理为纯UI功能,因为我们的目的不仅是允许撤销对实体执行的最后一个操作,而且我们希望允许编辑器查看发生在实体上的所有更改历史记录,并决定回滚到通用先前状态,只需单击整个更改历史记录的特定条目即可。这意味着向后端发送命令并保存新事件,以便下一次从事件存储中实现聚合时其状态为更改后的状态。 - Enrico Massone
@EnricoMassone:那么你就不能使用事件溯源。事件溯源被实现为不可变的事件存储。写入事件存储的事件可能永远不会再次被改变或删除。更改始终是通过补偿操作来完成的,这些操作将更改还原为新的不可变事件。 - Tseng
你仍然可以将其恢复到最初的状态,通过创建补偿事件来实现,如果适用的话(可能需要一个系统,其中每个事件都有一个现有的补偿事件和/或可能需要一些计算(比较状态A和状态A-n,然后计算差异))。允许更改事件历史,将会失去其目的。 - Tseng
请记住,DDD 是关于捕捉您公司或客户的真实世界流程。在现实中,已经发生的事件是无法撤销的(抱歉,时间机器不存在!)。为什么您的软件要允许这样做呢?这意味着您没有完全理解 DDD 的含义。您应该开发代表公司现有流程而不是为了一款软件而改变真实世界流程的软件。 - Tseng
2个回答

4
补偿事件的生成应该是Story聚合的关注点(毕竟,这就是事件溯源中聚合的作用 - 它仅是特定流的命令验证器和事件生成器)。假设您正在遵循典型的CQRS / ES流程:
-客户端发送撤销命令,该命令可能会说明要撤销到哪个版本,以及要定位的故事。 -撤销命令处理程序通常方式下加载Story聚合,可以从快照或将聚合的事件应用于聚合。 -以某种方式将命令传递给聚合(可能是一次调用方法,并从命令提取参数,或直接将命令传递给聚合)。 -假设撤销命令有效,则聚合以某种方式“返回”要保留的事件。 这些是补偿事件。
不幸的是,上述过程的第二步并非总是可行。为什么呢?聚合已经传递了所有先前的事件,那么它需要什么呢?聚合不仅看到要回滚的事件,它还必须处理该聚合的所有事件。
你真正拥有两个选择 - 通过让命令处理程序在某种方式上协助来减少聚合所需的簿记,或者整个过程由聚合内部管理。
命令处理程序提供帮助: 命令处理程序从命令中提取用户想要回滚到的版本,然后按照该版本重新创建聚合(通常方式下应用事件),并创建当前聚合。 然后将旧聚合与命令一起传递给聚合的撤销方法,以便聚合可以更轻松地进行状态比较。
你可能认为这有点笨拙,但它似乎是相当无害的,并且可以显着简化聚合代码。
聚合独自操作: 随着事件在聚合上的应用,它会添加到其状态中,无论需要多少簿记来计算补偿事件以便在收到撤销命令时使用。 这可以是预先计算的补偿事件映射,可以被还原到的每个先前状态的列表(允许状态比较),聚合已处理的事件列表(因此它可以在撤销方法中自行计算以前的状态),或者它所需的任何内容,并将其存储在其内存状态中(如果适用,则为快照状态)。
聚合自行操作的主要问题是性能 - 如果簿记状态的大小很大,则允许命令处理程序传递先前状态的简化将是值得的。 无论如何,您应该能够在将来的任何时候切换到这些方法之间而没有任何问题(如果您有快照,则可能需要重建它们)。

谢谢你的帮助。我同意你的观点,通过比较聚合内部的状态似乎是在事件溯源场景下管理回滚的最佳方式。 - Enrico Massone

1

我的两分钱。

对于回滚操作,一个编排类将负责处理它。它将发布一个aggregate_modify_generated事件和另一端的投影,用于获取收到后聚合物的当前状态。现在,当任何一个聚合失败时,它应该生成一个失败事件,收到它后,编排类将生成一个aggregate_modify_rollback事件,该事件将被该投影接收,并将聚合状态设置为先前获取的状态。
一个常见的投影器可以完成这个任务,因为事件将具有聚合id。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接