Java新特性并非只为面试:Axelix的两个真实生产用例
大家好!我是 Mikhail Polivakha,Axelix 项目的技术负责人。
Java 很早以前就转向了每六个月发布一次的周期。每六个月,我们都会迎来一个新版本,阅读最新的 JEP,观看会议演讲。然后回到工作中,继续写大致和五年前一样的代码。
顺便说一句,这未必是坏事。仅仅因为某个特性是新的就把它引入生产环境,并不是什么特别高明的工程策略。而且,许多应用仍运行在 Java 17 上,而 Java 11 在某些地方也依然活得好好的。
但这引出了一个自然的问题:
新的 Java 特性在普通的、真实世界的应用中有一席之地吗?不是在演示里,不是玩具项目里,而是在解决实际问题的代码里?
答案是:有。
在本文中,我将考察开源 Axelix 代码库中的两个例子:
- 我们如何使用
ScopedValue将安全上下文从 HTTP 过滤器深入传递到传输层。 - 为什么我们的一个接口变成了
sealed,而且并不是为了模式匹配或穷尽式switch。
乍一看,这两个例子似乎完全不相关。一个关乎执行上下文,另一个关乎整体领域设计。但它们实际上共享一个共同理念:优秀的语言特性能够让我们把开发者之间的约定,转化为由平台本身强制执行的约束。
那么,我们开始吧。
关于 Axelix 的一点背景
为了让下面的代码看起来不像一堆随机拼凑的类,我先简单介绍一下架构。
Axelix 是一个开源产品,用于帮助调试 Spring Boot 应用中的问题,并检测其中常见的问题和低效之处。简而言之,该系统由两部分组成:
- Axelix Master 聚合信息、处理 RBAC,并提供 UI 和内置 MCP 服务器。
- Spring Boot starter 以及一个小型 Build Plugin 会被安装到被监控的应用中,为 Master 提供所需的数据和操作。
当用户通过 UI 或 AI Agent 调用某个操作时,Master 有时必须联系特定应用的 starter。例如,它可能请求环境状态、获取 bean 信息,或清除缓存。
而这里就涉及一项安全相关要求:Master 必须把与执行该操作所在安全上下文关联的授权令牌传给 starter。
令牌出现在相当靠上的位置,位于一个 servlet 过滤器内;而它真正被需要的地方在更靠下的传输层,也就是构建出站 HTTP 请求的位置。
当然,我们可以在这两个位置之间的每个方法上都加一个 SecurityContext 参数。然后又要再加五个方法,接着再加十个。六个月后,我们会发现半个应用都在忙着做一件很有吸引力的事:把令牌从一个参数搬到另一个参数。
虽然那样也能工作,但我们想找到更好的方案。
用例一:通过 ThreadLocal 传递上下文
从历史上看,Java 中解决这类问题的标准方案一直是 ThreadLocal。例如,Spring Security 过去就是这样工作的。
思路非常简单:
同一线程中更靠下调用栈运行的代码可以调用 CONTEXT.get() 并取得所需的值。不需要把参数穿过每一个中间方法。
那么问题在哪里?
ThreadLocal 提供了超出我们需要的能力
ThreadLocal 第一个且相当广为人知的问题是:任何持有其引用的代码不仅可以调用 get(),还可以调用 set() 或 remove()。换句话说,ThreadLocal 本质上是一种共享可变状态,而经验丰富的工程师知道,这就像一枚鱼雷嵌在我们这艘“船”的侧面。
调用方代码存储已认证用户的上下文,但某个遥远的被调用方理论上可以替换这个上下文。某些任务确实需要这种可变性。但在我们的场景中,数据严格单向流动:
- 过滤器认证用户。
- 过滤器创建
SecurityContext。 - 更靠下的代码只读取该上下文。
调用栈更靠下的任何代码都不应改变用户身份。我们不仅不需要这种能力,而且它实际上构成了一种安全漏洞! 例如,假设某个第三方 SPI 接口实现可以乱动 SecurityContext,那将是一个严重问题。
但 ThreadLocal 还有另一个问题。ThreadLocal 中值的生命周期并不受代码结构约束。我们需要负责调用 remove(),通常是在 finally 块中。为什么这是个问题?
想象一下,出于某种原因——具体原因并不重要——我们在处理完一个 HTTP 请求后,未能从 ThreadLocal 中清除用户身份。然后我们收到另一个请求,而它恰好运行在线程池中的同一个线程上。这里先不谈虚拟线程和 thread-per-task 执行模型,因为那完全是另一个话题。此时,一个请求的安全上下文可能泄漏到另一个请求中。
现在想象一下,留在池中的 SecurityContext 属于一个拥有 ADMIN 角色的用户,该角色不仅允许其读取应用属性,还允许查看敏感值。默认情况下,Axelix 对敏感值有自己的处理策略。这些值通过单独的属性进行配置,只有 ADMIN 才被允许读取它们。如果我们在 SecurityContext 中留下了一个拥有 ADMIN 角色的用户,就可能实际上让没有该角色的人通过 UI 或 MCP 服务器看到机密信息。
当然,细心的开发者会写 try/finally。他们会用测试覆盖代码。他们会留下注释。但如果解决方案的正确性完全依赖于未来每一位开发者都记得某条重要注释,那远远算不上最强的保证。
ScopedValue:不是存储,而是有边界的执行作用域
在 Java 25 中,ScopedValue 已正式定稿。它解决的问题比 ThreadLocal 更窄:它允许在某个特定操作期间,将值沿调用链向下传递。
关键区别在于模型本身。ThreadLocal 类似于附着在线程上的一个可变盒子。ScopedValue 描述的则是在一个动态执行作用域内对值的绑定。
下面是我们如何在 Axelix Master 中使用它来传递 SecurityContext:
where(...).call(...) 调用大致表示以下含义:
执行这个回调,使其动态作用域内的这个
ScopedValue绑定到这个SecurityContext。
当回调结束时,绑定会自动消失。无论执行是正常完成还是抛出异常,都没有关系,因为 ScopedValue 的设计本身就考虑了这种情况。
读取上下文看起来像这样:
注意,ScopedValue 本身没有 set 方法。更靠下的代码可以读取绑定,但不能简单地替换其值。如果同一个 ScopedValue 需要临时绑定到另一个值,就会创建一个新的嵌套作用域。一旦该作用域结束,之前的绑定会再次可见。
换句话说,我们需要的语义已经体现在 API 中:
- 值沿调用栈向下传递;
- 绑定只存在于特定操作期间;
- 被调用方不能修改绑定;
- 离开作用域后会可靠地恢复之前的状态。
这正是这样一种情况:新的 Java 特性不仅省掉了几行涉及 finally 的代码,而且让正确的模型从代码本身显现出来。
在一个真实请求中如何工作
现在我们把整个流程串起来。
对于由 ExternalApiCookieAuthorizationFilter 处理的普通 UI 请求,我们从 cookie 中取得 JWT,解析用户,并在安全上下文中执行剩余的过滤器链:
对于 MCP 调用,IAM 流程类似但略有不同。如果你感兴趣,可以在这里查看源代码。我现在不再花时间讲它。
随后,常规应用逻辑会在过滤器链内运行。它可能经过控制器、服务和其他抽象。它们完全不需要令牌,所以我们不会用令牌污染它们的 API。
最终,在把请求发送给 starter 之前,会在 AbstractEndpointProber 中读取上下文:
一旦请求处理结束,绑定就会被移除,令牌也无法再通过这个 ScopedValue 获取。相当简洁!
ScopedValue 的一些限制
我觉得有必要提一下这个 API 的一些限制。总体而言,目前这些限制对我们来说完全可以接受。
绑定与线程绑定
ScopedValue 不会自动传播到任意的 CompletableFuture、执行器任务或异步 servlet 操作中。
当前 Axelix 代码能工作,是因为将请求代理到 starter 的过程——也就是读取绑定并设置 Authorization 头的地方——是在同一线程的过滤器链内同步发生的。子任务可以通过 StructuredTaskScope 继承绑定,但在 Java 25 中它仍是一个预览 API。
如果你把一个值放入 ScopedValue,然后向某个随机线程池提交任务,就不能指望该值在那里可用。
绑定是不可变的,但对象本身未必不可变
ScopedValue 不允许替换绑定本身。但如果你把一个可变对象放进去,它的字段仍然可以通过普通方法被修改。这个问题并非 ScopedValue 独有。它同样适用于 ThreadLocal 以及许多其他 API。看看这个例子:
这里,列表本身的绑定是稳定的。然而,没有什么能阻止别人调用 VALUE.get().add(...),所以可变性问题依然存在。再次说明,这个问题并非 ScopedValue 独有,但值得记住。
它不是参数的通用替代品
一般来说,一个值可以通过方法签名显式传给方法,也可以通过这类上下文隐式传递。从代码可维护性的角度看,如果一个值是方法契约的重要组成部分,那么普通参数几乎总是更好的选择。在这种情况下,API 契约在签名中清晰可见。
你不会陷入这样一种情况:接口声明如下:
而实现实际上在底层做的是类似这样的事情:
上面的代码具有欺骗性,因为调用方可能以为自己已经传入了用于此次调用的令牌,而现实中某个其他令牌来源却优先生效。再次说明,这不是 ScopedValue 特有的问题。我只是觉得值得一提。经验丰富的工程师很可能已经知道这一点。
总体而言,ScopedValue 和 ThreadLocal 特别适用于这样的上下文:它在调用栈很靠下的位置才需要,但与大多数中间方法无关:
- 安全上下文;
- 追踪上下文;
- 租户 ID 上下文;
- 请求元数据。
换句话说,必须先存在单向上下文传播的需求,然后才应该让 ScopedValue 进入画面。
一点小结
当一个值只应沿调用链向下传递,并且其生命周期不应超过一个受边界约束的操作时,就使用 ScopedValue。
如果你需要任意修改、与遗留代码集成,或支持较旧的 Java 版本,ThreadLocal 并不会消失。只需确保正确地封装 set 和 remove 操作。
用例二:sealed 接口与此有什么关系?
现在让我们转向一个完全不同的问题。或者说,很快就会看清楚,也许并非完全不同。
我想先提醒你,下面的解决方案存在一定争议,我稍后会解释原因。我描述它主要是为了分享我们的经验,并听听其他人在评论中的看法。尽管如此,这正是我们所做的。
Axelix Master 有一个内置 MCP 服务器。AI Agent 可以调用 MCP 工具,包括读操作和可能具有危险性的操作,例如清除缓存。作为 RBAC 的一部分,我们显然也需要检查这类操作的访问权限。
不同的 MCP 端点需要不同的权限。简而言之,Axelix Master 中有类似这样的结构:
当我们收到一个用于调用 MCP 工具的 JSON-RPC 请求时,会解析该请求,并把调用方试图调用的 MCP 端点的字符串名称转换为一个 McpEndpoint 对象。然后我们把这个对象用作 Map 的键,取得所需权限。
为什么把它用作键? 因为从领域角度看这是合理的:在 MCP 服务器内调用某个特定操作需要某个特定权限。拥有这种映射似乎非常正确。
我们能不能直接用 String 作为键? 从技术上说可以,但考虑到我们的实现和类结构,那会带来自身的不便。我现在不深入这些细节。
假设 McpEndpoint 是一个接口。此时,经验丰富的工程师会伸手去摸腰带上的柯尔特左轮手枪!
它只是一个接口。能出什么问题呢?
一个可能可变的 HashMap 键:一颗定时炸弹
为什么经验丰富的工程师会去摸柯尔特手枪?因为泛泛地说,McpEndpoint 是一个接口,而我们不知道它的实现可能长什么样。Map 中的键应该是不可变的,这是一个广为人知的事实。
想象一下,有人这样实现这个开放接口:
现在把这个端点放进一个 HashMap,然后改变它的名称:
在 put 期间,HashMap 根据旧的 hashCode 选择了一个桶。rename 之后,该对象返回不同的 hashCode,但它物理上仍然位于旧的桶中。一次查找可能无法找到一个明明就存在于同一个 Map 中的键。
结果,我们可能陷入这样一种情况:我们根本无法检测到某个特定的 McpEndpoint 在执行时需要某个 Authority。这又是另一种安全漏洞!
当然,我们可以在 Javadoc 中这样写:
McpEndpoint的所有实现都必须不可变,并且具有稳定的equals和hashCode实现。
但再一次,正确性将取决于一条注释。## sealed 并非只为 switch 而生
密封类和密封接口早在 Java 17 就已作为语言特性正式确定。它们通常通过一个封闭的形状继承体系来解释:
随后编译器就能知道完整的备选类型集合,并验证穷尽式模式匹配。这很有用,但这远不是它唯一的用途。
仔细想想,密封类型作为一种语言特性,其本质意味着我们不仅知道,而且掌控接口的每一种可能实现。这种控制可以覆盖不同属性,其中对我们而言尤其重要的是对不可变性的保障!
这就是 Axelix 中的 McpEndpoint 的样子:
唯一被允许的实现是一个 record:
这种组合给我们带来了什么?
- 任意代码都无法添加未知的
McpEndpoint实现。 - 如前所述,我们控制所有被允许的实现。
- 当前实现是
record,因此其组件在创建后无法重新赋值。 - 它唯一的组件是
String,而String本身也是不可变的。 record会生成基于组件的equals和hashCode实现,这里基于稳定的name组件。
因此,当前能够放入该 Map 的每一个 McpEndpoint 都具备我们需要的键语义。
这是 sealed 接口一个重要但并非显而易见的用例:我们封闭继承体系,是因为算法依赖于每一个实现的属性。
可以说,我们在面向契约工作的同时,实际上依赖了实现的属性,这违反了 SOLID。这完全没错。
不过,密封类型从其本质上就是围绕预先知道完整实现集合并执行穷尽式 switch 等操作而设计的。严格来说,这也违反了 SOLID,因为我们依赖实现,而不是纯粹面向抽象工作。这里确实有值得深入讨论的空间。
为什么不使用 enum?
这是一个自然的问题。如果 MCP 端点集合是固定的,为什么不直接这样做:
这完全是一种有效的替代方案。而且,对于永久固定的端点集合,enum 会提供更强的保证:不仅实现,连实例本身都会构成一个封闭集合。
那么为什么接口仍然可能合理?因为对我们来说,enum 过于不灵活,尤其无法扩展。不同的 Axelix Master 发行版可能拥有不同的 McpEndpoint 实例,而 enum 无法通过扩展来容纳它们。
不过始终要记住,可扩展性并非没有代价。只在真正需要的地方保留扩展点。
同样重要的是要记住,密封模型并不保证:
- 端点名称唯一;
- 每一个新端点在所有此类
Map映射中都被注册; - 权限映射本身的正确性。
你是不是添加了一个新常量,却忘了在某个解析器中注册它?javac 不会在这里救你。这需要不同的数据模型和测试。
在我们的具体场景中,sealed 解决了一个特定问题。这样就很好。我们不应该指望一种语言特性还能顺便洗碗、部署发布。
换句话说,这是可扩展性与不可变性之间的平衡。
这两个特性有什么共同点
现在让我们回到文章开头。
ScopedValue 和 sealed interface 看起来是完全不同的 Java 特性:
ScopedValue处理执行上下文。sealed interface处理类型系统。
但在我们的两个用例中,它们解决的是同一个工程问题:它们缩小了有效程序状态的集合。
使用 ScopedValue 时,我们表达的是:
一个值只能在一段有限的时间内绑定到
ScopedValue。更下层的代码可以读取它,但不能任意覆盖它。
使用 sealed interface 时,我们表达的是:
这个抽象在整个应用中可用,但只有我们控制下的类型才能实现它。
这与 Java 架构师所称的 Integrity by Default 整体方向非常契合:正确且安全的行为应当是默认行为,而危险的例外则应当需要显式决策。
在我看来,这正是评估新语言特性的正确方式。不是看它们能让我们删掉多少行代码,也不是看它们在会议演讲中多么优雅。真正的问题是:
这个特性能让我表达并保护应用中的哪一条不变量?
如果答不上来,也许你暂时还不需要这个特性。如果答得上来,那它就不再只是玩具。
实践建议
我们来总结一下。
何时考虑使用 ScopedValue
在以下情况下使用 ScopedValue:
- 数据只沿调用链向下传递。
- 中间方法不必将其作为参数接收。
- 被调用方不应替换该绑定。
- 数据的生命周期与一次有界操作相匹配。
如果该依赖是方法契约的重要部分,就继续使用普通参数。如果你需要可变性、遗留系统集成,或支持较旧版本的 Java,就继续使用 ThreadLocal。
何时考虑使用 sealed
在以下情况下使用密封类或密封接口:
- 抽象必须广泛可见。
- 实现集合必须保持在作者的控制之下。
- 代码依赖所有被允许实现的属性。
先有不变量,再谈特性
不要仅仅因为你可以,就到处寻找地方塞入 ScopedValue、密封类、record 模式或虚拟线程。众所周知,我们从来不会这么做,对吧?对吧……?
首先,识别系统中真实存在的约束:
- 这个上下文不得泄漏到请求边界之外;
- 这个绑定不得被下层修改;
- 这个继承体系不得被未知类型扩展;
- 这个对象必须具有稳定的值语义。
只有这时,你才应该选择能够表达该约束的语言特性。
结语
新的 Java 特性确实被用于真实世界的应用。但它们的价值并不体现在我们用新语法替换旧语法之时,而体现在一整类错误程序变得更难编写、甚至无法编译之时。
在 Axelix 中,ScopedValue 为安全上下文赋予了有界生命周期,并使其能够安全地沿调用栈向下传播。sealed interface 则让我们能够保持领域抽象公开,同时保留对实现语义的控制,并且仍然允许创建新的 McpEndpoint 实例。
这两个特性都减少了出错空间。在我看来,这正是我们对编程语言的主要期待之一。
源代码是开放的,因此你可以直接在 Axelix 仓库中查看所有示例。
保重!## 加入讨论
Java [LOADING...]
[LOADING...]Arkadiusz Przychocki2026年5月28日 1,195 次浏览
Scoped Values:我为何在 Exeris 中禁用了 ThreadLocal
JavaJavaProJEPs
Java [LOADING...]
[LOADING...]Nicolas Frankel2026年4月21日 36 次浏览
让非法状态不可表示
JavaKotlin研究
Java [LOADING...]
[LOADING...]Shai Almog2026年10月9日 777 次浏览
我们不想再构建一个 Java 服务器
Java
Debugging [LOADING...]
[LOADING...]Braham Shakti2026年9月3日 1,181 次浏览