08.领域事件

327 阅读15分钟

领域事件

1.介绍

领域事件是聚合中已发生的事实,代表聚合内已经发生的业务操作或状态变化。以电子商务应用为例,用户下单成功,用户支付完成等操作可以被视为领域事件

领域事件是领域模型的组成部分,它通常由聚合根产生,并被其它聚合或者限界上下文订阅和处理,触发相应的业务逻辑

2.注意

  1. 应该根据限界上下文的通用语言来命名事件:如果事件由某个命令操作产生,则通常以该命令操作方法的名字来命名领域事件,并且采用过去式。例如账户已激活这个领域事件,由activateAccount命令操作成功时生成,采用AccountActivated进行命名
  2. 应该将事件建模成值对象或贫血对象:应该将事件建模成值对象或贫血对象,并根据实际情况进行拖鞋,以适应序列化和反序列化框架需求。所谓妥协,主要指值对象通常被建模为不可变对象,因此不提供set方法。然而,由于领域事件需要进行序列化和反序列化,因此,需要提供空的构造方法和set方法,以避免框架运行错误

3.应用

  1. 解耦领域对象之间的关系:通过引入领域事件,领域对象之间的耦合度可以降低。领域对象可以通过发布事件的方式,告知其他对象自身发生了某个重要事件,而不需要直接调用其他对象的方法
  2. 触发其他领域对象的行为:订阅领域事件的其他上下文,可以在领域事件发生时执行对应的操作,例如发送通知,生成报表等
  3. 记录领域内已发生的状态变化:领域事件记录了领域内的一些重要变化,这些记录可以作为系统的审计日志,用于追踪和分析系统的状态变化和业务流程。此外,通过记录系统中的所有状态变化,可以实现事件溯源
  4. 实现跨聚合数据一致性:当一个聚合根发生了状态变化时,可通过领域事件的方式,通知其他聚合根进行相应的更新,以保证数据的一致性
  5. 进行限界上下文集成:领域事件可以作为一种发布语言,用于限界上下文之间的协作,实现限界上下文集成

4.消息体

领域事件可能只包含事件ID,业务主键,事件发生时间,事件类型,如以下领域事件的消息体:

{
    "eventType":"MobileChanged",
    "entityId":"123456",
    "eventTime":"1654156165",
    "eventId":"1234555"
}
  • entityId:聚合根实体的唯一标识,是执行业务操作的业务主键,订阅者可以通过entityId获得发生该领域事件的聚合根实体的信息
  • eventId:领域事件的id,订阅者可以根据它来实现幂等操作
  • eventType:用于区分领域事件的类型
  • eventTime:发生该领域事件的时间

对于上述消息,消费者可能需要查询发生领域事件的聚合根实体完整的业务信息,以执行业务操作。例如,在MobileChanged的消息体中,当事件订阅者消费者到该消息后,需要使用entityId查找修改后的手机号的值

领域事件也可以使用事件增强的方式进行设计,即在领域事件中包含消费者需要的完整信息,从而避免消费者进行额外的查询:

{
    "eventType":"MobileChanged",
    "entityId":"123456",
    "eventTime":"1654156165",
    "eventId":"1234555",
    "afterMobile":"18143034297"
}

afterMobile是在MobileChanged事件发生后,用户手机号的值。由于这个消息采用了事件增强的设计方式,消息中直接提供了修改后的手机号,领域事件的订阅者无需再查询新的手机号的值

5.建模实现

领域事件应该被建模为值对象或者贫血对象,可以建模一个抽象的领域事件基类,示例代码如下:

@Data
public abstract class DomainEvent {
    private String eventId;
    private String eventType;
    private String eventId;
    private Long eventTime;
}

6.生成

介绍

关于领域事件的生成,主要有两种方案:应用层创建领域事件,聚合根创建领域事件

应用层创建领域事件

介绍

应用层创建领域事件最为普遍,尤其是在贫血模型中,基本上都是在Service层创建并发布事件到消息中间件,伪代码如下:

@Service
public class ApplicationService {
    @Resource
    private DomainEventPublisher publisher;
    @Resource
    private DomainRepository repository;
    
    /**
     * 应用层创建并发布领域事件
     */
     public void doBusiness(Command cmd) {
         // 加载聚合根
         Entity entity = repository.load(new EntityId(cmd.getEntityIdValue()));
         // 聚合根执行业务逻辑
         entity.doBusiness(new ValueObject(cmd.getValue()));
         // 保存聚合根
         repository.save(entity);
         // 创建领域事件
         DomainEvent de = new DomainEvent(entity.getSomeValue());
         // 发布领域事件
         publisher.publish(de);
     }
}
争议

有人可能会认为这种方式不好,应该由聚合根发布,但其实应用层创建领域事件是有其合理性的,理由如下:

  • 领域事件属于值对象或贫血模型,既然可以在应用层创建EntityId这样的值对象,那么自然也是可以创建领域时间的
  • 聚合根完成业务逻辑后自行创建领域事件,会导致聚合根的业务方法职责不再单一。此时聚合根既要完成业务逻辑,也要维护创建领域事件的逻辑,实际上做了两件事情,未来如果要发布更多的事件,就要调整聚合根业务方法内的代码
  • 在现实世界中,发生事件的人不一定就是发起通知的人。举个例子,比如有人在做违法的事情,你举报了。这里你就是应用层,那个人就是聚合根。因此逻辑是可以接受的

聚合根直接调用基础设施进行发布

介绍

领域事件是在聚合状态发生变化时产生的,并且聚合知晓自身状态变化前后的值,因此可以在聚合的业务方法中创建领域事件

聚合根创建领域事件之后,调用基础设施将领域消息发布出去,这种情况需要给聚合根注入消息发送的基础设施Publisher

伪代码
public class Entity {
    private Publisher publisher;
    
    public void doBusiness(Command cmd) {
        // 1.业务逻辑处理,忽略
        // 2.创建领域事件,可能不止一个
        List<DomainEvent> domainEvents = createDomainEvent();
        // 3.发布领域事件
        publisher.publish(domainEvents);
    }
}
优点

对上层透明:直接在聚合内部处理领域事件的发送逻辑,不影响方法的返回值

缺点

聚合根不应该依赖基础设施。如果聚合调用基础设施进行发布,也就意味着一个聚合根做了两件事情:执行业务操作,发布领域事件。这正是贫血模型做的事情

因此,要避免在聚合根内调用基础设施发布领域事件,应该将领域事件的生成和发布两个过程分开。不推荐使用这种方法创建并发布领域事件

聚合根业务方法返回领域事件

介绍

聚合根自己创建领域事件并调用基础设施发布事件,会导致聚合跟方法的职责不单一。此时可以通过以下方案来规避这个问题:将业务方法的返回值改为领域事件。在聚合根创建领域事件之后,可以通过业务方法的返回值,将领域事件返回给应用层。应用服务再调用基础设施来发布领域事件

伪代码
public class Entity {
	public List<DomainEvents> doBusiness (ValueObject valueObject) {
        // 1.业务逻辑处理,省略
        // 2.创建领域事件
        List<DomainEvent> domainEvents = createDomainEvent();
        return domainEvents;
	}
}
@Service
public class ApplicationService {
    @Resource
    private DomainRepository domainRepository;
    @Resource
    private DomainEventPublisher publisher;
    /**
     * 应用层接收聚合根返回的领域事件并进行后续处理
     */
    public void doBusiness (Command cmd) {
        EntityId entityId = new EntityId(cmd.getEntityId());
        Entity entity = domainRepository.load(entityId);
        List<DomainEvent> domainEvents = entity.doBusiness(cmd.getValue());
        domainRepository.save(entity);
        // 注意:此处publisher和domainRepository存在分布式事物的问题
        // Repository可能正常提交,但是publisher发送失败,造成消息丢失
        publisher.publish(domainEvents);
    }
}
缺点
  1. 分布式事务问题:发布领域事件和保存聚合根这两个操作存在分布式事物的问题。可能会出现这样的情况,成功保存聚合根,但是发送领域事件失败,造成消息丢失
  2. 返回值被篡改:原本可能是void,基本数据类型或者值对象,但现在都被改成了领域对象

聚合根提供抽取领域事件的方法

介绍

在聚合根内定义一个用于存在事件的集合字段,通常是一个Collection类型的事件集合,当聚合根生成事件时,将事件存放到该事件的聚合中,然后由聚合根提供一个通用的方法进行领域事件抽取,通过该方法可以获得已保存刀事件集合中的领域事件

使用建议

由于抽取领域事件的方法具有通用性,所有的聚合根都可能需要进行领域事件的抽取,因此,可以定义一个抽象超类型类,该抽象类中定义了用来存放事件的字段,以及注册事件,抽取事件的方法

public abstract class AbstractAggregateRoot {
    private List<DomainEvent> domainEvents = new ArrayList();
    /**
     * 注册领域事件
     */
    public void registerDomainEvent (DomainEvent event) {
        this.domainEvents.add(event);
    }
    /**
     * 获得领域事件
     */
     public List<DomainEvent> getDomainEvents () {
         return Collections.unmodifiableList(this.domainEvents);
     }
}
伪代码
public class AggregateRoot extends AbstractAggregateRoot {
    public void doBusiness (ValueObject valueObject) {
        // 1.业务逻辑处理
        // 2.创建领域事件
        List<DomainEvent> domainEvents = createDomainEvent();
        // 3.注册保存起来
        for (DomainEvent e : domainEvents) {
            super.registerDomainEvent(e);
        }
    }
}
@Service
public class ApplicationService {
    @Resource
    private DomainRepository domainRepository;
    @Resource
    private DomainEventPublisher publisher;
    /**
     * 应用层获取聚合根的事件集合后发布
     */
    public void doBusiness (Command cmd) {
		AggregateRoot root = repository.load(entityId);
		root.doBusiness(cmd.getValue);
		repository.save(root);
		// 抽取聚合根的领域事件
		List<DomainEvent> domainEvents = root.getDomainEvents();
        // 注意:此处publisher和domainRepository存在分布式事物的问题
        // Repository可能正常提交,但是publisher发送失败,造成消息丢失
        publisher.publish(domainEvents);
    }
}

总结

  • 应用层创建领域事件这种方案实现起来非常简单,初期可以采用这种方案
  • 聚合根创建领域事件并直接调用基础设施进行发布的方案,容易造成新的贫血模型,不推荐使用
  • 聚合根业务方法返回领域事件的方案。耦合性不强,但是修改了方法的返回值,这会导致所有的业务方法都返回领域事件类型。不推荐使用
  • 聚合根创建领域事件并提供抽取领域事件方法的方案,规避了对聚合根方法的返回值的修改,也避免了造成新的贫血模型,推荐使用
  • 发布领域事件和保存聚合根这两个操作存在分布式事物的问题,可能造成领域事件的丢失

7.发布

介绍

发布领域事件和保存聚合根这两个操作存在分布式事物的问题,可能造成领域事件的丢失,这里咱们探讨如何可靠地发布领域事件

调用发布消息的方案之所以不可靠,是因为消息发布和本地数据库事务提交的两个操作不在一个事件中。要想可靠地发布消息,要么支持分布式事务,要么避免产生分布式事务问题

通常情况下,分布式事物的性能都不是很好,因此很少会选择支持分布式事务。我们讲重点介绍在避免分布式事务的前提下如何可靠地发布领域事件

事件存储

为了可靠地发布领域事件,我们可以考虑将领域事件消息发送的过程整合到本地事务中,作为本地事务的一部分:新增一张本地消息表(t_event表),用于记录待发布的领域事件,在同一个数据库事务中保存聚合根,并在本地消息表中保存领域事件。这种存储领域事件的技术实现即为事件存储(Event Store)

以下是表结构(省略了逻辑删除,创建人,创建时间,更新时间等字段):

create table 't_event' (
	'id' bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
	'event_id' varchar(64) NOT NULL COMMENT '事件ID',
	'event_data' varchar(4096) NOT NULL COMMENT '事件消息序列化后的JSON串',
	'event_time' datetime NOT NULL COMMENT '事件发布时间',
	'event_type' varchar(32) NOT NULL COMMENT '事件类型',
	'event_state' INF NOT NULL DEFAULT 0 COMMENT '事件状态,0:发布中,1:已发布',
	'version' bigint DEFAULT 1 COMMENT '乐观锁',
	PRIMARY KEY ('id'),
	UNIQUE INDEX unique_event_id (event_id)
)

一般event_id这一列上创建唯一索引以实现幂等,避免同一个领域事件被重复存储

引入事件存储机制后,需要对聚合根的Repository的save方法进行改造,使其在保存聚合根的时候也持久化领域事件

public class DomainRepository {
    // 注意事务操作
    @Transactional
    public void save (Entity entity) {
        // 2.获得领域事件
        List<DomainEvent> domainEvents = entity.getEvents();
        // 3.将领域事件转成事件表对应的数据模型
        List<Event> eventList = domainEvents.stream.map(de -> {
           // 具体转换过程省略,很简单 
        }).collect(Collectors.toList());
        // 4.持久化领域事件
        eventRepository.saveAl(eventList);
        // 5.将聚合根转换成数据模型
        DataModel dataModel = this.toDataModel(entity);
        // 6.保存数据模型
        articleRepository.save(dataModel);
    }
}

可靠发布:直接发布并轮询补偿

介绍

直接发布并轮询补偿。这种方案得实现思路是:为事件存储中得领域事件增加一个发布状态标识,该标识用于记录是否发布成功。应用层调用Repository完整聚合根状态保存和领域事件存储后,直接在应用服务中发布领域事件。如果发布成功,修改事件存储中领域事件的状态为已发布。此外,提供一个定时任务,定期到Event Store中检索超时未发布成功的事件,并将其读取出来再发布到消息队列中。发布成功后,也将领域事件的状态设置为已发布

asdasddq1e12easdasdrawio.png

伪代码
public class ApplicationService {
    @Resource
    private EventJdbcRepository eventJdbcRepository;
    
    public void doBusiness (Command cmd) {
        AggregateRoot root = repository.load(bizId);
        entity.doBusiness(cmd.getValue());
        repository.save(root);
        // 发布领域事件
        List<DomainEvent> domainEvents = entity.getDomainEvents();
        publisher.publish(domainEvents);
        // 通过事件的EntityId更新EventStore中事件的状态为已发布
        List<String> eventIds = domainEvents.stream()
        							.map(e->e.getEventId())
        							.collect(Collectors.toList());
     	eventJdbcRepository.publishSuccess(eventIds);
    }
}
public class Task {
    // TODO 1.扫描数据库超时未发布成功的领域事件
    // TODO 2.发布领域事件到消息中间件
    // TODO 3.修改数据库领域事件发布状态为已发布
}
优缺点
  • 优点:不需要额外部署中间件,成本低
  • 缺点:定时任务轮询数据库会给数据库造成一定压力,并且保证可靠发布的部分逻辑是耦合在系统里面的

可靠发布:事务日志拖尾

介绍

监听数据库的事务日志,以获取增量的新数据。可以通过引入CDC中间件来实现事务日志拖尾,常见的CDC中间件包括Debezium和Canal等

12312314124213123213io.png

优缺点
  • 优点:
    • 应用层不再需要手动发布领域事件,也不需要更新数据库事件表的发布状态,减轻了数据库压力
    • 不需要使用定时任务轮询数据库,减轻了数据库压力
    • 应用层不再需要关心领域事件的发布逻辑,减少了耦合和开发流程
  • 缺点:需要额外部署中间件,成本略高

8.订阅

介绍

领域事件呗发布到消息中间件后,对领域事件感兴趣的限界上下文可以进行订阅消费

领域事件的订阅者收到领域事件后,解析领域事件,调用自己的应用服务,执行相应的处理逻辑

领域事件订阅者收到领域事件消息后,也会调用应用服务来更新本地领域模型的状态,因此也可以将领域事件订阅者放置在用户接口层

注意

  1. 在实际开发中,有些开发者将消息订阅者放在应用层,这是值得商榷的,应用层不具备订阅中间件的职责,应用层提供应用服务给用户接口层调用
  2. 领域事件的订阅者需要在其处理逻辑中考虑是否支持幂等性。分布式系统中的消息传递存在不确定性,领域事件的发送过程可能会由多种原因而进行重试,导致同一个领域事件被发送多次,订阅者需要考虑重复接收相同领域事件的情况

伪代码

这里展示以Apache Kafka和Spring Kafka为例探讨领域事件订阅者的基本实现逻辑

领域事件订阅者接收领域事件,调用应用层完成业务逻辑,属于用户接口层。因此,可以将领域事件订阅者的包名叫做user-interface-subscriber。以下是某个领域事件订阅者的伪代码:

/**
 * 领域事件订阅者
 */
@Component
public class DomainEventSubscriber {
    @Resource
    private ApplicationService applicationService;
    
    @KafkaListener(topics = "domain_event_topic",groupId = "local_consumer_group_id")
    public void subscribe (String event) {
        // 解析得到领域事件
        DomainEvent domainEvent = JSON.parse(event,DomainEvent.class);
        // 拼装Command
        Command command = this.toCommand(domainEvent);
        // 应用层执行领域模型状态变更
        applicationService.handleCommand(command);
    }
}

9.幂等

忽略,请参考专门讲解幂等的文章即可,没有区别