DDD 中的那些模式 — 领域事件

2,258 阅读10分钟

严格的说事件驱动并不是一种模式,应该是一种架构风格或者编程范式。但是领域驱动设计中事件驱动所涵盖的范围没有那么大,往往只是作为整个系统解决方案的一部分,所以我还是把它归类在模式的范畴内。

事件无论对业务人员还是开发者都是非常熟悉且容易理解的概念,因此无论是在日常的需求沟通,还是系统设计中,事件都是建立领域模型时非常有用的工具。而在「事件风暴」这样的分析方法中,「领域事件」更是不可或缺的元素。在继续介绍领域驱动设计中的事件之前,我们先了解一下为什么要使用「事件」模式。

为什么需要「领域事件」

在之前介绍 Aggregation 聚合的文章中曾谈及,Aggregation 一个显著的特点或是限制条件就是每个事务应该只更新一个 Aggregation。无疑这对于系统设计提出了不小的挑战,如何设计一个粒度适中,又能符合业务要求的 Aggregation 并不是一件容易的事情。但是领域事件为我们提供了一种更为优雅的解决方案,在 Aggregation 完成更新后产生一个新的事件并广播出去,由其他订阅该事件的订阅者完成其他 Aggregation 的更新。这样就解除了 Aggregation 之间的耦合。

而另一个能让领域事件大显身手的地方是不同限界上下文之间的交互。现在较为流行的架构风格是将不同的限界上下文作为不同的微服务,微服务之间通过 API 的形式交互。但是 API 并不是唯一的解决方案,在某些场景下基于消息中间件的事件模型能够更好的降低耦合,提升系统的弹性。

如何使用「领域事件」

虽然事件的概念对于开发人员很好理解,但是在实际项目中真正使用事件驱动模式的却很少。一部分原因是事件驱动模式缺少框架的支持,往往需要手工处理许多包括异常,顺序发送等工作。另一个原因是事件驱动的编程模型与顺序编程模型差异很大,在发出事件后就将程序的控制逻辑交给了事件的订阅者,在开发与问题排查时不是那么的直观与方便。所以接下来的部分是我在一些事件驱动模式上的实践经验,希望能对大家有所帮助。

对领域事件建模

领域事件是一个对象,因此同样需要建模,定义它的数据结构。在开始定义我们的领域事件之前,还是先介绍一下业务场景。

当一个保险理赔申请提交,通过一系列的流程审核,确定理赔金额等数据无误后会有专人进行最后的二次审批,如果审批通过就可以支付给保险受益人相关的费用。从业务上看,理赔审批通过后,会有一连串的后台业务操作,首先是财务费用以及相关凭证的生产,然后是理赔通知书的生成与发送,如果保单由于理赔终止则需要对保单进行进一步的操作。这些业务行为无疑引入了数个 Aggregation 对象,肯定无法通过唯一的 Aggregation 在一个事务内完成,所以必须引入领域事件。以下是业务与事件的关系图:

领域事件由 Aggregation 生成,在我们的这个场景中,Aggregation 就是理赔案件对象 — ClaimCase。而领域事件的名称的格式一般为产生这个事件的 Aggregation 的名称 + 产生事件的动词的过去式,这里产生事件的行为是审批 — approve,所以我们可以把这个领域事件的名称定为: ClaimCaseApproved。这其实和事件风暴中的建议也一样。

确定了名字之后,我们看一下事件内部的数据结构。ClaimCaseApproved 内部数据结构一般与产生它的 Aggragation 很相似,都是相对重要的领域对象数据,在我们的业务场景中,会有理赔的案件号,保险单号,事故日期等。需要注意的是,对于领域事件,通常需要增加额外的两个属性,一个是事件的发生日期,还有一个是事件的唯一编号。这两项对于问题的排查与调试,以及订阅事件方的处理都是必需的。以下是事件的示例代码:

public class ClaimCaseApproved {
  private String eventId;
  private LocalDateTime occuredOn;
  private long claimCaseId;
  private long policyId;
  private LocalDateTime accidentDate;
  ……
}

事件的生成,发送与订阅

有了数据模型之后,我们需要考虑的是在一个分层架构中,应该将事件相关的代码放置于何处。至今为止并没有一个统一的规则,所以我介绍之前项目中曾经尝试过的方法,其中有好的地方也有不方便的地方,具体选择何种,就留给你自己了。

一种做法是在领域服务中处理事件发送与订阅的逻辑,而事件的生成由领域对象,即 Aggregation 负责。我们先看一下示例代码:

public class ClaimCase {
  public ClaimCaseApproved approve() {
    ……
  }
}

这里的代码很简单,ClaimCase 是一个 Aggregation 的领域对象,而 approve 方法执行的是审批的业务逻辑,它的返回结果就是它所产生的事件。接着看一下领域服务的代码:

public class ClaimCaseService {
  private DomainEventPublisher publisher;
  ……
  public void approve() {
    ClaimCase claimCase = .....;
    ClaimCaseApproved claimCaseApproved = claimCase.approve();
    publisher.publish(claimCaseApproved);
    ……
  }
}

领域服务 ClaimCaseService 调用领域对象的 approve 方法获得生成的领域事件后进行发送,这里的 DomainEventPublisher 只是一个接口,具体的实现会依赖与基础设施层。这种做法的问题在于需要领域对象显式的返回事件对象,如果你的领域对象的这个方法正好需要返回值,而 Java 又是一门不支持多个返回值的语言,那么就有些尴尬了,比较直白的解决方案就是引入第三方库,返回一个类似 Tuple 的数据结构。

还有一种事件生成的可选方案是在领域对象内部保留一个数据结构存储产生的事件,然后在领域服务中调用特定的方法获取已经产生的事件,再发送,示例的代码如下:

public class ClaimCase implements DomainEventGenerator {
  private Map<DomainEventType, List<DomainEvent>> registeredDomainEvents;
  public void approve() {
      ……
      registerDomainEvent(new ClaimCaseApproved());
  }
}

这次 ClaimCase 方法不再返回对应的领域事件,而是将事件保存在内部的 Map 中。接着看一下 ClaimCaseService 的变化:

public class ClaimCaseService {
  public void approve() {
  …
      claimCase.approve();
      Map<DomainEventType, List<DomainEvent>> registeredDomainEvents = claimCase.getRegisteredDomainEvents();
      publisher.publish(registeredDomainEvents);
 }
}

领域服务 ClaimCaseService 在调用了 claimCaseapprove 方法后,显式的调用了 claimCase.getRegisteredDomainEvents 方法,获取领域对象内部注册的领域事件,然后再发送。

另一种则是事件的发送,处理逻辑放在应用服务层,即 Application Service 中,具体的细节和领域服务中大同小异,我就不赘述了。但是有几点是需要牢记的:

  1. 领域事件是领域逻辑的一部分,所以在领域层不应该依赖某些底层的框架或是中间件,例如直接依赖某个消息中间件的 api。
  2. 事件的发送应该是异步非阻塞的,不应该阻塞当前处理的线程。
  3. 设计上避免事件链的产生,即一个事件被处理后又产生了另一个事件,第二个事件的处理又产生了第三个事件,在设计没有注意的情况会变成一个环。(别问我是怎么知道的~~~)
  4. 考虑最终一致性的解决方案,记好日志,以及事件丢失的处理与排查方案。

使用框架

无论上述何种方法你可能都需要通过「观察者」这样的模式实现事件驱动的整个架构,但是如果你是使用 Java 的,就可以使用 Spring 这样的框架,通过依赖注入将事件的订阅,发布从领域模型中剥离出去。下面让我们看看如何使用 Spring 实现之前的例子:

public class ClaimCaseApproved extends ApplicationEvent {
  ……
}

我们的领域事件变化不大,只是继承了由 Spring 提供的 ApplicationEvent 基类。 ClaimCaseService 中的 publisher 可以通过 Spring 的注入 Spring 的 ApplicationPublisher

@Autowired
private ApplicationEventPublisher applicationEventPublisher;

Spring 中把 Subscriber 称之为 Listerner,这里我们可以定义自己的订阅者:

@Component
public class FinanceFeeSubscriber {
  @Autowared
  private FinanceClaimFeeApplicationService financeClaimFeeApplicationService;

  @Async
  @EventListener
  public void handleClaimCaseApproved(ClaimCaseApproved event) {
      financeClaimFeeApplicationService.generateClaimFeeFor(…);
      ……
  }
}

上面的代码中我们借助 Spring 的能力注入了费用模块的应用层服务 FinanceClaimFeeApplicationService,然后通过 @Async@EventListener 两个 annotation 声明了处理领域事件的异步方法,在方法中我们调用了应用层服务完成了由于理赔审批通过引起的费用相关处理逻辑。

这里需要注意的是要将方法声明为异步,这样处理事件的方法就会在一个独立的线程中运行,不会阻塞发布事件的线程。其次是这里业务上对事件处理的顺序没有要求,因此可以并行处理,按照上述的代码,可以再创建两个订阅者,负责理赔通知书生成和保单终止的业务处理,彼此没有影响。

如果你不想使用 Spring 提供能够的事件机制,可以考虑使用 Google Gauva 提供的 EventBus,它提供了类似的功能,使用起来也非常简单。

最后要注意的是,无论使用 Spring 还是 Guava,事件数据都是保存在内存中的,如果遇到服务重启很可能就会丢失未处理的数据,因此在项目中一定要记录日志并想好如何处理事件丢失的问题,必要时需要手工触发重发事件等机制。

小结

事件驱动是非常贴合人类思维习惯的一种架构模式,而领域事件也是分析领域模型的优秀工具。虽然使用事件驱动的编程模型需要考虑一些额外的问题,例如线上的调试,事件的容错,重发等,但是毋庸置疑的是领域事件为我们提供了更好的解除耦合的手段,能够将大量复杂的业务逻辑拆分到不同的事件订阅者中处理,而彼此之间又保持着松耦合的关系。在项目允许的情况下,我强烈推荐领域事件这种模式,有兴趣的你不妨尝试一下!

本文使用 mdnice 排版

欢迎关注我的微信号「且把金针度与人」,获取更多高质量文章