DDD实战第九篇 如何识别并实现领域事件?

1,589 阅读7分钟

大家好,我是飘渺。今天,我们要继续深入探讨领域驱动设计(DDD)与微服务的相关话题。

在之前的讨论中,我们已经涉及到DDD的战术设计中有一个核心概念,即领域事件(Domain Events)。领域事件是指那些在特定领域内发生的、具有重要性和意义的事情或状态变化。这类事件经常被用来捕捉系统中领域对象间的交互,以及在关键业务流程中发生的变化。

以实例来说,领域事件可能是业务流程的一环,比如当用户成功注册后,可能会触发积分赠送的操作;或者是某个事件发生后触发的后续动作,例如连续输入错误密码三次后,可能会触发账户锁定的操作。

1. 识别并处理领域事件的方法

识别领域事件的方法与我们刚才的定义息息相关。在与相关业务人员交流时,我们需要留心他们口中的关键词,比如“如果发生……,则……”,“当完成……后,请通知……”,“发生……时,需要……”等。在这些情况下,如果某个事件的发生会引发进一步的动作,那么这个事件很可能就是领域事件。

1.1 领域事件与事务处理

在DDD的框架下,领域事件通常通过最终一致性的方式保证事务,而非传统的SOA方式的直接调用。

首先,我们需要明确一下DDD中关于聚合的设计原则。

聚合是一组关联的实体和值对象的集合,它们共同构成一个整体。在DDD的设计中,有一个原则:在一次事务中,最多只能更改一个聚合的状态。 如果一次业务操作需要更改多个聚合的状态,那么就应采用领域事件来实现最终一致性。

领域事件驱动设计能够打破领域模型间的紧密依赖,发布事件后,发布者无需关心订阅者是否成功处理了事件。这种方式有助于解耦领域模型,保持领域模型的独立性和数据的一致性。当领域模型映射到微服务系统架构时,领域事件有助于微服务之间的解耦,微服务间的数据并不需要强一致性,而可以基于事件的最终一致性。

在具体的业务场景中,我们可以看到,有的领域事件发生在微服务内的聚合之间,有的则发生在微服务之间,还有一些场景可能两者皆有。一般而言,跨微服务的领域事件处理较为常见。因此,微服务设计时对不同领域事件的处理方式需要进行区分。

1.2 在微服务内部处理领域事件

在微服务内部,领域事件主要发生在聚合内部或者紧密相关的聚合之间。在事件发生后,发布方聚合会创建并持久化事件实体,然后将事件发布到内部的事件机制。订阅方接收这些事件数据,并根据事件内容进行后续业务操作。

在单一微服务内部,大部分事件的处理都在同一个进程内发生,这使得事务控制变得相对简单。按照 DDD 的原则——“一次事务只更新一个聚合”,本地事务就足以保证数据一致性。然而,如果一个业务操作涉及到跨聚合的访问,我们通常会通过服务编排和组合的方式完成,这在对实时性和数据一致性有较高要求的场景中非常有用。

1.3 在微服务间处理领域事件

领域事件也可以跨越微服务边界,实现不同限界上下文或领域模型之间的业务协作。这种跨微服务的领域事件的主要目标是解耦微服务,以降低微服务间实时服务访问的压力。

处理跨微服务的领域事件需要考虑更多的因素,如事件的构建、发布和订阅、事件数据的持久化、使用的消息中间件等。特别是,如果一个业务操作需要同时更新多个微服务中的数据,这就可能需要引入分布式事务机制,如Seata或者事务消息等,以确保跨微服务的数据一致性。

我们今天重点介绍一下在微服务内部的领域事件处理机制,至于跨微服务的领域事件等后面文章再来处理。

2. 在DailyMart中实现领域事件

在《DailyMart03:如何构建商城的领域模型》一文中构建用户子域的领域模型时提到了,当注册成功后,系统会赠送100积分,结合上文我们知道这是一个典型的领域事件。

现在我们来看看如何在DailyMart中实现此领域事件。

2.1 创建领域事件

首先让我们来创建一个领域事件的基类,包含事件ID、事件发生事件以及事件源,基类继承SpringBoot的ApplicationEvent类,这样在项目中可以方便使用SpringBoot的ApplicationEventPublisher来发布事件。

public abstract class DomainEvent<T> extends ApplicationEvent {
    @Serial
    private static final long serialVersionUID = 8057704899566664706L;
    @Getter
    private final String eventId;
    @Getter
    private final Instant occurredOn;
    @Getter
    private final T eventSource;


    public DomainEvent(T eventSource) {
        super(eventSource);
        this.eventId = UUID.randomUUID().toString();
        this.occurredOn = Instant.now();
        this.eventSource = eventSource;
    }
}

用户注册事件只需要继承此类即可。

public final class UserRegisteredEvent extends ApplicationEvent {
    @Serial
    private static final long serialVersionUID = -913852622150439450L;

    @Getter
    private final CustomerUser customerUser;

    public UserRegisteredEvent(Object source,CustomerUser customerUser) {
        super(source);
        this.customerUser = customerUser;
    }
}

需要说明的是领域事件位于DDD中的领域层,同时在命名领域事件时,需要注意以下几点原则:

  1. 反映业务含义:领域事件的名称应该清晰地反映出事件所代表的业务含义,即它是由什么触发的,以及它在业务上的影响是什么。
  2. 使用过去时:因为领域事件代表了已经发生的事情,所以通常用过去时来命名领域事件,如OrderCreatedProductShipped等。
  3. 简洁明了:尽量保持事件名称的简洁性,避免过长或者复杂的名称。短而有意义的事件名称更容易被理解和记忆。
  4. 避免技术术语:领域事件的名称应该基于业务语言,避免使用技术术语。业务语言(Ubiquitous Language)是DDD中的一个重要概念,它是开发人员和业务专家共同理解和沟通业务的语言。

2.2 发布领域事件

在用户服务的应用层CustomerUserService中,当用户注册成功后,我们发布UserRegisteredEvent

@Transactional
public UserRegistrationDTO register(UserRegistrationDTO userRegistrationDTO) {
	......
	CustomerUser customerUser = CustomerUser.builder()
			.userName(new CustomerUserName(userRegistrationDTO.getUserName()))
			.phone(new CustomerUserPhone(userRegistrationDTO.getPhone()))
			.email(new CustomerUserEmail(userRegistrationDTO.getEmail()))
		.password(CustomerUserPassword.fromPlainText(userRegistrationDTO.getPassword()))
			.build();


	CustomerUser registerUser = customerUserRepository.save(customerUser);

	//发布用户注册事件
	eventPublisher.publishEvent(new UserRegisteredEvent(registerUser));
	return  customerUserAssembler.domainToDTO(registerUser);
}

2.3 监听领域事件

在用户域的应用层中,我们监听OrderCreatedEvent事件,并执行相应的业务逻辑。

@Component
@Slf4j
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class UserRegistrationListener {

    private final CustomerUserRepository customerUserRepository;

    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) //让其在同一个事务中执行
    public void handleUserRegisteredEvent(UserRegisteredEvent userRegisteredEvent){
        CustomerUser customerUser = (CustomerUser) userRegisteredEvent.getSource();

        log.info("用户{}成功注册,触发增加积分操作", customerUser.getUserName().userName());
        customerUser.addPoints(+100,"注册用户赠送积分");       
        customerUserRepository.save(customerUser);
    }
}

由于使用了上一篇文章中提到的DDD仓储层的接口规范,这里虽然我们是更新操作,同样只需要调用customerUserRepository的save方法,由基础设施层的实现类决定是更新还是保存,详情请翻阅上篇文章。

同时,我们注意到,增加用户积分这个业务逻辑应该收敛到领域层,所以我们在CustomerUser对象中增加了一个addPoints方法,具体代码如下:

@Data
@Builder
public class CustomerUser implements Aggregate<UserId> {
    @Serial
    private static final long serialVersionUID = -8169499720446145361L;
	
	...
	
    /**
     * 给用户添加积分
     * @param additionalPoints 积分
     * @param description 描述
     */
    public void addPoints(int additionalPoints,String description){
        if (this.points == null) {
            this.points = new Points(0);
        }
        int newValue = this.points.getValue() + additionalPoints;
        this.points = new Points(newValue);

        PointsRecord pointsRecord = new PointsRecord();
        pointsRecord.setPoints(additionalPoints);
        pointsRecord.setDescription(description);
        pointsRecord.setDate(new Date());

        if (this.pointsRecord == null) {
            this.pointsRecord = new ArrayList<>();
        }
        this.pointsRecord.add(pointsRecord);
    }

}

通过上面三个步骤,我们实现了当用户注册后,系统自动赠送积分的功能。当然在我们这个具体的示例中,使用领域事件可能并不是必要的,因为我们可以直接在应用服务中处理积分赠送和保存用户聚合,我在此文中只是为了展示领域事件的实现流程。记住领域事件主要是用于解耦和处理跨聚合的业务逻辑。

3. 小结

在DDD中使用领域事件的优势在于它提供了一种灵活的机制来处理聚合内或聚合间的复杂业务逻辑,同时保持代码的解耦。它在以下场景中特别有用:

  1. 跨聚合通信:当一个操作影响到多个聚合,并且你想避免直接在一个聚合中引用另一个聚合时。
  2. 异步处理:当你想异步处理某些操作以提高性能时,例如发送欢迎邮件给新注册的用户。
  3. 可扩展性:当你的应用需要可扩展性,并且可能需要在未来添加更多的业务逻辑时,领域事件允许你轻松地添加更多的事件监听器而不需要修改现有代码。

DDD&微服务系列源码已经上传至GitHub,如果需要获取源码地址,请关注公号 java日知录 并回复关键字 DDD 即可。