事件总线下多模块业务原子性问题
事件总线下多模块业务原子性问题
[!info] related notes
事件总线下多模块业务原子性问题
我有一个问题,在一个项目中,使用了 DDD 架构 然后有 reminder 模块 和 schedule 、notification模块。他们之间通过事件总线连接, 我要怎么测试 比如 启用一个 reminder 模块,此时应该要 创建一个 对应的 scheduleTask 实体对象(根据时间配置执行trrigger,触发 notification)。 所以 我要测试 reminder 提醒的 业务流 需要 跨越三个模块, 禁用 reminder 时,也要确保 scheduleTask 实体进入 disabled 状态 。 我在业务中要怎么确保业务完整性,或者怎么测试
这是一个非常经典的 DDD(领域驱动设计)与 EDA(事件驱动架构)相结合的场景。你的核心挑战在于:如何在解耦的模块之间,既保证业务流程的最终一致性,又能编写可靠的自动化测试。
由于模块间通过“事件总线”通信,这意味着业务流程是异步且最终一致的。你不能像测试单体应用那样在一个事务里断言所有状态。
以下我将从 “架构设计保障” 和 “测试策略实施” 两个维度为你解答。
一、 架构设计层面:如何确保业务完整性?
在测试之前,必须先确保架构本身能处理分布式/解耦带来的问题。
1. 事务性发件箱模式 (Transactional Outbox Pattern)
这是确保“Reminder 保存成功”与“事件发送成功”原子性的关键。
- 问题: 如果 Reminder 存入数据库后,消息队列挂了,事件没发出去,Schedule 就永远不会创建任务。
- 解决: 在 Reminder 的同一个数据库事务中,既保存
Reminder实体,也将要发送的事件保存到一张Outbox表中。然后由一个独立的后台进程(Relay)读取 Outbox 表并推送到事件总线。
2. 幂等性 (Idempotency)
- 问题: 假如消息中间件因为网络抖动重发了
ReminderEnabled事件,Schedule 模块不能创建两个重复的任务。 - 解决: Schedule 模块在消费消息时,必须检查唯一性。通常利用
reminder_id作为幂等键(Idempotency Key)。如果数据库中已经存在对应reminder_id的 ScheduleTask,则忽略或更新,而不是新建。
3. 关联 ID (Correlation ID)
为了跨越三个模块追踪业务流,每个事件都必须携带一个 TraceId 或 CorrelationId。当 Reminder 触发时生成 ID,Schedule 和 Notification 处理时都透传这个 ID。这对于排查问题和链路追踪至关重要。
二、 测试策略:分层测试
针对你的跨模块流程,建议采用 “测试金字塔” 策略,重 Unit Test,轻 E2E。
1. 单元测试 (Unit Test) - 聚焦领域逻辑
不要在单元测试中测试跨模块交互。只测试当前上下文的职责。
- Reminder Context:
- 测试点: 调用
reminder.enable()后,断言该实体不仅状态变为 Enabled,而且在其domainEvents()列表中包含了一个ReminderEnabled事件。 - 不涉及: 此时不关心事件是否发出去,只关心实体是否产生了正确的事件数据。
- 测试点: 调用
- Schedule Context:
- 测试点: 编写一个 Handler 测试。输入一个构造好的
ReminderEnabled事件对象,调用 Schedule 的 Application Service,断言 Schedule 数据库(或 Mock 的 Repository)中生成了对应的ScheduleTask。 - 重点: 测试幂等性(输入两次同样的事件,只产生一条数据)。
- 测试点: 编写一个 Handler 测试。输入一个构造好的
2. 模块集成测试 (Module Integration Test) - 聚焦组件协作
这里使用内存级组件来模拟整个流程,速度快且稳定。如果你使用的是 Java (Spring Boot),可以使用 Spring Modulith 或简单的 ApplicationEventPublisher。
- 场景: 启用 Reminder 触发 Schedule。
- 测试手段:
- 启动 Spring 上下文(包含 Reminder 和 Schedule 模块,使用 H2 或 TestContainers 数据库)。
- 使用内存事件总线(Mock 掉真实的 Kafka/RabbitMQ,改用内存传递)。
- Action: 调用 Reminder Service 的
enable(id)方法。 - Wait: 因为是异步的,使用
Awaitility库进行等待。 - Assert: 查询 Schedule 的 Repository,确认 Task 已创建。
代码示例 (伪代码/Java风格):
@Test
void shouldCreateScheduleTask_WhenReminderIsEnabled() {
// 1. 准备数据
var reminderId = createDisabledReminder();
// 2. 执行动作 (在 Reminder 模块)
reminderService.enable(reminderId);
// 3. 异步断言 (跨越到 Schedule 模块)
// 使用 Awaitility 等待,直到条件满足或超时
await().atMost(5, SECONDS).untilAsserted(() -> {
// 直接查询 Schedule 的数据库
var tasks = scheduleRepository.findByReminderId(reminderId);
assertThat(tasks).hasSize(1);
assertThat(tasks.get(0).getStatus()).isEqualTo(TaskStatus.PENDING);
});
}
3. 契约测试 (Consumer-Driven Contract Testing) - 防止接口破坏
- 风险: Reminder 修改了事件结构(比如把
userId改成了operatorId),但 Schedule 没改,集成测试可能没覆盖到所有字段,上线后 Schedule 解析失败。 - 解决: 使用 Pact 或 Spring Cloud Contract。Schedule 定义它需要什么样的事件格式(契约),Reminder 每次构建时都会验证它发出的事件是否符合这个契约。
更系统的整理可继续看 契约测试与 CDC。
4. 端到端测试 (E2E) - 少量,覆盖核心路径
在类似生产的环境(连接真实的 MQ 和 DB)中跑通全流程。
- 场景: 完整的“启用 -> 调度 -> 通知”流程。
- 测试点: 用户 API 启用 Reminder,等待几秒,断言 Notification 模块记录了一条发送记录。
- 注意: 这种测试维护成本高,仅覆盖“快乐路径”(Happy Path)。
三、 针对“禁用 Reminder”的具体测试设计
你提到的需求:“禁用 reminder 时,也要确保 scheduleTask 实体进入 disabled 状态”。
这是一个典型的最终一致性场景。
测试步骤设计:
- Given (前置条件):
- 系统中已存在一个 Enabled 的 Reminder。
- 系统中已存在一个对应的 Pending 状态的 ScheduleTask。
- When (动作):
- 调用
reminderService.disable(reminderId)。
- 调用
- Then (断言):
- 即时断言: Reminder 状态变为 Disabled。
- 即时断言: Reminder 产生了一个
ReminderDisabled事件。 - 异步断言 (使用 Awaitility):
- 查询 Schedule 模块:找到对应
reminderId的 ScheduleTask。 - 验证该 Task 的状态已变为
DISABLED(或者CANCELLED)。
- 查询 Schedule 模块:找到对应
四、 总结建议
在 DDD 架构下,为了确保你描述的业务完整性,建议采取以下优先级的措施:
- 代码实现: 严格遵循 “发布-订阅” 模式,确保 Schedule 模块消费逻辑的幂等性。
- 基础设施: 如果对可靠性要求极高,实现 Outbox Pattern。
- 自动化测试: 重点编写 “模块间集成测试”(Integration Test)。不要过分依赖 E2E 测试。
- 使用内存数据库 + 内存事件总线。
- 使用
Awaitility解决异步断言问题。 - 直接验证数据库状态,而不是通过 API 查询(因为 API 可能也是最终一致的)。
你可以为我做的下一步:
如果你的项目是基于 Java/Spring 的,我可以为你提供一个具体的 Awaitility 结合 Spring Events 的集成测试代码模版,或者为你解释如何使用 Spring Modulith 自动验证模块依赖。你需要吗?