soda docs v17.0.1

上下文传递

任何一个多服务系统都会遇到这个问题:一条请求在服务 A 触发,事件到了服务 B,日志里怎么还能看出它属于原来那次请求、是谁触发的?

soda 的答案是:上下文跟着负载走,而不是跟着线程走。


上下文字段

BaseCommand / BaseQuery / AbstractDomainEvent 都带这组字段:

字段类型含义
requestIdString一次请求的标识,贯穿整条链路
callerUidLong / String调用方用户 ID
userNameString用户名
authoritiesString权限串
jtiStringJWT 的 jti(令牌唯一标识)
jwtString原始 JWT
tenantIdString租户 ID
userInfoString扩展的用户信息
extensionString扩展字段
hopCountInteger异步跳转计数,递归保护用

事件还额外带 eventId、eventType、occurredOn。

提示

callerUid 在 BaseCommand 里是 Long,在 AbstractDomainEvent 里是 String —— 事件要跨服务序列化,字符串更安全。


三种载体

同一次调用的上下文,在框架里同时存在于三个地方:

text
① 命令/事件对象上的字段      ← 跨线程、跨服务靠它
② DomainEventContext(ThreadLocal)  ← 同一线程内随手取
③ MDC                        ← 日志

① 载荷携带 —— 跨线程、跨服务的那一份

DomainEventContext 是 ThreadLocal,换线程就没了。所以真正可靠的传递方式是:把上下文放进消息载荷本身。

事件从产生到消费,完整走一趟:

text
产生事件(聚合根 addPendingEvent)
   │  ← injectContext() 把 ThreadLocal 里的值写进事件字段
   ▼
事件对象 { eventId, eventType, requestId, userName, authorities, ... }
   │
   ▼  序列化(Redis 总线:JSON)
   ▼
跨服务传输
   │
   ▼  反序列化
   ▼
事件对象(字段完好)
   │  ← CqrsAroundHandler 从事件恢复 ThreadLocal
   ▼
EventHandler 里 DomainEventContext.getRequestId() 又能拿到了

这就是为什么事件必须能序列化 —— 上下文是跟着载荷走的。事件类需要有无参构造器、字段要有 getter/setter。

② ThreadLocal —— 同一线程内的那一份

java
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 的值会自动出现在日志格式里:

java
MDC.put("requestId", requestId);
MDC.put("username", userName);

配合 logback 的 %X{requestId} 就能打出:

text
requestId:8f3a...,method:CreateProducerCommandHandler-handle, ...,username:zhangsan

注入的四个时机

1. HTTP 入口 —— 你自己写切面

框架不替你读 HTTP 头(那样就绑死 Web 框架了)。你写一个切面读头、写命令:

java
if (args[0] instanceof BaseRequest) {
    ((BaseRequest) args[0]).setRequestInfo(requestId, userName);
}

2. Handler 切面 —— 框架自动

命令进 CommandHandler 时,框架把命令里的上下文搬进 ThreadLocal:

java
DomainEventContext.setRequestId(cmd.getRequestId());
DomainEventContext.setUserName(cmd.getUserName());
DomainEventContext.setJti(cmd.getJti());
DomainEventContext.setAuthorities(cmd.getAuthorities());

事件进 EventHandler 时,框架从事件里恢复:

java
DomainEventContext.setRequestId(event.getRequestId());
DomainEventContext.setUserName(event.getUserName());
// ...

3. 聚合根挂事件时 —— 框架自动

java
protected void addPendingEvent(AbstractDomainEvent event) {
    if (event != null) {
        injectContext(event);   // ← ThreadLocal → 事件字段
        domainEvents.add(event);
    }
}

injectContext 只填还没填的字段(if (event.getRequestId() == null)),所以你可以手动覆盖:

java
ProducerEvent event = new ProducerEvent("CREATE_PRODUCER", this);
event.setRequestId("custom-id");   // ← 手动设了,injectContext 不会覆盖
addPendingEvent(event);

4. 仓储切面 —— 框架补齐

保存时,框架再补一次上下文(主要是 hopCount):

java
Integer hopCount = DomainEventContext.getHopCount();
if (hopCount != null) {
    e.setHopCount(hopCount);
}

上下文回填

有一处容易忽略:命令总线切面会回填上下文。

java
// 命令自己没带 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 提供了幂等支持:

yaml
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 命令绕过回填

下一步