领域事件的学习

领域事件的学习

绅士君 绅士君 2026-10-20 103 技术

问题场景

开发中常会出现两个模块互相需要的情况。以聊天系统为例:

  • 聊天模块需要用户模块:展示群成员昵称、头像,校验用户是否存在。
  • 用户模块需要聊天模块:注册成功后,默认把新用户拉进"聊天大厅"(或新手群)。

依赖方向一旦画成 chat → system 和 system → chat,就形成了循环依赖。

三种"看起来能解决"的做法

"用 Spring 的三级缓存不就行了?"

三级缓存解决的是 Bean 之间的循环引用,而这里是 Maven 模块之间的循环——ssjcode-system 的 pom 里加上 ssjcode-chat,reactor 在编译期就会报 cycle,根本走不到 Spring 启动。退一步说,就算两个类在同一个模块里互相注入,Spring Boot 2.6 起默认 spring.main.allow-circular-references=false,启动即失败。所以这条路在工程上不是"能不能",而是"不该走":它把两个模块焊死成一块,改任何一边都要回归另一边。

"把编排提到上层 Controller。"

让注册和加群变成两个接口,由启动模块的 Controller 依次调用。确实能解开循环,但代价是 Controller 开始承担业务流程——它本该只做参数校验和协议转换。项目一大,这类"上层编排"会散落各处,没人说得清注册到底触发了几件事。

"惰性初始化:注册不管群,用户第一次用群功能时再补。"

零耦合、零事件,最省事。但"注册即在新手群里"这类运营需求(进群要发欢迎消息、要在群成员列表里立刻可见)就满足不了——用户不点开群,他永远不在群里。

领域事件

关键转变是:用户模块只宣告"注册成功了"这件事,不关心谁在乎它。

复制代码
system ──发布──▶ UserRegisteredEvent ◀──监听── chat
   (依赖方向不变:chat → system,事件类放在两者共同依赖的 common-core)

1. 在核心模块定义事件

java 复制代码
package com.it.ssjcode.common.core.event;

/** 用户注册成功事件 */
public record UserRegisteredEvent(Long userId) {}

两个细节:

  • 事件类必须放在 common-core(项目核心模块,所有子模块都需要共同依赖它) 这类双方都依赖的下层模块,否则解耦是假的。
  • 载荷只放 userId,不要放 SysUser 实体。一旦引用实体,common-core 就反向依赖了 system,循环绕了个更大的圈子回来了。record 天然不可变,正适合当事件。

2. 注册流程只负责发布

java 复制代码
private final ApplicationEventPublisher eventPublisher;

// ...
sysUserRoleMapper.insert(sysUserRole);
// 宣告"这个用户注册成功了",到此为止
eventPublisher.publishEvent(new UserRegisteredEvent(sysUser.getId()));

system 模块从此不认识 chat,编译期依赖里也没有它。

3. 聊天模块自己监听

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class UserRegisteredListener {

    private final ChatGroupService chatGroupService;

    /**
     * AFTER_COMMIT:主事务提交后才执行。
     *   ① 加群失败不会连带回滚用户注册;
     *   ② 注册被回滚时也不会误加群(事件压根不会发布出去)。
     * REQUIRES_NEW:给入群逻辑自己开一个事务,与注册主事务完全隔离。
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void onUserRegistered(UserRegisteredEvent event) {
        try {
            chatGroupService.joinDefaultGroup(event.userId());
        } catch (Exception e) {
            // 同步监听器仍在请求线程里,不兜住异常会让注册接口返回 500
            log.error("新用户初始化入群失败, userId={}", event.userId(), e);
        }
    }
}

值得注意的是

① AFTER_COMMIT 阶段已经没有活动事务了。

所以 REQUIRES_NEW 在这里不是"挂起外层事务再新建一个",而是"凭空给自己开一个"。不加它,joinDefaultGroup 里的多条写 SQL(插成员、更新群人数、写欢迎消息)会各自 autocommit,中途失败就留下半成品数据;加了它,至少入群这件事自己要么全成要么全回滚。

② 没有事务时,事件根本不会触发。

@TransactionalEventListener 默认只在有事务上下文时执行。register() 上有 @Transactional 所以正常,但哪天有人把它摘了,入群会静默失效——不报错、不留日志。要兜住这种退化,加 fallbackExecution = true。

方案对比

方案 解开循环 业务下沉 Controller 支持"注册即入群" 可观测性 适用
直接互相依赖 ✗ 编译失败 — ✓ 好 不存在这个选项
上层 Controller 编排 ✓ ✗ 污染 ✓ 好 流程短、就两三步
惰性初始化 ✓ ✓ ✗ 中 无运营时效要求
接口回调(UserLifecycleHandler) ✓ ✓ ✓ 好(显式) 想让调用链看得见
领域事件 ✓ ✓ ✓ 中(隐式) 一个时刻多方响应

接口回调那条值得记一下:在 common-core 定义 void onRegistered(Long userId),chat 提供实现,system 注入 List<UserLifecycleHandler> 遍历调用。它和事件能力等价,只是控制流显式、单测好写。团队嫌"注解里藏逻辑"难查时,选它。

分布式

以后如果需要升级项目到分布式,只要修改发送和接收方式即可。拆服务时改动被压在最外层:

java 复制代码
// 发布端:ApplicationEventPublisher → MQ
rocketMQTemplate.convertAndSend("user-registered", new UserRegisteredEvent(userId));

// 接收端:@TransactionalEventListener → MQ 监听器
@RocketMQMessageListener(topic = "user-registered", consumerGroup = "chat-service")

业务代码一行不动。这才是领域事件真正的回报——不是"少写一个依赖",而是注册这件事的下游变化不再需要修改注册。

总结

模块之间不应该有相互帮助的关系,最多只能单向依赖,所以为了避免这种情况,改为模块完成动作时发布对应事件,让其他模块按需处理对应的事件。发布方只描述事实,订阅方各自决定怎么响应,依赖方向就永远是单向的。

期待能留下你的脚印~

登录后即可发表评论~去登录
还没有评论,抢个沙发~

© 2026 绅士次元 · 个人博客

本网站一切内容不代表本站立场,并不代表本站赞成其观点和对其真实性负责。

如无意中侵犯了某个企业或个人知识产权,请及时通过电子邮件(3115355853@qq.com)告知我们,本网站将立刻给予删除。