1. 概述
在本教程中,我们将介绍如何使用 @DomainEvents
注解和 AbstractAggregateRoot
类,来方便地发布和处理由聚合(Aggregate)产生的领域事件(Domain Event)。聚合是领域驱动设计(DDD)中关键的战术模式之一。
聚合用于接收业务命令,这些命令通常会触发一个与业务相关的事件,即领域事件。
如果你对 DDD 和聚合还不太了解,建议先阅读 Eric Evans 的 原著。另外,Vaughn Vernon 写的 Effective Aggregate Design 系列文章也非常值得一读。
手动处理领域事件往往比较繁琐。幸运的是,Spring Framework 提供了便捷的机制,在使用数据仓库操作聚合根时,可以轻松地发布和监听领域事件。
2. Maven 依赖
Spring Data 在 Ingalls 发布版本中引入了 @DomainEvents
注解,它适用于所有类型的 Repository。
本文中的代码示例使用的是 Spring Data JPA。最简单的集成方式是使用 Spring Boot Data JPA Starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
3. 手动发布事件
首先,我们尝试手动发布领域事件。在下一节中,我们将介绍 @DomainEvents
的使用方式。
为了演示需要,我们使用一个空的标记类作为领域事件 —— DomainEvent
。
我们使用标准的 ApplicationEventPublisher
接口来发布事件。
✅ 发布事件有两个常见的位置:服务层或聚合内部。
3.1. 服务层发布事件
可以在服务方法中调用 Repository 的 save
方法之后,手动发布事件。
如果服务方法处于事务中,并且事件监听器使用了 @TransactionalEventListener
注解,那么事件将在事务成功提交后才被处理。
✅ 这样可以避免在事务回滚时处理“虚假”事件,保证系统一致性:
@Service
public class DomainService {
// ...
@Transactional
public void serviceDomainOperation(long entityId) {
repository.findById(entityId)
.ifPresent(entity -> {
entity.domainOperation();
repository.save(entity);
eventPublisher.publishEvent(new DomainEvent());
});
}
}
✅ 下面是一个测试,证明事件确实被发布了:
@DisplayName("给定一个已存在的聚合,"
+ " 当在服务中执行领域操作时,"
+ " 领域事件被成功发布")
@Test
void serviceEventsTest() {
Aggregate existingDomainEntity = new Aggregate(1, eventPublisher);
repository.save(existingDomainEntity);
// when
domainService.serviceDomainOperation(existingDomainEntity.getId());
// then
verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}
3.2. 聚合内部发布事件
也可以在聚合内部直接发布事件。
这种方式更符合面向对象的设计理念,事件的创建由聚合自己管理:
@Entity
class Aggregate {
// ...
void domainOperation() {
// some business logic
if (eventPublisher != null) {
eventPublisher.publishEvent(new DomainEvent());
}
}
}
❌ 但这种方式可能不如预期那样工作,因为 Spring Data 在从 Repository 初始化实体时,不会自动注入 ApplicationEventPublisher
。
下面是对应的测试,展示了实际行为:
@DisplayName("给定一个已存在的聚合,"
+ " 当在其内部执行领域操作时,"
+ " 领域事件不会被发布")
@Test
void aggregateEventsTest() {
Aggregate existingDomainEntity = new Aggregate(0, eventPublisher);
repository.save(existingDomainEntity);
// when
repository.findById(existingDomainEntity.getId())
.get()
.domainOperation();
// then
verifyNoInteractions(eventHandler);
}
⚠️ 可以看到,事件根本就没有被发布。在聚合中注入依赖不是一个好主意,Spring Data 并不会自动帮你注入这些依赖。
聚合是通过默认构造函数实例化的,如果想让它正常工作,需要我们手动重建实体(比如使用自定义工厂或 AOP)。
⚠️ 此外,不要在聚合方法执行完后立即发布事件,除非你能 100% 确保该方法处于事务中。否则,可能会在变更尚未持久化时发布“虚假”事件,导致系统不一致。
✅ 因此,更好的做法是让聚合只负责管理事件集合,并在即将被持久化时返回这些事件。
在下一节中,我们将介绍如何使用 @DomainEvents
和 @AfterDomainEventsPublication
注解来更优雅地管理事件发布。
4. 使用 @DomainEvents 发布事件
从 Spring Data Ingalls 版本开始,我们可以使用 @DomainEvents
注解来自动发布领域事件。
当使用正确的 Repository 保存实体时,Spring Data 会自动调用带有 @DomainEvents
注解的方法,并将该方法返回的事件通过 ApplicationEventPublisher
接口进行发布:
@Entity
public class Aggregate2 {
@Transient
private final Collection<DomainEvent> domainEvents;
// ...
public void domainOperation() {
// some domain operation
domainEvents.add(new DomainEvent());
}
@DomainEvents
public Collection<DomainEvent> events() {
return domainEvents;
}
}
✅ 下面是一个示例测试:
@DisplayName("给定带有 @DomainEvents 的聚合,"
+ " 当执行领域操作并保存时,"
+ " 事件被成功发布")
@Test
void domainEvents() {
// given
Aggregate2 aggregate = new Aggregate2();
// when
aggregate.domainOperation();
repository.save(aggregate);
// then
verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}
✅ 事件发布后,Spring Data 会调用带有 @AfterDomainEventsPublication
注解的方法。
这个方法通常用于清空事件列表,避免事件被重复发布:
@AfterDomainEventPublication
public void clearEvents() {
domainEvents.clear();
}
✅ 将该方法加入 Aggregate2
类中,看看效果:
@DisplayName("给定带有 @AfterDomainEventPublication 的聚合,"
+ " 当执行领域操作并保存两次时,"
+ " 事件仅在第一次被发布")
@Test
void afterDomainEvents() {
// given
Aggregate2 aggregate = new Aggregate2();
// when
aggregate.domainOperation();
repository.save(aggregate);
repository.save(aggregate);
// then
verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}
✅ 可以看到,事件只在第一次保存时被发布。如果去掉 @AfterDomainEventsPublication
注解,事件会在第二次保存时再次被发布。
⚠️ 但是否真的会再次发布,取决于你的实现逻辑。Spring 只保证调用该方法,其他逻辑需要开发者自行处理。
5. 使用 AbstractAggregateRoot 模板类
借助 AbstractAggregateRoot
模板类,我们可以进一步简化事件发布流程。
只需要在需要添加事件时调用 registerEvent
方法即可:
@Entity
public class Aggregate3 extends AbstractAggregateRoot<Aggregate3> {
// ...
public void domainOperation() {
// some domain operation
registerEvent(new DomainEvent());
}
}
✅ 这是上一节示例的简化版本。
下面是两个测试用例验证其行为:
@DisplayName("给定继承 AbstractAggregateRoot 的聚合,"
+ " 当执行领域操作并保存两次时,"
+ " 事件仅在第一次被发布")
@Test
void afterDomainEvents() {
// given
Aggregate3 aggregate = new Aggregate3();
// when
aggregate.domainOperation();
repository.save(aggregate);
repository.save(aggregate);
// then
verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}
@DisplayName("给定继承 AbstractAggregateRoot 的聚合,"
+ " 当执行领域操作并保存时,"
+ " 事件被成功发布")
@Test
void domainEvents() {
// given
Aggregate3 aggregate = new Aggregate3();
// when
aggregate.domainOperation();
repository.save(aggregate);
// then
verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}
✅ 可以看到,使用模板类可以大幅减少代码量,同时实现相同的功能。
6. 实现注意事项
虽然 @DomainEvents
看起来很好用,但也有几个需要注意的“坑”。
6.1. 事件未发布
在使用 JPA 时,我们并不总是需要显式调用 save
方法来持久化变更。
✅ 如果我们的代码处于事务中(比如加了 @Transactional
注解),并且修改了已有实体,那么通常只需让事务自动提交即可,不需要手动调用 save
。
⚠️ 这意味着即使聚合产生了新的事件,它们也不会被自动发布。
✅ 此外,@DomainEvents
机制仅在使用 Spring Data Repository 时生效。这是设计时必须考虑的因素。
6.2. 事件丢失
如果在事件发布过程中发生异常,监听器将永远不会被通知。
即使我们能设法确保监听器被通知,目前也没有回压机制让发布者知道事件处理失败。如果监听器因异常中断,事件将永远丢失。
⚠️ 这是 Spring 开发团队已知的设计缺陷。有核心开发者曾提出过一个 解决方案。
6.3. 本地上下文
领域事件默认通过 ApplicationEventPublisher
在同一个线程中发布和消费。
✅ 通常我们希望将事件通过消息中间件(如 RabbitMQ、Kafka)广播给其他系统。
在这种情况下,我们需要手动将事件转发到消息代理。也可以使用 Spring Integration 或第三方方案,如 Apache Camel。
7. 总结
在本文中,我们学习了如何使用 @DomainEvents
注解管理聚合中的领域事件。
✅ 这种方式可以大大简化事件基础设施的实现,让我们更专注于业务逻辑本身。
⚠️ 但要注意,Spring 的领域事件机制并非完美方案,实际使用时仍需谨慎。
所有示例代码可在 GitHub 上获取:https://github.com/eugenp/tutorials/tree/master/persistence-modules/spring-data-jpa-annotations