“解耦神器”之SpringEvents领域事件

2024年 3月 8日 50.5k 0

大家好,我是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文件,这里不再赘述。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论