大家好,我是Jensen。一个想和大家一起打怪升级的程序员朋友。
在DDD项目的落地过程中,除了聚合、模型等等重要概念,领域事件在其中扮演了一个非常重要的角色,它不仅能解耦领域层与其他层,作为“跳出”领域层的跳板,还是一种策略模式的高级用法。即便你的项目没有DDD,领域事件在传统的MVC分层架构也大有妙用。
下面我们一起来解锁这个“解耦神器”。
1.什么是领域事件
领域事件是一种用于表示领域模型中发生的重要事件的机制。它们用于通知其他相关的聚合或服务,以便它们可以采取相应的行动。
领域事件通常由聚合根( Aggregate Root)发布。当聚合根内部发生重要的状态更改时,它会发布一个领域事件。其他聚合或服务可以订阅这些事件,并在事件发生时采取相应的行动。
以下是使用领域事件的四大步:
- 定义领域事件:领域事件是一个简单的对象,它包含事件的名称、发生时间和相关的数据。例如,一个订单已完成的领域事件可能包含订单的 ID 和完成时间。
- 发布领域事件:当聚合根内部发生重要的状态更改时,它会发布一个领域事件。例如,当订单完成时,订单聚合根会发布一个 OrderCompletedEvent 事件。
- 订阅领域事件:其他聚合或服务可以订阅领域事件,并在事件发生时采取相应的行动。例如,一个订单跟踪服务可以订阅 OrderCompletedEvent 事件,并在订单完成时发送通知给客户。
- 处理领域事件:当领域事件被发布时,订阅者会收到通知,并可以根据事件的数据采取相应的行动。例如,订单跟踪服务可以在收到 OrderCompletedEvent 事件时发送通知给客户。
领域事件的使用可以帮助保持领域模型的解耦和一致性。通过使用领域事件,不同的聚合或服务可以独立地处理事件,而不需要直接相互依赖。这有助于提高系统的可维护性和灵活性。
(以上内容由豆包AI生成,描述还是蛮契合的,理由我就不过多掩饰了)
2.领域事件的定义、发布与订阅
在DDD工程中,领域事件定义在领域层,具体来说是放在领域契约下面,如:domain.contract.event,它不属于某个聚合私有,由该系统下的所有聚合共享。
为什么要这样划分呢?
我认为,领域事件不仅能在领域层发布,也可能在应用层发布,甚至在接入层发布,而在领域聚合之外发布的事件,必然会存在跨聚合的事件属性。
我举个预约的场景:
工单中台下的预约业务需要设计一个支付回调接口,由商城系统支付成功后进行回调,此时商城系统传入的回调命令参数在处理完核心业务后(如设置预约单状态为待服务),再发布支付回调成功事件,以执行后续的非核心业务逻辑(比如提醒服务店员需要联系客户到店等等)。
工单中台和商城系统已然进行了服务拆分,工单中台本身并不包含支付业务,领域层(如领域服务)并没有发布这个支付回调成功的事件的入口,那么,发布领域事件的最佳位置是在应用层。
至此,事件的定义、事件的发布已经确定好了位置,但事件在哪里订阅也有讲究。
我在DDD落地过程中,曾多次调整领域事件订阅的位置,有试过放在领域层聚合下面,也有试过抽取到SDK工程里,最终在前段时间确定下来了,事件订阅就放在应用层的listener包下面,意为事件监听器。
至于命名规则,需要看系统的复杂度,一般小而美的微服务,以聚合Listener或以外部系统Listener命名足以,如工单中台(WorkOrder)下的预约领域聚合(Appointment),其监听器以AppointmentListener命名,订单领域聚合(Order)是商城系统(如Mall)外部聚合,其监听器以MallListener命名而非OrderListener。
特别强调一点,在高内聚的架构设计中,外部系统的调用不会设计特别多,如果存在大量的跨系统交互,我们该反思一下是不是微服务拆分得太细了,大量的外部系统调用会存在跨线程的分布式事务等问题等。
当然,随着业务快速发展,系统复杂度随之上升,事件监听listener也可能跟着拆分,这时候我们的原则还是往大了拆,不宜拆得太细。
对于非DDD工程,可以考虑在根目录定义一个event包,包括entity和listener:entity下定义领域事件,listener下定义领域事件监听器,这样一来我们写代码就更加简单清晰。
3.领域事件解耦实战
下图是我在DDD工程落地的案例,我们要先约定好代码放哪里才能更好地规划后续的编码工作。
上面所说的领域事件,一直停留在概念层面,事件的发布订阅只是设计模式,那具体要怎么实现,才是核心技术。
发布订阅有很多种实现方式,如Java自带的观察者模型java.util.Observer,事件驱动模型java.util.EventListener,还有基于第三方跨线程的消息队列模型(如Kafka、RabbitMQ、RocketMQ、Redis等),以及Spring的发布订阅模型SpringEvents。
在这里,我认为领域事件在工程内部解耦即可,用不上第三方跨线程的MQ模型,所以我选了SpringEvents作为发布订阅的底层实现,而且Spring事件有个好处,它可以在Idea工具中链接消息发布和订阅,对于编程还是非常友好的。
在系统内部事件满天飞的情况下,解耦完还能保证代码可读性,可谓是锦上添花。
SpringEvents的常规打开方式:
- 定义事件:定义一个事件类,该类应该继承自ApplicationEvent类。你可以在事件类中添加任何需要的数据,这些数据将在事件发布时传递给订阅者。
- 发布事件:使用ApplicationEventPublisher发布事件。你可以通过ApplicationContext获取ApplicationEventPublisher实例,并使用其publishEvent方法发布事件。
- 订阅事件:使用@EventListener注解来订阅事件。将@EventListener注解应用于一个方法上,并指定要订阅的事件类型。该方法将在事件发布时被调用,并接收事件对象作为参数。
领域事件还要解决一个问题,如果我们通过@Async+@EventListener实现异步监听,需要跨线程传递信息,那我们就要对领域事件做一层小小的封装了。
首先,写一个领域事件抽象类,该类由其他事件继承:
public abstract class DomainEvent extends ApplicationEvent {
// 本地线程变量池,用于存储跨线程信息
private final Map THREAD_LOCALS = ThreadContext.getValues();
/**
* 领域事件构造器
*
* @param source 事件内容
* @param 任意类型
*/
public DomainEvent(T source) {
super(source);
}
/**
* 获取事件内容
*
* @param 任意类型
* @return 事件内容
*/
public T get() {
ThreadContext.setValues(THREAD_LOCALS);
return (T) super.getSource();
}
/**
* 租户判断
* 使用方式:监听方法标注@EventListener(condition = "#event.tenantIn('xxx', 'xxx')")
*
* @param tenantIds 指定租户ID才能订阅
* @return 该租户能否监听
*/
public boolean tenantIn(String... tenantIds) {
ThreadContext.setValues(THREAD_LOCALS);
String tenantId = ThreadContext.getOrDefault("tenant-id", "");
return Arrays.asList(tenantIds).contains(tenantId);
}
}
以上代码,把本地线程变量存进了领域事件内,在监听器获取事件内容时,把本地线程变量塞到另一个线程里。
细心的同学发现,该类封装的tenantIn方法有什么作用?
这是为了控制指定的租户才能监听到该事件,比如某个租户需要监听下单完成后,推到他自己的ERP系统,但是其他租户并没有这个需求,那么我们就可以使用这种方式控制不同租户的行为,这样解耦也不会对业务主流程产生太大影响。
除了SaaS系统的租户隔离监听,我们也可以利用这一特性做些别的策略。
以上代码我们再抽象一轮:
/**
* 领域事件
* 1. 异步事件透传线程变量
* 2. 租户策略
* 3. 条件策略
*/
public abstract class DomainEvent extends ApplicationEvent {
// 本地线程变量池,用于存储跨线程信息
private final Map THREAD_LOCALS = ThreadContext.getValues();
/**
* 领域事件构造器
*
* @param source 事件内容
* @param 任意类型
*/
public DomainEvent(T source) {
super(source);
}
/**
* 获取事件内容
*
* @param 任意类型
* @return 事件内容
*/
public T get() {
ThreadContext.setValues(THREAD_LOCALS);
return (T) super.getSource();
}
/**
* 租户判断
* 使用方式:监听方法标注@EventListener(condition = "#event.tenantIn('xxx', 'xxx')")
*
* @param tenantIds 指定租户ID才能订阅
* @return 该租户能否监听
*/
public boolean tenantIn(String... tenantIds) {
ThreadContext.setValues(THREAD_LOCALS);
String tenantId = ThreadContext.getOrDefault("tenant-id", "");
return Arrays.asList(tenantIds).contains(tenantId);
}
// 监听者能否执行的条件,用于控制事件监听器能否执行(策略模式)
private Collection supports;
/**
* 领域事件构造器
*
* @param source 事件内容
* @param supports 支持执行的条件,配合supports方法使用
* @param 任意类型
*/
public DomainEvent(T source, Collection supports) {
super(source);
this.supports = supports;
}
/**
* 条件判断(策略模式)
* 使用方式:监听方法标注@EventListener(condition = "#event.supports('xxx', 'xxx')")
*
* @param supports 支持的类型
* @param 任意类型
* @return 该条件下能否监听
*/
public boolean supports(T... supports) {
if (this.supports == null) return false;
ThreadContext.setValues(THREAD_LOCALS);
List supportList = Arrays.asList(supports);
for (Object support : this.supports) {
if (supportList.contains(support)) {
return true;
}
}
return false;
}
/**
* 发布事件,方便但降低代码可读性
* 建议使用原生的SpringContext.getApplicationContext().publishEvent()方法
*/
public void publish() {
SpringContext.getApplicationContext().publishEvent(this);
}
}
我们加入了新的成员变量supports,有什么作用呢?来看一个消息中心的例子就一目了然。
业务需求是:消息中心需要写一个事件发布的接口,聚合站内信、极光推送、小程序订阅消息、公众号模板消息、邮件、短信功能等等,并且后续支持扩展。
首先设计一下整个消息中心,DDD领域图如下:
对应的领域事件定义和监听器:
领域事件定义:
public class PublishEventMessageEvent extends DomainEvent {
public PublishEventMessageEvent(EventMessage eventMessage) {
super(eventMessage, Collections.singleton(eventMessage.getPushChannel()));
}
}
发布事件的核心代码:
// 存储事件消息
EventMessage eventMessage = EventMessage.builder().eventCode(messageDefine.getEventCode()).notify(messageDefine.getNotify())
.pushChannel(pushChannel).content(contentCopy).target(targetCopy)
.categoryCode(messageDefine.getCategoryCode()).categoryName(messageDefine.getCategoryName())
.pushConfig(messageDefine.getPushConfig())
.build();
eventMessage.save();
// 发布事件消息事件
SpringContext.getApplicationContext().publishEvent(new PublishEventMessageEvent(eventMessage));
事件消息事件监听器:
/**
* 极光推送监听器
*/
@Component
public class JPushListener {
/**
* 发送极光消息
*
* @param event
*/
@EventListener(condition = "#event.supports('jpush')")
public void sendJPushMessage(PublishEventMessageEvent event) {
EventMessage eventMessage = event.get();
// 下面是核心的推送逻辑
}
}
上面以极光推送监听器为例,其他监听器也是同样的实现方式,后续如果还有别的推送实现,再写一个推送监听器即可,消息定义里把对应的推送通道pushChannel给加上。
需要注意的是,使用事件作为策略模式,一般是单向的通知,不宜接收监听器的返回结果做后续处理。你可能会说,那可以在事件的数据里定义返回值啊,方法层传递引用对象就行了,但再细想一下,如果在推送监听器上做了异步处理,那由事件发布者处理这个结果就变得不可控了。
4.写在最后
基于SpringEvents实现的领域事件作为一种跨层解耦的手段,可以让我们的代码可读性变得更高,扩展性更强,无论新老项目都是使用即见效的举措。
上述领域事件DomainEvent已集成到我的D3Boot开源基础框架,大家需要可以移步Gitee抄作业。
Gitee源码地址:
https://gitee.com/jensvn/d3boot(例行赊Star)
D3boot基础框架具体的使用方式见源码的README.md文件,这里不再赘述。