Ohhnews

分类导航

$ cd ..
DZone Java原文

Error Prone 入门:如何在编译期捕获 Bug

#error prone#java#静态分析#编译期检查#代码质量

Java 编译器的职责是检查代码是否遵循语言规则。试图从永远不可能包含该元素的集合中移除元素,或者在日期模式中使用错误的字母,并不会违反这些规则,因此没有任何机制会阻止代码编译。简而言之,任何符合规则的代码都能编译,即使它并没有实现作者的意图。

什么是 Error Prone?

Error Prone 是 Google 为填补这一空白而构建的开源静态分析工具。与大多数静态分析工具不同,它不作为独立步骤运行;而是作为插件接入 javac。利用编译器在分析代码时产生的信息,它会查找那些符合语言规则但很可能错误的模式,并将其报告为编译器警告或错误。

这个思路很简单:bug 在编写代码时修复成本最低。Error Prone 在编译期间捕获 bug,在它们进入代码审查、CI 或生产环境之前。Google 已在其庞大的 Java 代码库中使用该工具多年,但其使用并不限于 Google。Uber 开发的空安全工具 NullAway 就作为 Error Prone 插件运行。Spring Framework 7 在构建期间使用 NullAway 检查自己的代码库。Caffeine 等流行库也在构建中使用 Error Prone。本文介绍 Error Prone,文中的示例可在该仓库中找到。

它与其他静态分析工具有何不同

Java 世界已有 SpotBugs、PMD、Checkstyle 和 SonarQube 等成熟工具。Error Prone 与它们相比有三个关键特点:

  1. 它在编译器内部运行。不需要单独的构建步骤或服务器。由于它是 javac 的插件,因此可与任何调用 javac 的构建工具(Maven、Gradle、Bazel、Ant)配合使用。
  2. 它了解编译器所了解的一切。分析运行在 javac 构建的 AST 上,位于类型归属和流分析阶段之后。这使得 Error Prone 可以访问所有符号和类型信息。例如,它确切知道表达式的泛型类型参数是什么,或者选择了哪个重载。
  3. 它不止于诊断,还会建议修复。大多数检查都附带建议修复,可以通过一条命令应用到源代码。

Error Prone 如何帮助开发者?

Error Prone 的优势在于,它能在编译时阻止那些可以编译但行为不正确的代码。让我们仔细看看官方文档中的以下示例:

$ java
Set s = new HashSet<>();
for (short i = 0; i < 100; i++) {
    s.add(i);
    s.remove(i - 1);
}
System.out.println(s.size());

这段代码看起来每一步都会移除前一个元素,因此你会期望它打印 1。但它实际打印 100,因为表达式 i - 1 是 int,并被装箱为 Integer。由于 Set 中不包含 Integer,remove 什么也没移除。而且由于 remove(Object) 签名接受任何类型,编译器也不会报错。然而,Error Prone 会拒绝编译这段代码:

[ERROR] ShortSet.java:[22,21] [CollectionIncompatibleType] Argument 'i - 1' should not be passed to this method; its type int is not compatible with its collection's type argument Short

现在看看以下代码:

$ java
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("YYYY-MM-dd");

String reportName(LocalDate date) {
    return "report-" + FORMATTER.format(date) + ".csv";
}

这段代码读起来没问题,而且在一年中的大部分时间里甚至能正确工作。然而,模式中的 Y 并不表示日历年;它表示基于周的年。日历年用小写 y 表示。当一年的最后几天落入下一年的第一周时,这两个值就会不同。例如,对于 LocalDate.of(2025, 12, 29),结果是 report-2026-12-29.csv,日期提前跳了一年。bug 何时出现还取决于区域设置。同一段代码在 en-US 区域设置下会从 12 月 28 日开始产生错误年份,而在 tr-TR 区域设置下则是从 12 月 29 日开始。一年中的其余时间里测试都能通过,而且在代码审查中,YYYY 和 yyyy 之间的差异很容易被忽略。Error Prone 能识别这种模式,停止编译,并同时建议修复:

[ERROR] ReportDate.java:[16,83] [MisusedWeekYear] Use of "YYYY" (week year) in a date pattern without "ww" (week in year). You probably meant to use "yyyy" (year) instead.
Did you mean 'private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");'?

注意,在两个示例中,代码都是合法的 Java,并且可以毫无问题地编译。最好的情况下,IDE 会显示警告;构建不会中断。由于代码也能通过测试,这个 bug 可能会长期不被发现。使用 Error Prone,你不必依赖任何人的注意力来发现这些细微 bug:构建会失败,错误消息会解释问题,而且通常还会建议修复。

扩展 Error Prone

Error Prone 并不是一组封闭的规则。在内置检查不够用时,你可以使用它们所用的同一套基础设施添加自己的规则。有两种方式:

  1. 使用 Refaster 编写转换规则。形式为“当你看到这段代码时,将其替换为那段代码”的规则以普通 Java 模板编写。
  2. 编写自己的 BugChecker。团队特定的 bug 模式使用 Error Prone 自身检查所用的同一 API 来定义。

Refaster

Refaster 是 Error Prone 的一部分,是一个重构工具,专为跨大型代码库的大规模转换而构建。你不是针对 AST API 编写规则,而是用普通 Java 编写:“当你看到这个时,把它替换成那个。”一个 Refaster 规则是一个普通 Java 类,至少包含两个方法。@BeforeTemplate 定义要查找的模式,@AfterTemplate 定义要放入其位置的代码:

$ java
class StringIsEmpty {
    @BeforeTemplate
    boolean before(String s) {
        return s.length() == 0;
    }

    @AfterTemplate
    boolean after(String s) {
        return s.isEmpty();
    }
}

模板匹配时会使用类型信息,而不是文本。这就是为什么 name.length() == 0 和 user.getEmail().length() == 0 都能被捕获,而其他同样具有 length() 方法的类型上的相同表达式则不会被捕获。通过编写多个 @BeforeTemplate,你可以将导致相同结果的不同模式合并到一条规则中。使用规则需要两步:

  1. 使用 RefasterRuleCompiler 插件编译规则类,该插件会生成一个 .refaster 文件。该插件位于 error_prone_refaster 构件中,通过 -Xplugin:RefasterRuleCompiler --out rules.refaster 参数传递给编译器。
  2. 将生成的 .refaster 文件通过 -XepPatchChecks:refaster: 传递给 Error Prone。Error Prone 会将匹配项写入补丁文件,或通过 -XepPatchLocation:IN_PLACE 直接应用到源代码。

Refaster 非常适合从一种 API 迁移到另一种 API、移除已弃用方法,或在团队内统一某种惯用法等任务。Picnic 的开源项目 Error Prone Support 还提供了一种在每次构建时像常规 Error Prone 检查一样运行 Refaster 规则的方法。

自定义 BugChecker

Error Prone 的内置检查并未覆盖所有规则。除了广泛共享的规则,例如“国家身份证号、信用卡号和电子邮件地址等敏感数据不得写入日志”或“货币计算不要使用 new BigDecimal(double)”,每个团队还可能有自己的规则,例如“不得再调用这个内部 API”。Error Prone 允许你在与内置检查相同的基础设施上编写这些规则。自定义检查是一个继承 BugChecker 的 Java 类。它通过实现的 *TreeMatcher 接口声明自己关心哪些 AST 节点。Error Prone 会为每个这样的节点调用检查,检查返回匹配项和可选的修复。以下示例实现了敏感数据规则。虽然规则是通用的,但只有团队知道哪些数据是敏感的。因此,团队使用自己的 @SensitiveData 注解标记国家身份证号或电子邮件地址等字段:

$ java
public record Customer(
    String name,
    @SensitiveData String nationalId,
    @SensitiveData String email,
    @SensitiveData String cardNumber) {}

当日志调用接收到被标记的字段,或接收到一个其 toString() 会打印被标记字段的对象时,该检查会报告错误:

$ java
@AutoService(BugChecker.class)
@BugPattern(summary = "Sensitive data must not be written to logs", severity = ERROR)
public final class SensitiveDataLogging extends BugChecker implements MethodInvocationTreeMatcher {

    private static final String SENSITIVE_DATA = "org.jugistanbul.SensitiveData";
    private static final Matcher LOGGER_CALL = instanceMethod()
        .onDescendantOfAny(
            "java.lang.System.Logger",
            "java.util.logging.Logger",
            "org.slf4j.Logger");

    @Override
    public Description matchMethodInvocation(MethodInvocationTree tree, VisitorState state) {
        if (!LOGGER_CALL.matches(tree, state)) {
            return Description.NO_MATCH;
        }

        tree.getArguments().stream()
            .flatMap(argument -> stringifiedValues(argument, state)) // "..." + x → "...", x
            .filter(value -> isSensitive(value, state))
            .forEach(value -> state.reportMatch(
                buildDescription(value)
                    .setMessage("'%s' contains sensitive data and must not be written to logs"
                        .formatted(state.getSourceForNode(value)))
                    .build()));

        return Description.NO_MATCH;
    }

    private static boolean isSensitive(ExpressionTree value, VisitorState state) {
        // customer.email(): the field or accessor itself is annotated
        var symbol = ASTHelpers.getSymbol(value);
        if (symbol != null && ASTHelpers.hasAnnotation(symbol, SENSITIVE_DATA, state)) {
            return true;
        }

        // customer: the class, or one of its fields, is annotated, so toString() leaks it
        var type = ASTHelpers.getType(value);
        if (type == null || type.isPrimitive() || !(type.tsym instanceof ClassSymbol valueClass)) {
            return false;
        }

        return ASTHelpers.hasAnnotation(valueClass, SENSITIVE_DATA, state)
            || valueClass.getEnclosedElements().stream()
                .anyMatch(member -> member.getKind() == ElementKind.FIELD
                    && ASTHelpers.hasAnnotation(member, SENSITIVE_DATA, state));
    }

    // stringifiedValues(...) splits a string concatenation into its operands; see the repo
}

在演示的 CheckoutService 中,第一行通过,因为它只记录客户姓名,而另外两行无法编译:

$ java
LOG.log(Level.INFO, "Order " + orderId + " placed by " + customer.name());
LOG.log(Level.DEBUG, "Order " + orderId + " placed by " + customer);
LOG.log(Level.INFO, "Receipt for order " + orderId + " sent to " + customer.email());
[ERROR] CheckoutService.java:[25,67] [SensitiveDataLogging] 'customer' contains sensitive data and must not be written to logs
[ERROR] CheckoutService.java:[28,90] [SensitiveDataLogging] 'customer.email()' contains sensitive data and must not be written to logs

该检查会查看传递给日志记录器的表达式,因此先复制到局部变量中的值(String email = customer.email();)不会被跟踪;跟踪它需要数据流分析。这个简短的类展示了自定义检查的所有部分:

  • @BugPattern:定义规则的名称、描述和严重级别(ERROR 或 WARNING)。可以通过其类名关闭规则或更改其严重级别(-Xep:SensitiveDataLogging:WARN),也可以通过 @SuppressWarnings 抑制它。
  • 匹配器:MethodMatchers 等现成构建块可以用一个表达式、结合类型信息来表达“System.Logger、java.util.logging 或 SLF4J 日志记录器的任意方法”之类的条件。
  • 类型和符号信息:ASTHelpers 提供对被记录值的符号和类型的访问。注解通过其完全限定名解析,因此另一个包中同名注解不会被混入。文本搜索无法做到这一点。
  • @AutoService:使检查可通过 ServiceLoader 被发现。只需将包含这些检查的 jar 添加到注解处理器路径即可。

该检查只报告错误,但检查也可以使用 SuggestedFix 建议修复。仓库中的 BigDecimalDoubleConstructor 是一个简短示例,它建议使用 BigDecimal.valueOf(double) 而不是 new BigDecimal(double)。检查使用 CompilationTestHelper 进行测试。测试源代码中的 // BUG: Diagnostic contains: 注释标记了预期出现错误的行。对于建议修复的检查,BugCheckerRefactoringTestHelper 会将修复输出与预期源代码进行比较;仓库中的 BigDecimalDoubleConstructorTest 就是一个例子。

运行示例

本文所有示例都以可运行形式位于 error-prone-in-action 仓库中:触发内置检查的 ShortSet 和 ReportDate,三个带测试的自定义检查,以及 StringIsEmpty Refaster 规则。仓库的 README 说明了每个示例由哪条命令运行。

结论

Java 编译器检查代码是否遵循语言规则,而不是检查作者想要什么。Error Prone 从编译器内部填补了这一空白。利用 javac 的类型信息,它捕获那些可以编译但很可能错误的代码,并且通常还会建议修复。在内置检查不够用时,你可以使用 Refaster 编写转换规则,并使用 BugChecker API 编写团队特定检查。在我看来,这种方法在今天比以往任何时候都更有价值。随着 AI 编码助手加快编写代码的速度,能在 bug 修复成本最低的时刻——编译时——捕获 bug 的工具,正从奢侈品变成必需品。