领域事件的学习
问题场景
开发中常会出现两个模块互相需要的情况。以聊天系统为例:
- 聊天模块需要用户模块:展示群成员昵称、头像,校验用户是否存在。
- 用户模块需要聊天模块:注册成功后,默认把新用户拉进"聊天大厅"(或新手群)。
依赖方向一旦画成 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")
业务代码一行不动。这才是领域事件真正的回报——不是"少写一个依赖",而是注册这件事的下游变化不再需要修改注册。
总结
模块之间不应该有相互帮助的关系,最多只能单向依赖,所以为了避免这种情况,改为模块完成动作时发布对应事件,让其他模块按需处理对应的事件。发布方只描述事实,订阅方各自决定怎么响应,依赖方向就永远是单向的。
