使用 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 领导的一个工作组,该工作组汇集了 JetBrainsOracleUberVMware/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 null safety 警告

虽然我们可以忽略或漏掉这些 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 ReactorMicrometer 也在我们的工作范围内。

当 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