Reactor 的 Context 设计确实非常反直觉,因为它颠覆了我们习惯的“命令式编程”中自上而下的传参思维。
为了回答这个问题,我们需要穿过代码表象,去探究 Reactor 核心团队(如 Stéphane Maldini 和 Simon Baslé)在设计 Context 时的核心矛盾和取舍。
第一部分:架构师视角 —— 为什么要这么设计?
“逻辑定义在 Publisher 里,为什么要依赖下游(Subscriber)来设置 Context?谁能保证下游一定设置了?”
要理解这一点,必须接受响应式流的一个核心公理:Publisher 只是蓝图(Blueprint),Subscription 才是运行时(Runtime)。
1. 解耦“定义”与“执行” (Decoupling Definition from Execution)
想象一下 ThreadLocal。当你写一个方法 void saveData() 时,你并不把 Connection 传进去,而是假定“当前运行这个方法的线程”里已经有了 Connection。
- ThreadLocal 逻辑: 运行环境(线程)决定了上下文。
- Reactor 逻辑: 谁订阅(Subscribe),谁就是运行环境。
设计思考:
如果我们在 Publisher 里硬编码设置 Context,那么这个 Publisher 就“脏”了,它被绑定死在一个具体的环境里了。
Reactor 希望 Publisher 是无状态的、可复用的。
举例:
假设你有一个
Mono<Void> saveOp = repository.save(user);
- 场景 A(生产): Controller 调用
saveOp,通过 WebFilter 在下游注入了“生产库连接”。- 场景 B(测试): 单元测试调用
saveOp,在下游注入了“Mock 连接”。如果 Context 是在 Publisher 内部定义的(自上而下),你就必须修改
saveOp的定义才能切换环境。而通过自下而上(Upstream Propagation) ,同一个saveOp实例可以被不同的下游订阅,每次订阅自动获得不同的 Context。
2. 作用域的本质:Context = Subscription Scope
在响应式流中,没有“全局”的概念,只有“订阅链”的概念。
- 数据流(Data Flow) :是从上游流向下游的(OnNext)。
- 控制流(Control Flow) :是从下游流向上游的(Subscribe / Request)。
核心矛盾: 事务(Transaction)或安全令牌(Token)这种东西,它的生命周期应该覆盖整个业务处理过程。
- 当我们在链条最末端(WebFlux 框架层)收到 HTTP 请求时,我们才确定了“这是一个什么用户的请求,需要什么事务”。
- 这个时刻,是在
subscribe()发生的瞬间确定的。 - 因此,上下文必须伴随着
subscribe()动作,逆着数据流的方向,一层层传给上游的数据库操作符。
3. 回答你的疑问:谁保证 ContextWrite 一定被调用?
你担心:“如果上游依赖 Context,但下游没人写 contextWrite 怎么办?”
答案是:框架层保证。
在 Spring WebFlux 中,你几乎看不到手写 contextWrite 的代码。
- Spring Security:在
WebFilter里,在整个处理链的最外层(也就是最下游),偷偷帮你执行了contextWrite(SecurityContext)。 - Spring TX:在
@Transactional切面中,通过操作符拦截,帮你执行了contextWrite(TransactionContext)。
对于业务开发者来说,Context 是透明的。你只管在上游用 deferContextual 取,框架负责在下游(入口处)存。这和 ThreadLocal 在 Tomcat 线程池入口处赋值是一样的道理。
第二部分:源码级别解析 —— 架构与流转
接下来我们深入 Reactor Netty / Core 的源码,看看 Context 到底长什么样,以及它是怎么“逆流而上”的。
1. 数据结构:Context 是什么?
在 reactor-core 中,Context 本质上是一个高度优化的、不可变的 LinkedHashMap。
由于 Context 的读写频率极高(每个操作符都会透传它),Reactor 团队没有直接用 HashMap(太重,且线程不安全),而是设计了 ContextN。
- Context0: 空上下文。
- Context1: 存 1 个 KV。
- Context2: 存 2 个 KV。
- ...
- ContextN: 超过 5 个 KV 时使用 Map 实现。
源码特征(Immutable):
Java
// 伪代码演示设计思想
interface Context {
// 写入不是修改当前对象,而是返回一个新的!
Context put(Object key, Object value);
<T> T get(Object key);
}
每次调用 contextWrite,都会像洋葱一样包一层,生成一个新的 Context 对象,旧的对象保持不变。这保证了在并发分支(比如 flatMap 里的多线程)中,Context 是绝对线程安全的。
2. 存储位置:CoreSubscriber
Context 存在哪里?它不是存在 Thread 里,而是存在 Subscriber 对象里。
Reactor 定义了一个扩展接口 CoreSubscriber,它是所有 Reactor 内部 Subscriber 的基类:
Java
// reactor.core.CoreSubscriber
public interface CoreSubscriber<T> extends Subscriber<T> {
// 【核心】每个订阅者必须携带一个 Context
default Context currentContext() {
return Context.empty();
}
}
这意味着,Reactor 调用链上的每一个节点(Operator Subscriber) ,都持有 Context。
3. 运行流程:逆流而上的传递
我们通过一个最简单的链路来追踪:
Source -> map -> contextWrite -> Subscriber
代码:
Java
Mono.just("data") // 1. Source
.map(d -> d) // 2. MapOperator
.deferContextual(ctx -> ..)// 3. Access (读取)
.contextWrite(k, v) // 4. Write (写入)
.subscribe(); // 5. Downstream
Step 1: 组装阶段(Assembly)
创建了一串包装对象:ContextWrite(Map(Source))。
Step 2: 订阅阶段(Subscribe - 自下而上)
这是 Context 传递的关键时刻!
-
用户调用
subscribe()。 -
ContextWriteSubscriber被激活。- 它的构造函数接受了下游传来的
Context(可能是空的)。 - 关键动作: 它执行
put(k, v),生成了一个新的 Context(包含 k=v)。 - 它调用上游的
subscribe(this),并重写了currentContext()方法,返回这个新 Context。
- 它的构造函数接受了下游传来的
-
MapSubscriber被激活。- 它调用
actual.currentContext()。因为它的actual是上面的ContextWriteSubscriber,所以它拿到了包含 k=v 的 Context。 - 它继续向上调用
source.subscribe(this),透传这个 Context。
- 它调用
-
MonoDeferContextual被激活。- 这是一个特殊的 Source。当它被
subscribe时,它不发数据,而是直接看actual.currentContext()。 - Bingo! 它看到了 k=v。于是它执行你的 Lambda 逻辑,把 Context 里的 Connection 拿出来。
- 这是一个特殊的 Source。当它被
源码模拟(简化版):
Java
// 代表 contextWrite 操作符的 Subscriber
class ContextWriteSubscriber<T> implements CoreSubscriber<T> {
final CoreSubscriber<? super T> actual; // 下游
final Context context; // 当前层合并后的 Context
public ContextWriteSubscriber(CoreSubscriber<? super T> actual, Context toMerge) {
this.actual = actual;
// 【关键】合并下游的 Context 和当前要写入的 Context
this.context = actual.currentContext().putAll(toMerge);
}
@Override
public void onSubscribe(Subscription s) {
actual.onSubscribe(s);
}
@Override
public Context currentContext() {
// 【关键】当上游问我要 Context 时,我给它这个合并后的
return this.context;
}
}
当手动切换线程时,Context是否依然能保证多线程间传递正确性
事实上, “在线程频繁切换时依然能保持上下文传递” ,正是 Reactor 放弃 ThreadLocal 而发明 Context 的根本原因。
我们通过三个层面来彻底解开这个疑惑:
一、 核心原理:Context 绑定的是“对象”,而不是“CPU 核心”
在 Java 堆内存中,对象(Object) 是跨线程共享的。
- ThreadLocal 是绑定在
Thread.currentThread()这个“动态变量”上的。 - Context 是绑定在
Subscriber这个“静态对象”上的(注:指引用关系上的静态,非static关键字)。
形象的比喻:
-
ThreadLocal(本地坐席) :你把钱包放在了**“1 号会议室”**。如果你被调度去“2 号会议室”开会,你就拿不到钱包了。
-
Context(随身背包) :你把钱包放在了你的**“背包”**里。
- 即使
publishOn把你(Subscriber)从“1 号会议室”赶到了“2 号会议室”。 - 即使
subscribeOn让你一开始就在“3 号会议室”排队。 - 不管你在哪个房间,背包(Context)是背在你身上的,你走到哪,钱包就在哪。
- 即使
二、 场景推演:线程切换时发生了什么?
假设我们有这样一段代码:
Java
Mono.deferContextual(ctx -> {
// 3. 在这里获取 Connection
System.out.println("执行线程:" + Thread.currentThread().getName());
return Mono.just(ctx.get("conn"));
})
// 2. 强制切换线程!
.publishOn(Schedulers.boundedElastic())
// 1. 写入 Context
.contextWrite(ctx -> ctx.put("conn", "MyConnection"))
.subscribe();
1. 订阅阶段 (Subscription Phase) —— Context 的传递
Context 是在 subscribe() 时从下往上传递的。
-
Step A (
subscribe) : 触发。 -
Step B (
contextWrite) : 创建了包含 "MyConnection" 的新 Context。 -
Step C (
publishOn) :publishOn也是一个 Operator,它内部有一个PublishOnSubscriber。- 当下游把 Context 传给它时,它把这个 Context 存在了自己的字段里(或者它持有下游 Subscriber 的引用,通过引用能找到 Context)。
- 它继续向上游
subscribe,把这个 Context 递上去。
-
Step D (
deferContextual) : 此时 Context 已经传递到了顶层。
结论: 在“组装管道”的阶段(订阅流),Context 已经顺着对象引用链(Linked List)传到了最上游。这个过程完全不受线程调度的影响,因为这只是对象之间的方法调用。
2. 运行阶段 (Execution Phase) —— 跨线程读取
现在,数据开始流动,或者逻辑开始执行。
publishOn发挥作用了。它告诉上游:“嘿,把任务交给我,我要换个线程去跑”。- Reactor 的调度器(Scheduler)启动一个新的线程(比如
boundedElastic-1)。 - 这个新线程开始执行
deferContextual里的 Lambda 表达式。
关键时刻:
在新线程里,代码执行 ctx.get("conn")。
这个 ctx 对象是从哪里来的?
- 它是
deferContextual在订阅阶段就拿到的那个对象。 - 这个对象存在于 Java 堆内存(Heap) 中。
- 所有线程都能访问堆内存里的对象。
只要 Subscriber 对象的引用还在,新线程就能通过引用访问到里面的 Context。不需要做任何“线程间数据拷贝”的操作(不像 InheritableThreadLocal 那样需要复制),因为对象本身就是共享的。
三、 源码视角的证据
如果我们看 publishOn 的源码(FluxPublishOn),会发现它的逻辑大致如下(简化版):
Java
// PublishOnSubscriber.java
static class PublishOnSubscriber<T> implements CoreSubscriber<T>, Runnable {
final CoreSubscriber<? super T> actual; // 下游订阅者
final Scheduler.Worker worker; // 新线程的调度器
@Override
public void onNext(T t) {
// 1. 收到上游数据,不做处理,放入队列
queue.offer(t);
// 2. 调度一个任务到新线程去执行
worker.schedule(this);
}
@Override
public void run() {
// --- 这里已经是新线程了 ---
T data = queue.poll();
// 3. 把数据推给下游
// 【重点】actual 是一个 Java 对象,它一直在这里,没变过
actual.onNext(data);
}
@Override
public Context currentContext() {
// 4. 如果有人问我要 Context
// 我就问下游(actual)要。即使我现在运行在新线程,actual 还是那个 actual。
return actual.currentContext();
}
}
解析:
当 run() 方法在新线程执行时,它依然持有 actual 这个成员变量。通过 actual,它就能顺藤摸瓜找到 Context。
总结
- Context 依然有意义:它是解决 Reactive 异步环境上下文丢失的唯一正解。
- 原理:
ThreadLocal依赖线程栈,Context依赖对象堆。 - 切换线程:
publishOn/subscribeOn只是改变了**“谁来执行代码” (CPU),并没有改变“代码操作的是哪个对象”**(Memory)。 - 只要链条不断,Context 就在:无论中间经过多少次线程跳跃,只要 Subscriber 的引用链是连通的,Context 就能通过
currentContext()方法沿着对象链逆流而上被找到。
总结:对比传统编码 vs 响应式编码
为了让你更直观地理解,我们可以把 Context 想象成 HTTP 请求头(Headers) ,把 Subscriber 想象成 服务器节点。
| 场景 | 传统编码 (ThreadLocal) | 响应式编码 (Reactor Context) |
|---|---|---|
| 存储介质 | 线程 (Thread) | 订阅者 (Subscriber) |
| 查找方式 | 去“当前房间”(线程)的口袋里找 | 问“我的下家”(Subscriber)要 |
| 生命周期 | 即使请求结束,线程还在,数据可能残留(内存泄漏风险) | 请求处理完,Subscriber 对象被 GC,Context 自动消失(零风险) |
| 传递方向 | 隐式,伴随线程栈帧 | 反向,伴随 subscribe 动作从下往上 |
| 本质比喻 | 像 “全局变量” (环境决定行为) | 像 “依赖注入” (调用者决定环境) |
思考:
Reactor 团队选择这种设计,是为了在异步、线程切换频繁的场景下,提供一种确定性的、作用域绑定准确的上下文管理机制。
- ThreadLocal 是隐式的、不稳定的(线程会变)。
- Context 是显式的、稳定的(订阅链一旦建立就不变)。
虽然它在写代码时(contextWrite 在下,defer 在上)看起来很怪,但在运行时(subscribe 阶段),它是唯一能保证数据在整个 Operator 链条中正确传递的方案。