DDD聚合和@DomainEvents

2023/05/18

1. 概述

在本教程中,我们将解释如何使用@DomainEvents注解和AbstractAggregateRoot类来方便地发布和处理聚合生成的域事件-领域驱动设计中的关键战术设计模式之一。

聚合接受业务命令,这通常会导致生成与业务域相关的事件-域事件

如果你想了解有关DDD和聚合的更多信息,最好从Eric Evans的原著开始。Vaughn Vernon还撰写了一系列关于有效聚合设计的文章,绝对值得一读。

手动处理域事件可能很麻烦。值得庆幸的是, Spring框架允许我们在使用数据Repository处理聚合根时轻松发布和处理域事件

2. Maven依赖

Spring Data在Ingalls发布系列中引入了@DomainEvents。它适用于任何类型的Repository。

本文提供的代码示例使用Spring Data JPA。将Spring域事件与我们的项目集成的最简单方法是使用spring-boot-starter-data-jpa

<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());
              });
    }
}

下面是一个测试,证明事件确实是由serviceDomainOperation发布的:

@DisplayName("given existing aggregate, when do domain operation on service, then domain event is published")
@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初始化实体的方式,这可能无法按预期工作。

以下是显示真实行为的相应测试:

@DisplayName("given existing aggregate, when do domain operation directly on aggregate, then domain event is NOT published")
@Test
void aggregateEventsTest() {
	Aggregate existingDomainEntity = new Aggregate(0, eventPublisher);
	repository.save(existingDomainEntity);
    
	// when
	repository.findById(existingDomainEntity.getId())
	    .get()
	    .domainOperation();
    
	// then
	verifyNoInteractions(eventHandler);
}

正如我们所看到的,该事件根本没有发布。在聚合内部具有依赖关系可能不是一个好主意。在此示例中,ApplicationEventPublisher未由Spring Data自动初始化。

聚合是通过调用默认构造函数来构造的。为了使它的行为符合我们的预期,我们需要手动重新创建实体(例如使用自定义工厂或切面编程)。

此外,我们应该避免在聚合方法完成后立即发布事件。至少,除非我们100%确定此方法是事务的一部分。否则,我们可能会在更改尚未持久化时发布“虚假”事件。这可能会导致系统不一致。

如果我们想避免这种情况,我们必须记住始终在事务中调用聚合方法。不幸的是,通过这种方式,我们的设计与持久层技术严重耦合。我们需要记住,我们并不总是使用事务系统。

因此,让我们的聚合简单地管理域事件的集合,并在它即将被持久化时返回它们通常是一个更好的主意

在下一节中,我们将解释如何使用@DomainEvents和@AfterDomainEvents注解使域事件发布更易于管理。

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("given aggregate with @DomainEvents, when do domain operation and save, then an event is published")
@Test
void domainEvents() {
	// given
	Aggregate2 aggregate = new Aggregate2();
    
	// when
	aggregate.domainOperation();
	repository.save(aggregate);
    
	// then
	verify(eventHandler, times(1)).handleEvent(any(DomainEvent.class));
}

发布域事件后,将调用@AfterDomainEventsPublication标注的方法。

此方法的目的通常是清除所有事件的列表,以便将来不会再次发布它们:

@AfterDomainEventPublication
public void clearEvents() {
    domainEvents.clear();
}

让我们将这个方法添加到Aggregate2类中,看看它是如何工作的:

@DisplayName("given aggregate with @AfterDomainEventPublication, when do domain operation and save twice, then an event is published only for the first time")
@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));
}

我们清楚地看到该事件仅是第一次发布。如果我们从clearEvents方法中删除@AfterDomainEventPublication注解,那么将第二次发布相同的事件

但是,实际会发生什么取决于实现者。Spring只保证调用此方法-仅此而已。

5. 使用AbstractAggregateRoot模板

借助AbstractAggregateRoot模板类,可以进一步简化域事件的发布。当我们想要将新的域事件添加到事件集合时,我们所要做的就是调用register方法:

@Entity
public class Aggregate3 extends AbstractAggregateRoot<Aggregate3> {
    // ...
    public void domainOperation() {
        // some domain operation
        registerEvent(new DomainEvent());
    }
}

这与上一节中显示的示例相对应。

只是为了确保一切按预期工作-以下是测试:

@DisplayName("given aggregate extending AbstractAggregateRoot, when do domain operation and save twice, then an event is published only for the first time")
@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("given aggregate extending AbstractAggregateRoot, when do domain operation and save, then an event is published")
@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注解)并对现有实体进行更改,那么我们通常只是让事务提交而不显式调用Repository上的save方法。因此,即使我们的聚合产生了新的域事件,它们也永远不会被发布。

我们还需要记住@DomainEvents功能仅在使用Spring Data Repository时有效。这可能是一个重要的设计因素。

6.2 丢失事件

如果在事件发布期间发生异常,监听器将永远不会收到通知

即使我们能以某种方式保证事件监听器的通知,目前也没有让发布者知道出现问题的背压。如果事件监听器被异常中断,该事件将保持未使用状态并且永远不会再次发布。

这个设计缺陷是Spring开发团队已知的,一位主要开发人员甚至提出了解决此问题的可能方法。

6.3 本地上下文

域事件使用简单的ApplicationEventPublisher接口发布。

默认情况下,在使用ApplicationEventPublisher时,事件在同一线程中发布和使用。一切都发生在同一个容器中。

通常,我们希望通过某种消息代理发送事件,以便其他分布式客户端/系统得到通知。在这种情况下,我们需要手动将事件转发到消息代理。

也可以使用Spring Integration或第三方解决方案,例如Apache Camel

7. 总结

在本文中,我们学习了如何使用@DomainEvents注解管理聚合域事件。

这种方法可以极大地简化事件基础结构,因此我们可以只关注域逻辑。我们只需要知道没有灵丹妙药,Spring处理域事件的方式也不例外。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章