【问题标题】:Is it safe to publish Domain Event before persisting the Aggregate?在持久化聚合之前发布领域事件是否安全?
【发布时间】:2017-09-12 04:25:44
【问题描述】:

在许多不同的项目中,我看到了两种不同的引发领域事件的方法。

  1. 直接从聚合中引发领域事件。例如,假设您有 Customer 聚合,其中有一个方法:

    public virtual void ChangeEmail(string email)
    {
        if(this.Email != email)
        {
            this.Email = email;
            DomainEvents.Raise<CustomerChangedEmail>(new CustomerChangedEmail(email));
        }
    }
    

    我可以看到这种方法存在 2 个问题。第一个是无论聚合是否持久化,都会引发事件。想象一下,如果您想在成功注册后向客户发送电子邮件。将引发事件“CustomerChangedEmail”,即使未保存聚合,某些 IEmailSender 也会发送电子邮件。当前实现的第二个问题是每个事件都应该是不可变的。所以问题是如何初始化它的“OccuredOn”属性?仅在内部聚合!这是合乎逻辑的,对!它迫使我将 ISystemClock (系统时间抽象)传递给聚合的每个方法!什么???你不觉得这种设计脆弱和笨重吗?以下是我们将提出的建议:

    public virtual void ChangeEmail(string email, ISystemClock systemClock)
    {
        if(this.Email != email)
        {
            this.Email = email;
            DomainEvents.Raise<CustomerChangedEmail>(new CustomerChangedEmail(email, systemClock.DateTimeNow));
        }
    }
    
  2. 第二种方法是按照事件溯源模式推荐的方式去做。在每个聚合上,我们定义了一个未提交事件的(列表)列表。请注意,UncommitedEvent 不是域事件!它甚至没有 OccuredOn 属性。现在,当在 Customer Aggregate 上调用 ChangeEmail 方法时,我们不会提出任何问题。我们只是将事件保存到我们聚合中存在的 uncommitedEvents 集合中。像这样:

    public virtual void ChangeEmail(string email)
    {
        if(this.Email != email)
        {
            this.Email = email;
            UncommitedEvents.Add(new CustomerChangedEmail(email));
        }
    }
    

那么,什么时候引发实际的域事件???这个责任被委托给持久层。在 ICustomerRepository 中,我们可以访问 ISystemClock,因为我们可以轻松地将它注入到存储库中。在 ICustomerRepository 的 Save() 方法中,我们应该从 Aggregate 中提取所有未提交的事件,并为每个事件创建一个 DomainEvent。然后我们在新创建的域事件上设置 OccuredOn 属性。然后,在一个事务中,我们保存聚合并发布所有域事件。通过这种方式,我们将确保所有事件都将在跨国界引发并具有总体持久性。
我不喜欢这种方法的什么?我不想为同一个事件创建 2 种不同的类型,即对于 CustomerChangedEmail 行为,我应该有 CustomerChangedEmailUncommited 类型和 CustomerChangedEmailDomainEvent。最好只有一种类型。请分享您对此主题的经验!

【问题讨论】:

  • 这一切都很好,但恐怕这不是一个问题,所以它很可能会作为题外话被关闭。
  • 问题是在持久化聚合之前发布域事件是否安全?
  • 我已经看到即使是 Vaughn Vernon(著名的实现领域驱动设计的作者)也使用第一种方法,所以我想知道。也许我错过了smth,第一种方法是完全安全和好的?
  • 第一种方法假定聚合在事务中被修改。因此,该事件立即引发,但仅在事务提交后处理。从技术上讲,可以将事件处理程序配置为仅在事务成功提交后触发。至于OccuredOn 属性 - 这可以由某种集中式事件工厂(甚至直接由域发布者)设置,因此您不必在每个聚合中复制代码。
  • @Mike Wojtyna,您能否提供一个示例,说明如何将事件处理程序配置为仅在事务提交成功后触发?不错的收获,但我没想到。关于“OccuredOn”,如果事件本身已经在聚合中创建并且它是不可变的,那么集中式事件工厂如何设置它?

标签: c# dns domain-driven-design


【解决方案1】:

我不支持您介绍的两种技术中的任何一种:)

现在我倾向于从域中返回事件或响应对象:

public CustomerChangedEmail ChangeEmail(string email)
{
    if(this.Email.Equals(email))
    {
        throw new DomainException("Cannot change e-mail since it is the same.");
    }

    return On(new CustomerChangedEmail { EMail = email});
}

public CustomerChangedEmail On(CustomerChangedEmail customerChangedEmail)
{
    // guard against a null instance
    this.EMail = customerChangedEmail.EMail;

    return customerChangedEmail;
}

通过这种方式,我不需要跟踪我未提交的事件,也不需要依赖诸如DomainEvents 之类的全局基础架构类。应用层控制事务和持久性的方式与没有 ES 的方式相同。

至于协调发布/保存:通常另一层间接会有所帮助。我必须提一下,我认为 ES 事件不同于系统事件。系统事件是有界上下文之间的事件。消息传递基础架构将依赖系统事件,因为这些事件通常比域事件传达更多信息。

通常在协调诸如发送电子邮件之类的事情时,会使用流程管理器或其他一些实体来承载状态。你可以在你的Customer 上携带一些DateEMailChangedSent,如果为空,则需要发送。

步骤如下:

  • 开始交易
  • 获取事件流
  • 拨打电话更改客户的电子邮件,甚至添加到事件流中
  • 需要记录电子邮件发送(DateEMailChangedSent 回空)
  • 保存事件流 (1)
  • 发送SendEMailChangedCommand 消息 (2)
  • 提交事务(3)

有几种方法可以完成消息发送部分,可能将其包含在同一个事务中(不是 2PC),但现在让我们忽略它。

假设之前我们已经发送了一封电子邮件,我们的 DateEMailChangedSent 在开始之前就有一个值,我们可能会遇到以下异常:

(1) 如果我们无法保存事件流,那么这里没有问题,因为异常会回滚事务并且会再次进行处理。
(2) 如果由于某些消息传递失败而无法发送消息,则没有问题,因为回滚会将所有内容恢复到我们开始之前。 (3) 好吧,我们已经发送了我们的消息,因此提交时的异常可能看起来像是一个问题,但请记住,我们无法将 DateEMailChangedSent 设置回 null 以表明我们需要发送新的电子邮件.

SendEMailChangedCommand 的消息处理程序将检查DateEMailChangedSent,如果不是null,它将简单地返回,确认消息并消失。但是,如果它 为空,那么它将直接与电子邮件网关交互或通过消息传递使用某些基础设施服务端点来发送邮件(我更喜欢这样)。

好吧,那是我的无论如何:)

【讨论】:

  • 此解决方案的唯一问题是,每次您在域对象上调用任何域方法时,您还需要记住发布适当的事件。这就是类似于 DomainEvents 的解决方案背后的主要原因。这个问题有点类似于持久性问题(无论是在域方法上持久化实体还是在外部使用存储库)。然而,在存储库的情况下,情况很简单——我们都同意我们应该在实体之外使用存储库。就个人而言,我更倾向于从应用层发出事件,因为我相信它不是领域层的一部分。
  • 确实 Mike Wotjyna,我们在编程时需要记住各种各样的事情 :) --- 我更喜欢将这些封装成应用层任务,自己协调域对象/服务。然后它们可以在任何集成点使用。事务范围和连接仍然由应用层处理。
  • @Eben Roux,非常感谢您的回答!您建议的方法很有趣,但我看到了 2 个小问题。第一个是返回类型。我希望我的方法坚持告诉,不问原则。如果我的方法改变了聚合的状态,它应该是无效的。感觉改变状态+返回事件违反了 SRP。第二个是位于聚合方法内部的业务逻辑和“收集”领域事件的逻辑的混合,这可能会给理解代码带来困难。 BTW,“收集”领域事件的逻辑应该属于基础设施,你怎么看?
  • @Eben Roux,'OccuredOn' 初始化怎么样?如果聚合改变状态并返回域事件,应用层无法重置它们的 'OccuredOn' 属性。
  • @DmitriBodiu,关于您的第一点:您的域正在以任何方式发出事件。你并不是真的要求事件。您正在告诉您的域执行某些操作,而这些事件恰好是结果。拥有域返回事件似乎更容易测试,并且可以更清楚地了解正在发生的事情。收集事件确实是基础设施。这就是为什么我的 Shuttle.Recall ES 机制中有以下内容:var stream = eventStore.Get(id); stream.Add(aggregate.DoSomething()); eventStore.Save(stream);...
【解决方案2】:

我见过 2 种不同的引发领域事件的方法。

从历史上看,有两种不同的方法。 Evans 在描述领域驱动设计的战术模式时没有包括领域事件; they came later.

在一种方法中,领域事件充当事务中的协调机制Udi Dahan wrote一些帖子描述了这种模式,得出结论:

请注意,上述代码将与常规域工作在同一事务内的同一线程上运行,因此您应避免执行任何阻塞活动,例如使用 SMTP 或 Web 服务。

,常见的替代品,实际上是一种非常不同的动物,因为事件被写入记录簿,而不仅仅是用于协调写入模型中的活动。

当前实现的第二个问题是每个事件都应该是不可变的。所以问题是如何初始化它的“OccuredOn”属性?仅在内部聚合!这是合乎逻辑的,对!它迫使我将 ISystemClock (系统时间抽象)传递给聚合的每个方法!

当然 - 见John Carmack's plan files

如果您不将时间视为输入值,请考虑直到您这样做 - 这是一个重要的概念

在实践中,实际上有两个重要的时间概念需要考虑。如果时间是您的域模型的一部分,那么它就是一个输入。

如果时间只是您想要保留的元数据,那么聚合不一定需要知道它——您可以将元数据附加到其他地方的事件中。例如,一个答案是使用工厂的实例来创建事件,工厂本身负责附加元数据(包括时间)。

如何实现?代码示例的示例对我有很大帮助。

最直接的例子是将工厂作为参数传递给方法。

public virtual void ChangeEmail(string email, EventFactory factory)
{
    if(this.Email != email)
    {
        this.Email = email;
        UncommitedEvents.Add(factory.createCustomerChangedEmail(email));
    }
}

应用层的流程看起来像

  1. 根据请求创建元数据
  2. 从元数据创建工厂
  3. 将工厂作为参数传递。

然后,在一个事务中,我们保存聚合并发布所有域事件。通过这种方式,我们将确保所有事件都将在跨国界引发并具有总体持久性。

通常,大多数人都尽可能避免两阶段提交。

因此,发布通常不是事务的一部分,而是单独进行的。 请参阅 Greg Young 在Polyglot Data 上的演讲。主要流程是订阅者从记录簿中提取事件。在该设计中,推送模型是一种延迟优化。

【讨论】:

  • 非常感谢您的回答!您能否详细说明一下如果时间只是您试图保留的元数据,那么聚合不一定需要知道它——您可以将元数据附加到其他地方的事件中。例如,一个答案是使用工厂的实例来创建事件,工厂本身负责附加元数据(包括时间) 如何实现?代码示例的示例对我有很大帮助。
  • 非常感谢您的回答!关于如果时间只是您试图保留的元数据,那么聚合不一定需要知道它——您可以将元数据附加到其他地方的事件。我怎么能以正确的顺序从聚合中收集所有事件?如果我在 Aggregate 上有一个 Collection 并且我想从聚合的内部引发(添加到集合)一个事件?想象一下具有 OrderItems 的 Order。如何从 OrderItem 类引发事件?我知道这是完全不同的问题......
  • 但我所做的是我为每个驻留在聚合内的实体添加了一个集合。然后在 Aggregate 我有 GetUncommitedEvents() 方法,它将聚合的事件与所有孩子的 enitites 事件连接起来。在这里,我需要 OccuredOn 对每个事件进行相应的排序。这是问题的根源)
【解决方案3】:

我倾向于使用第二种方法来实现领域事件。

我有一个简单的DomainEventDispatcher(应用层)类来监听应用程序中的各种持久性事件,而不是手动检索然后分派聚合根存储库中的所有事件。添加、更新或删除实体时,它会确定它是否为AggregateRoot。如果是这样,它会调用releaseEvents(),它会返回一组域事件,然后使用应用程序EventBus 进行分派。

我不知道您为什么如此关注occurredOn 属性。

领域层只关心领域事件的核心,例如聚合根 ID、实体 ID 和值对象数据。

在应用程序层,您可以拥有一个事件信封,它可以包装任何序列化的域事件,同时为其提供一些元数据,例如唯一 ID (UUID/GUID)、它来自哪个聚合根、它发生的时间等。这可以持久化到数据库中。

此元数据在应用程序层很有用,因为您可能会使用 HTTP 上的消息总线/事件流将这些事件发布到其他应用程序,并且它允许每个事件是唯一可识别的。

同样,关于事件的元数据通常在领域层没有意义,只有应用层。领域层不关心或使用事件 ID 或它们发生的时间,但使用这些事件的其他应用程序会这样做。这就是为什么将这些数据附加到应用层的原因。

【讨论】:

    【解决方案4】:

    我解决电子邮件发送问题的方法是将事件的发布和通过消息队列处理的事件解耦。这样,您在将事件发送到队列后关闭事务,并且发送电子邮件或其他不能或不应成为原始数据库事务的一部分的效果将在不久之后发生在不同的事务中。当然,最简单的方法是拥有一个将领域事件发布到队列中的事件处理程序。

    如果您想更加确定在事务提交时域事件将被发布到队列中,您可以将事件保存到将与事务一起提交的 OUTBOX 表中,然后让线程从表并发布到事件队列

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2018-11-18
      • 2019-12-23
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-02-06
      • 2020-03-20
      相关资源
      最近更新 更多