使用 JSpecify 和 NullAway 实现 Spring 应用 Null Safety
Null Safety 旨在防止空指针(NullPointerException
)异常。
最初在 Spring 中引入 Null Safety 支持要追溯到 2017 年发布的 Spring Framework 5.0。2025 年,我们会继续完善这一功能,为 Java 或 Kotlin 的 Spring 开发人员带来更多附加值。
我们要解决什么问题?
举一个具体的例子,假设我们正在使用一个提供 TokenExtractor
接口的库,其定义如下:
interface TokenExtractor {
/**
* Extract a token from a {@link String}.
* @param input the input to process
* @return the extracted token
*/
String extractToken(String input);
}
如果由于某种原因实现返回 null
值,在 token.length()
中访问 null
引用(如下所示)会导致 NullPointerException
异常,通常会在运行时产生状态码为 500 Internal Server Error 的 HTTP 响应。
package com.example;
String token = extractor.extractToken("...");
System.out.println("The token has a length of " + token.length());
由于这种异常只可能在某些情况下发生(例如,在使用未经测试的非常特殊的输入时),这可能在生产过程中很晚才被发现,从而导致最终用户感到沮丧,甚至无法进行交易,减少公司收入,损害品牌,并涉及延迟和修复成本。
这种异常经常发生,以至于空(null
)引用的发明者托尼-霍尔(Tony Hoare)本人都为自己发明了空引用而夸张地道歉,并称其为 “我的十亿美元错误”。但正如 Kotlin 所展示的那样,根本问题并不在于空引用本身,而在于类型系统中没有明确指定空引用。
在 Java 中,对于非原始类型的使用,空值的使用是未指定的。参数可以接受或不接受空参数。返回值可能为空,也可能为非空。你不知道,只能靠阅读 Javadoc 或分析实现来弄清楚。但是,即使库的作者记录了这一点,所有的 API 通常也不会保持一致,通常也不会进行自动检查,你无法真正知道某个参数/返回值是否真的是非空的,或者库的作者是否只是忘了记录它是可空的。这在设计上是很容易出错的,而且你也没有合适的方法来解决这个问题。
JSpecify 和 NullAway
解决这个潜在问题的方法是使所有 API 的类型使用的空值性显式化,并在 IDE 和构建中自动检查相关的一致性。由于 Java 尚未提供空限制和可空类型,因此我们需要一种方法来指定 Spring API 的空值性。
2017 年,我们选择引入 Spring nullability 注解,它建立在 JSR 305(一个休眠但广泛传播的 JSR)语义和注解之上。由于技术限制、地位不明确、缺乏适当规范等原因,它远非完美,但它是我们当时确定的最佳实用选择。随后,Spring 团队加入了由 Google 领导的一个工作组,该工作组汇集了 JetBrains、Oracle、Uber、VMware/Broadcom 等多家投资于 JVM 生态系统的公司,目的是设计和贡献一个不依赖于特定验证工具的更好的解决方案。这就是 JSpecify 的开端。
我经常观察到的关于空值性的一个误解是,一开始,你可能觉得这主要是在 众多 @Nullable
变体 中选择一个,但这只是冰山上可见的一小部分。这些注解需要适当的规范和工具支持等。以协作的方式就通用的 nullness 规范达成一致,正是 JSpecify 花了多年时间才达到 1.0 的原因。
JSpecify 是一套 注解、规范 和 文档,旨在借助 NullAway 等工具,确保 IDE 或编译过程中 Java 应用和库的 null safety。
需要了解的一个要点是,在 Java 中,类型用法的默认 “可空性” 是未指定的,而 “非空类型” 用法远比 “可空类型” 用法更频繁。为了保持代码库的可读性,我们通常希望定义在特定作用域中,除非标记为可空,否则类型用法默认为非空。这正是 @NullMarked
的目的,例如,它通常是通过 package-info.java
文件在包(package
)级设置的:
@NullMarked
package org.example;
import org.jspecify.annotations.NullMarked;
该注解将类型使用的默认 “可空性” 从 “未指定”(Java 默认值)改为 “非空”(JSpecify @NullMarked
默认值)。因此,我们现在可以相应地完善我们的 API 和文档。
package org.example;
interface TokenExtractor {
/**
* Extract a token from a {@link String}.
* @param input the input to process
* @return the extracted token or {@code null} if not found
*/
@Nullable String extractToken(String input);
}
现在,当调用一个方法的返回值时,IDE 会正确警告我们可能出现 NullPointerException
异常,如果我们传递了一个空参数,IDE 也会显示警告,因为这个默认是非空的。
虽然我们可以忽略或漏掉这些 IDE 警告,但可以在构建时通过 NullAway 配置来检查整个代码库中 nullness 注解的一致性,并抛出错误。如果发现不一致,编译就会中断,从而避免发布不安全的空 API(来自第三方依赖的非注解类型除外)。
> Task :compileJava FAILED
/Users/sdeleuze/workspace/jspecify-nullway-demo/src/main/java/org/example/Main.java:7: error: [NullAway] dereferenced expression token is @Nullable
System.out.println("The token has a length of " + token.length());
^
(see http://t.uber.com/nullaway )
1 error
如果你想亲自尝试,或查看相关的 Gradle 构建示例,请参阅 https://github.com/sdeleuze/jspecify-nullway-demo。
这些错误信息表示,要求使用这些 API 的开发人员明确处理 null
引用:
String token = extractor.extractToken("...");
if (token == null) {
System.out.println("No token found");
}
else {
System.out.println("The token has a length of " + token.length());
}
你可能会反对说,Java 的 Optional<T>
是为了表达值的存在或不存在而设计的。但实际上,Optional<T>
在很多用例中都无法使用,因为它带来了运行时开销(至少在 Project Valhalla 的值类可用之前是如此),增加了代码和 API 的复杂性,不适合用于参数,而且 破坏了现有的 API 签名。
Spring 即将到来的下一级别 Null Safety
Spring Framework 7(目前处于里程碑阶段)的整个代码库都已改用 JSpecify。你可以在 此处 找到相关文档。与之前的版本相比,一个重要的改进是,现在数组/变量元素以及泛型也可以指定空值性。这对 Java 开发人员来说是件好事,但对 Kotlin 开发人员来说也是如此,他们将看到习以为常的 null-safe API,就像 Spring 是用 Kotlin 编写的一样。
但最大的改进是,整个 Spring 团队目前正致力于在整个 Spring 产品组合中初步提供 null-safe API,并进行相关的构建时检查以确保一致性。这是一个持续的过程,我们还不能保证能在 11 月发布 Spring Boot 4.0 时完成这项工作,但我们会尽量做到全面覆盖。Project Reactor 和 Micrometer 也在我们的工作范围内。
当 Spring Boot 4 发布并在你的应用中使用时,特别是如果你在应用程序级别也启用了这些 null
检查,那么在生产中出现 NullPointerException
的风险将大大降低,甚至是消除,因为只有来自第三方库的类型才有可能出现这种情况。通过明确指定可能发生 null
引用的位置、处理这些代码路径并引入相关的自动检查,我们将 “价值十亿美元的错误” 转化为零成本的抽象,允许表达潜在的缺失值,从而显著提高 Spring 应用的安全性。
Ref:https://spring.io/blog/2025/03/10/null-safety-in-spring-apps-with-jspecify-and-null-away