上下文传递
任何一个多服务系统都会遇到这个问题:一条请求在服务 A 触发,事件到了服务 B,日志里怎么还能看出它属于原来那次请求、是谁触发的?
soda 的答案是:上下文跟着负载走,而不是跟着线程走。
上下文字段
BaseCommand / BaseQuery / AbstractDomainEvent 都带这组字段:
| 字段 | 类型 | 含义 |
|---|---|---|
requestId | String | 一次请求的标识,贯穿整条链路 |
callerUid | Long / String | 调用方用户 ID |
userName | String | 用户名 |
authorities | String | 权限串 |
jti | String | JWT 的 jti(令牌唯一标识) |
jwt | String | 原始 JWT |
tenantId | String | 租户 ID |
userInfo | String | 扩展的用户信息 |
extension | String | 扩展字段 |
hopCount | Integer | 异步跳转计数,递归保护用 |
事件还额外带 eventId、eventType、occurredOn。
callerUid 在 BaseCommand 里是 Long,在 AbstractDomainEvent 里是 String —— 事件要跨服务序列化,字符串更安全。
三种载体
同一次调用的上下文,在框架里同时存在于三个地方:
① 命令/事件对象上的字段 ← 跨线程、跨服务靠它
② DomainEventContext(ThreadLocal) ← 同一线程内随手取
③ MDC ← 日志① 载荷携带 —— 跨线程、跨服务的那一份
DomainEventContext 是 ThreadLocal,换线程就没了。所以真正可靠的传递方式是:把上下文放进消息载荷本身。
事件从产生到消费,完整走一趟:
产生事件(聚合根 addPendingEvent)
│ ← injectContext() 把 ThreadLocal 里的值写进事件字段
▼
事件对象 { eventId, eventType, requestId, userName, authorities, ... }
│
▼ 序列化(Redis 总线:JSON)
▼
跨服务传输
│
▼ 反序列化
▼
事件对象(字段完好)
│ ← CqrsAroundHandler 从事件恢复 ThreadLocal
▼
EventHandler 里 DomainEventContext.getRequestId() 又能拿到了这就是为什么事件必须能序列化 —— 上下文是跟着载荷走的。事件类需要有无参构造器、字段要有 getter/setter。
② ThreadLocal —— 同一线程内的那一份
DomainEventContext.setRequestId("req-123");
DomainEventContext.getRequestId(); // "req-123"
DomainEventContext.setUserName("张三");
DomainEventContext.getUserName();
DomainEventContext.setJti("...");
DomainEventContext.getJti();
DomainEventContext.setAuthorities("admin,user");
DomainEventContext.getAuthorities();
DomainEventContext.setCallerUid("1001");
DomainEventContext.getCallerUid();
DomainEventContext.setHopCount(1);
DomainEventContext.getHopCount();
DomainEventContext.clear(); // ← 清理DomainEventContext.clear() 必须在 finally 里调。线程池会复用线程 —— 不清理,上一个请求的用户身份就会泄漏给下一个请求。这不是理论问题,是真实发生过的事故类型。
框架在 CqrsAroundHandler 里已经替你做了清理。
③ MDC —— 给日志用
MDC 的值会自动出现在日志格式里:
MDC.put("requestId", requestId);
MDC.put("username", userName);配合 logback 的 %X{requestId} 就能打出:
requestId:8f3a...,method:CreateProducerCommandHandler-handle, ...,username:zhangsan注入的四个时机
1. HTTP 入口 —— 你自己写切面
框架不替你读 HTTP 头(那样就绑死 Web 框架了)。你写一个切面读头、写命令:
if (args[0] instanceof BaseRequest) {
((BaseRequest) args[0]).setRequestInfo(requestId, userName);
}2. Handler 切面 —— 框架自动
命令进 CommandHandler 时,框架把命令里的上下文搬进 ThreadLocal:
DomainEventContext.setRequestId(cmd.getRequestId());
DomainEventContext.setUserName(cmd.getUserName());
DomainEventContext.setJti(cmd.getJti());
DomainEventContext.setAuthorities(cmd.getAuthorities());事件进 EventHandler 时,框架从事件里恢复:
DomainEventContext.setRequestId(event.getRequestId());
DomainEventContext.setUserName(event.getUserName());
// ...3. 聚合根挂事件时 —— 框架自动
protected void addPendingEvent(AbstractDomainEvent event) {
if (event != null) {
injectContext(event); // ← ThreadLocal → 事件字段
domainEvents.add(event);
}
}injectContext 只填还没填的字段(if (event.getRequestId() == null)),所以你可以手动覆盖:
ProducerEvent event = new ProducerEvent("CREATE_PRODUCER", this);
event.setRequestId("custom-id"); // ← 手动设了,injectContext 不会覆盖
addPendingEvent(event);4. 仓储切面 —— 框架补齐
保存时,框架再补一次上下文(主要是 hopCount):
Integer hopCount = DomainEventContext.getHopCount();
if (hopCount != null) {
e.setHopCount(hopCount);
}上下文回填
有一处容易忽略:命令总线切面会回填上下文。
// 命令自己没带 requestId → 从 ThreadLocal 拿
if (cmd.getRequestId() == null) {
String requestId = DomainEventContext.getRequestId();
if (requestId != null) {
cmd.setRequestId(requestId);
cmd.setUserName(DomainEventContext.getUserName());
// ...
}
}这解决了一个真实问题:在事件消费者里发新命令时,新命令是空的,但当前线程的 ThreadLocal 里还留着上游事件带过来的上下文。回填让新命令自动接上链路,requestId 不会断。
跨线程会断吗
会,但可控。
DomainEventContext 是 ThreadLocal,切到新线程就没了。所以:
- 异步任务里要手动搬运:
DomainEventContext.setRequestId(old.getRequestId()) - 异步命令(
sendAsyncCommand)走的是框架的切面 —— 命令对象自带上下文字段,所以切线程后依然在载荷上,事件照样能拿到
BusFacade.sendAsyncCommand 捕获的是 CommandContext(旧版上下文载体)并在异步线程里恢复,用完在 finally 里清理。DomainEventContext 那套是新的上下文机制,由切面负责。
判断标准很简单:上下文跟着"消息"走就不断,跟着"线程"走就断。 框架里凡是跨边界传递的载体(命令、查询、事件)都带上下文字段,就是为了这个。
上下文与事件幂等
跨服务投递时,同一条事件可能被重复投递(网络重试、消费者重启)。soda-event-redis 提供了幂等支持:
soda:
event:
redis:
stream:
idempotency:
enabled: true
redis-key-prefix: soda-events-idempotency
expire-time: 86400幂等键就是事件 ID —— 所以事件 ID 必须稳定,不能每次重新生成。AbstractDomainEvent 的无参构造器会用雪花算法生成 eventId,这保证了同一事件重投时 ID 不变。
排查上下文丢失
按这个顺序查:
| 现象 | 检查 |
|---|---|
| 日志里 requestId 是空的 | Controller 切面有没有把 Soda-Request-Id 塞进命令 |
| 命令里有 requestId 但事件里没有 | 事件是不是手工 new 出来但没走 addPendingEvent |
| 事件里有但消费者里取不到 | 事件类是不是漏了无参构造器 / 字段没序列化 |
| 消费端串了别的用户 | ThreadLocal 没清理,检查自己有没有绕过框架切面起线程 |
| 新命令 requestId 断了 | 是不是手动 new 命令绕过回填 |