Java 基础体系 · 第 81/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 代码质量:Checkstyle、SpotBugs、Error Prone、覆盖率和门禁

代码质量不是一个单一指标。一个项目可以“测试覆盖率很高”,却存在明显的空指针风险;也可以“静态检查全绿”,却没有测试关键业务分支。原因在于这些工具观察的是不同对象:

  • Checkstyle 观察源代码的结构和风格;
  • Error Prone 在编译 Java 源码时发现一类容易导致错误的写法;
  • SpotBugs 分析编译后的字节码,寻找可能的缺陷模式;
  • 覆盖率工具 观察测试执行过哪些指令、行或分支;
  • 门禁 把这些结果转换为是否允许合并、发布或部署的决策。

它们不是互相替代的关系,而是不同阶段、不同数据源上的质量信号。


一、先建立完整的质量模型

设一次提交为 CC,其质量检查结果可以抽象为:

Q(C)=S,E,B,T,GQ(C) = \langle S, E, B, T, G \rangle

其中:

  • SS:Checkstyle 等源代码规则的结果;
  • EE:Error Prone 等编译期检查的结果;
  • BB:SpotBugs 等字节码分析的结果;
  • TT:测试及覆盖率结果;
  • GG:门禁根据前述结果计算出的最终决策。

门禁并不是重新发现问题,而是定义一个布尔函数:

G(C)=(Serror=0)(Eerror=0)(B阻断级问题=0)(T失败测试=0)(coveragethreshold)G(C) = (S_{\text{error}} = 0) \land (E_{\text{error}} = 0) \land (B_{\text{阻断级问题}} = 0) \land (T_{\text{失败测试}} = 0) \land (\text{coverage} \geq \text{threshold})

实际项目通常还会增加:

  • 新增代码不能引入新的高严重性问题;
  • 关键模块必须达到更高的覆盖率;
  • 变更代码覆盖率必须达标;
  • 允许存在有明确豁免记录的旧问题;
  • 依赖漏洞、许可证或 API 兼容性检查必须通过。

重要的是,覆盖率是测试执行事实,不是代码正确性证明。同样,静态分析没有报错,也不等于程序没有缺陷。每种信号都有自己的观测边界。


二、Checkstyle:对 Java 源代码结构进行规则检查

2.1 Checkstyle 检查什么

Checkstyle 通常读取 .java 文件,构造语法树或基于词法结构检查规则。它关注的是源代码是否符合约定,例如:

  • 类、方法和字段的命名;
  • import 顺序;
  • 缩进和括号位置;
  • 方法参数数量;
  • 类的可见性;
  • Javadoc;
  • 文件中声明的顺序;
  • 某些简单的复杂度和编码约束。

例如:

public final class UserService {
    public String findName(long id) {
        return "user-" + id;
    }
}

如果项目规定:

  • 类名必须使用 UpperCamelCase;
  • 方法名必须使用 lowerCamelCase;
  • 行宽不能超过 100;
  • public 方法必须有 Javadoc;

那么 Checkstyle 可以在代码进入编译或测试之前发现违规。

但它通常不能回答以下问题:

public String findName(long id) {
    return database.load(id).name();
}

database.load(id) 是否可能返回 null,需要数据流分析或运行测试才能判断。Checkstyle 的职责不是证明这段代码在运行时安全。

2.2 Checkstyle 的输入、输出和生命周期

一个典型流程是:

.java 源文件
    │
    ▼
Checkstyle 解析源代码
    │
    ├── 规则匹配
    ├── 生成 warning/error
    └── 根据 severity 决定检查是否失败

它一般不需要先生成 .class 文件,因此可以在编译前运行。典型命令形态如下:

java -jar checkstyle.jar \
  -c /config/checkstyle.xml \
  src/main/java

输入是:

  • Checkstyle 本身及其依赖;
  • XML 配置;
  • Java 源文件目录。

输出通常包含文件名、行号、列号、规则名和消息,例如:

src/main/java/com/example/UserService.java:12:5:
        Missing a Javadoc comment.

命令是否返回非零退出码,取决于配置中的严重级别和执行方式。CI 应依据退出码而不是只观察日志文本。

2.3 一个可运行的规则示例

下面的 Java 文件存在两个常见风格问题:

package com.example;

import java.util.List;
import java.util.ArrayList;

public class user_service {
    public List<String> names( ) {
        return new ArrayList<>();
    }
}

问题包括:

  • user_service 不符合常见的类名规则;
  • import 顺序通常应为 ArrayListList 之前;
  • 方法声明中的空格格式不符合常见规则;
  • public 类和方法可能缺少 Javadoc;
  • 类可能应声明为 final,但这属于团队设计规则,不是 Java 语言强制要求。

这里需要区分三类约束:

  1. Java 语言规范要求:例如类声明必须是合法语法;
  2. Checkstyle 规则要求:例如命名和缩进;
  3. 团队经验规则:例如所有无子类设计的类都应使用 final

第三类不应被伪装成 Java 25 的语言事实。Checkstyle 配置表达的是项目政策,不是 Java SE 规范。

2.4 Checkstyle 的边界

Checkstyle 能发现:

if (condition) {
    doSomething();
}

是否缺少规定的空格或括号,但不能可靠地发现:

if (user != null) {
    user.send();
}
audit(user.getId());

这里的 user 可能在两次使用之间被其他线程改变,也可能 getId() 存在业务错误。即使代码格式完全合规,仍然可能有并发或业务缺陷。

Checkstyle 也不应被用来表达所有设计要求。例如强制方法不超过 20 行,可以限制复杂度,但不能证明拆分后的方法更容易理解。规则越多,误报和“为了过检查而重排代码”的成本越高。


三、Error Prone:编译阶段发现高风险 Java 写法

3.1 Error Prone 与 Checkstyle 的根本区别

Error Prone 是编译器集成型静态分析工具。它通常运行在 javac 编译过程中,能够利用:

  • Java 语法树;
  • 符号解析结果;
  • 类型信息;
  • 方法调用关系;
  • 编译器已经进行的类型检查。

因此,它可以发现一些“代码语法合法,但很可能不是作者本意”的问题。

例如:

String value = "abc";

if (value == new String("abc")) {
    System.out.println("equal");
}

这段代码可以编译,但 == 比较的是对象引用,不是字符串内容。对于内容比较,通常应写:

if (value.equals(new String("abc"))) {
    System.out.println("equal");
}

或者在允许 null 时:

if ("abc".equals(value)) {
    System.out.println("equal");
}

Error Prone 能在编译期将这类写法报告出来。Checkstyle 主要看到的是运算符和表达式的结构,而 Error Prone 能结合类型语义理解 String 的比较。

3.2 一个完整的编译期例子

下面的代码有一个典型问题:

import java.util.Objects;

public final class EqualityExample {
    public static boolean same(String left, String right) {
        return left == right;
    }

    public static boolean sameSafely(String left, String right) {
        return Objects.equals(left, right);
    }
}

逐步看:

  1. left == right 在 Java 中是合法的,因为两个操作数都是引用类型;
  2. 对引用类型使用 ==,比较的是是否指向同一个对象;
  3. 两个内容相同的字符串不一定是同一个对象;
  4. 因此 same("a", new String("a")) 返回 false
  5. Objects.equals 处理了两个引用都为 null、一边为 null 以及内容相等的情况。

测试例子:

public final class EqualityExampleMain {
    public static void main(String[] args) {
        String a = "a";
        String b = new String("a");

        System.out.println(EqualityExample.same(a, b));
        System.out.println(EqualityExample.sameSafely(a, b));
    }
}

预期输出:

false
true

Error Prone 的价值在于:它在测试运行之前就可以提示这种高风险写法。不过具体检查项、默认严重级别以及是否提供自动修复,取决于所使用的 Error Prone 版本和构建集成方式。

3.3 Error Prone 的生命周期

其基本数据流如下:

.java 文件
   │
   ▼
javac 解析、类型检查、符号解析
   │
   ▼
Error Prone 编译期检查
   │
   ├── 普通诊断信息
   ├── 警告
   ├── 错误
   └── 可选的自动修复
   │
   ▼
生成 .class,或因错误退出

与普通编译器错误不同,Error Prone 的某些检查属于“代码意图风险”:

List<String> values = new ArrayList<>();
values.toArray(new String[0]);

这可能是合法且合理的代码;而另一些检查可能认为某种写法容易出错。项目必须根据实际代码风格审查规则级别,而不能把所有诊断都无条件提升为阻断错误。

集成时应特别注意:

  • Error Prone 依赖编译器集成;
  • Maven、Gradle、不同 JDK 和不同 Error Prone 版本的配置方式可能不同;
  • 某些版本需要额外的模块导出参数;
  • 必须确认所选版本明确支持当前构建使用的 JDK,包括 Java 25;
  • 使用的编译器、构建插件和 Error Prone 版本应锁定并在 CI 验证,而不能只在开发机上“碰巧能运行”。

Java 25 的语言语义由 Java Language Specification 定义,但 Error Prone 不是 Java SE 的一部分。它对新语言特性的支持属于工具版本能力,不能从“JDK 能编译”推导出“静态检查工具已经支持”。

3.4 Error Prone 的真实边界

Error Prone 适合发现:

  • 错误的 equals 或 hashCode 使用;
  • 不安全的 API 调用;
  • 永远成立或永远不成立的条件;
  • 误用集合、异常和并发 API;
  • 明显无效的转换或比较;
  • 某些可由编译期类型信息确定的缺陷。

它通常不能确定:

public Money calculate(Order order) {
    return priceTable.find(order.productId())
        .multiply(order.quantity());
}

这里的价格是否正确,取决于:

  • 价格表是否加载了正确版本;
  • 数量是否允许为负;
  • 金额是否需要舍入;
  • 数据库事务是否完整;
  • 并发下价格是否发生变化。

这些是业务规则、运行时状态或外部系统行为,不能仅靠编译器诊断证明。


四、SpotBugs:对字节码进行缺陷模式分析

4.1 SpotBugs 为什么分析 .class

Java 源码经过编译后会变成字节码。SpotBugs 通常读取 .class 文件和相关依赖,基于字节码控制流、数据流和模式匹配来发现缺陷。

基本流程是:

.java
  │
  ▼
javac / Error Prone
  │
  ▼
.class 字节码
  │
  ├── 控制流分析
  ├── 数据流分析
  ├── API 调用模式分析
  └── 缺陷模式报告

因此,SpotBugs 必须在成功编译之后运行。若编译没有生成目标类,SpotBugs 没有完整输入;若缺少依赖类,部分分析可能变得不完整,甚至产生解析错误。

4.2 典型缺陷:检查后对象状态可能变化

考虑下面的并发代码:

public final class SessionRegistry {
    private Session session;

    public void send(String message) {
        if (session != null) {
            session.send(message);
        }
    }

    public void clear() {
        session = null;
    }
}

如果 sendclear 可以由不同线程调用,那么检查与使用之间存在时间窗口:

  1. 线程 A 读取 session,得到非空引用;
  2. 线程 B 执行 clear,把字段设为 null
  3. 线程 A 再次读取字段执行 session.send(message)
  4. 第二次读取可能得到 null,产生空指针异常。

一种修复是先保存局部变量:

public final class SessionRegistry {
    private volatile Session session;

    public void send(String message) {
        Session current = session;
        if (current != null) {
            current.send(message);
        }
    }

    public void clear() {
        session = null;
    }
}

这里有两个不同问题:

  • 使用局部变量避免了同一表达式中重复读取字段;
  • volatile 提供了跨线程可见性保证。

但这仍不代表 Session.send 本身线程安全,也不代表清理操作不会与发送产生业务层竞态。SpotBugs 可能报告“检查后使用”或并发相关模式,但工具报告不能替代并发模型设计。

4.3 SpotBugs 报告中的优先级

SpotBugs 常使用缺陷优先级或置信度表达风险,例如:

  • 高优先级:更值得首先处理;
  • 中优先级:可能是真实缺陷;
  • 低优先级:可能是风格或低风险问题;
  • 高置信度:工具更确信模式匹配到了问题;
  • 低置信度:需要人工复核。

这不是概率,也不是“高优先级必然有 bug”。一个高优先级报告仍可能是误报;一个低优先级报告也可能在特定业务路径上非常危险。

门禁应明确使用哪一个维度,例如:

阻断条件:
- 新增 HIGH 优先级缺陷:阻断;
- 任何被确认的安全相关缺陷:阻断;
- 旧的 MEDIUM 缺陷:暂不阻断,但必须登记;
- LOW 缺陷:只做报告。

不能把“工具返回了报告”简单等同于“构建失败”。

4.4 编译、分析和依赖故障

SpotBugs 常见失败不一定是业务缺陷:

  • 编译目录为空;
  • 类文件由比分析器更新的 JDK 版本生成;
  • 依赖类未加入分析 classpath;
  • 多模块项目只分析了部分模块;
  • 生成代码和手写代码混在同一目录;
  • 分析插件自身与 Java 25 不兼容。

诊断时应区分:

分析器启动失败       → 工具、JDK 或插件兼容性问题
无法解析类            → classpath 或构建产物问题
发现 BugInstance      → 需要审查的代码问题
报告为空              → 不等于代码没有缺陷

SpotBugs 能分析的是已编译行为模式。它不能执行数据库查询、模拟真实网络超时,也不能知道一个 null 在业务上是否表示“未找到”还是“系统故障”。


五、覆盖率:测试执行经过了什么代码

5.1 覆盖率不是单一数字

Java 项目常使用 JaCoCo 等工具收集覆盖率。工具通常对字节码进行插桩,在运行测试时记录执行情况。常见指标包括:

指令覆盖率

设所有可执行字节码指令数为 II,测试实际执行过的指令数为 IcI_c,则:

Instruction Coverage=IcI\text{Instruction Coverage} = \frac{I_c}{I}

它回答的是:“有多少字节码指令被执行过?”

行覆盖率

设编译器调试信息把可执行指令映射到 LL 个源代码行,其中被执行的行为 LcL_c,则:

Line Coverage=LcL\text{Line Coverage} = \frac{L_c}{L}

行覆盖率依赖编译产生的调试映射。一个源代码行可能对应多个字节码分支,所以“这一行执行过”不代表这一行的所有逻辑路径都执行过。

分支覆盖率

设条件分支总数为 BB,被测试执行过的分支数为 BcB_c,则:

Branch Coverage=BcB\text{Branch Coverage} = \frac{B_c}{B}

分支覆盖率更接近条件路径,但它仍不是完整路径覆盖。若一个方法有 nn 个相互独立的二值条件,理论路径数最多接近 2n2^n;实际还会受到条件依赖、异常路径和循环次数影响。

5.2 完整算例:为什么行覆盖率会误导

代码:

public final class ShippingFee {
    public int fee(boolean vip, int weight) {
        if (vip && weight <= 10) {
            return 0;
        }
        return weight > 20 ? 30 : 10;
    }
}

假设只有一个测试:

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

final class ShippingFeeTest {
    @Test
    void vipLightPackageIsFree() {
        assertEquals(0, new ShippingFee().fee(true, 5));
    }
}

执行过程:

  1. viptrue
  2. weight <= 10true
  3. 进入第一个 return
  4. weight > 20 ? 30 : 10 完全没有执行;
  5. 测试只验证了一个业务场景。

如果源代码工具把第一条条件和 return 视为已执行,行覆盖率可能看起来不错;但下列行为完全没有验证:

  • vip = false
  • vip = true, weight = 20
  • weight > 20
  • 重量为负数时的行为;
  • vip && ... 中短路求值的另一条路径。

更完整的测试至少应覆盖:

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

final class ShippingFeeTest {
    private final ShippingFee shippingFee = new ShippingFee();

    @Test
    void vipLightPackageIsFree() {
        assertEquals(0, shippingFee.fee(true, 5));
    }

    @Test
    void nonVipPackageCostsTen() {
        assertEquals(10, shippingFee.fee(false, 5));
    }

    @Test
    void heavyPackageCostsThirty() {
        assertEquals(30, shippingFee.fee(false, 21));
    }

    @Test
    void vipHeavyPackageIsNotFree() {
        assertEquals(30, shippingFee.fee(true, 21));
    }
}

这个例子说明:

  • 覆盖率只能说明测试执行到哪里;
  • 断言决定测试是否真正检查了结果;
  • 一个没有断言的测试可能增加覆盖率,却没有增加有效信号;
  • 代码中的输入边界必须由业务规则决定,而不是由覆盖率工具自动推导。

5.3 覆盖率插桩的生命周期

常见生命周期是:

编译类
  │
  ▼
插桩后的类
  │
  ▼
运行单元测试 / 集成测试
  │
  ▼
执行数据文件
  │
  ▼
报告生成
  │
  ├── HTML:供人查看
  ├── XML:供 CI 或质量平台读取
  └── 门禁检查:判断阈值是否满足

覆盖率工具可能采用离线插桩,也可能由测试运行时处理。无论实现方式如何,核心都一样:测试运行时记录执行事实,测试结束后根据类文件和执行数据计算报告

因此,以下情况会造成报告失真:

  • 测试运行的类文件与生成报告使用的类文件不是同一版本;
  • 执行数据文件残留,混入了其他构建;
  • 多模块报告遗漏了某个模块;
  • 只运行了快速单元测试,却宣称覆盖了集成路径;
  • 过滤掉了大量生产代码后仍使用总覆盖率做决策;
  • 测试失败,但构建流程仍继续生成部分报告。

覆盖率报告生成成功,不代表测试成功。门禁必须同时检查测试退出状态和覆盖率阈值。

5.4 覆盖率阈值的数学陷阱

假设模块 A 有 10 行,覆盖 100%;模块 B 有 1,000 行,覆盖 70%。按行数加权的总覆盖率为:

10×100%+1000×70%101070.3%\frac{10 \times 100\% + 1000 \times 70\%}{1010} \approx 70.3\%

如果只看总数,模块 A 的高质量测试会掩盖模块 B 的不足。

反过来,若一个很小的模块只有 5 行但覆盖率从 100% 下降到 60%,它可能对整体百分比影响很小,却表示刚删除了关键测试。因此,工程上常同时使用:

  • 总体覆盖率;
  • 每个模块最低覆盖率;
  • 变更代码覆盖率;
  • 分支覆盖率;
  • 关键包或关键类的专门阈值。

这些是门禁策略,不是 JaCoCo 或 Java 规范的强制定义。


六、门禁:把质量信号变成可执行决策

6.1 门禁与报告的区别

报告回答:

发现了什么?

门禁回答:

在什么条件下,构建可以继续?

例如一份 SpotBugs 报告可能包含 20 个问题,但门禁可以规定只阻断新增的高优先级问题。覆盖率报告可能显示 78%,门禁则可能要求:

总行覆盖率 >= 75%
总分支覆盖率 >= 65%
变更代码行覆盖率 >= 85%
测试失败数 = 0

门禁阈值必须具有明确的对象和口径:

  • 是行覆盖率还是分支覆盖率?
  • 统计主代码还是测试代码?
  • 是否排除生成代码?
  • 是所有历史代码还是本次变更代码?
  • 是按整个仓库还是按模块?
  • 缺少报告时是失败还是跳过?

“覆盖率达标”如果没有这些定义,无法稳定执行。

6.2 一个门禁流水线

一个合理的流水线可以表示为:

flowchart TD
    A[提交代码] --> B[检出并锁定 JDK 25 与依赖]
    B --> C[Checkstyle 源码检查]
    C -->|失败| X[阻断并修复]
    C -->|通过| D[编译:javac / Error Prone]
    D -->|失败| X
    D -->|通过| E[SpotBugs 字节码分析]
    E -->|阻断级问题| X
    E -->|通过| F[运行测试]
    F -->|测试失败| X
    F -->|通过| G[生成覆盖率报告]
    G --> H[检查覆盖率和变更代码阈值]
    H -->|不达标| X
    H -->|通过| I[允许合并或进入下一阶段]

关键路径的因果关系是:

  1. Checkstyle 可以不依赖编译产物,因此先检查源代码;
  2. Error Prone 依赖编译过程,编译失败时不能产生可信的完整类文件;
  3. SpotBugs 依赖类文件,所以放在编译之后;
  4. 覆盖率依赖测试执行,必须在测试之后计算;
  5. 门禁读取各阶段的退出码和机器可读报告,最终做决策。

也可以并行执行一些独立阶段,但不能破坏依赖关系。例如 Checkstyle 和普通依赖下载可以并行;SpotBugs 不能早于成功编译,覆盖率不能早于测试。

6.3 故障路径必须可区分

CI 中至少需要区分以下状态:

状态 含义 默认处理
Checkstyle 规则违规 源代码政策不满足 阻断
Error Prone 编译诊断 编译期高风险写法或编译失败 阻断
SpotBugs 高优先级问题 字节码模式存在潜在缺陷 通常阻断
测试断言失败 行为与预期不符 阻断
测试进程异常退出 测试环境或代码故障 阻断
覆盖率报告缺失 质量数据不完整 通常阻断
覆盖率低于阈值 测试执行不足 按策略阻断
工具下载失败 构建基础设施故障 阻断,不应伪装成通过
误报豁免 经审查的已知问题 允许,但必须可追踪

最危险的配置是“工具运行失败时忽略错误”。例如:

checkstyle ... || true

这会把真实违规、工具崩溃、配置错误和 Java 25 不兼容全部变成成功。除非这是专门用于非阻断报告的旁路任务,否则不应这样处理。


七、基线、增量门禁与抑制

7.1 为什么需要基线

遗留项目第一次启用 SpotBugs 或严格 Checkstyle 时,可能一次发现数千个问题。如果要求一次全部清理,团队往往会选择关闭工具,或者大量添加无审查的抑制。

基线是对启用时已有问题的固定记录。门禁只阻断:

当前问题集合基线问题集合\text{当前问题集合} - \text{基线问题集合}

也就是新增问题。

但基线不是永久豁免。它的风险在于:

  • 旧问题可能继续扩散;
  • 代码移动后,原问题可能被错误地认为是同一个问题;
  • 基线文件本身可能被随意修改;
  • 问题数量可能只增不减。

因此,基线应有:

  • 版本控制;
  • 生成方式和工具版本记录;
  • 定期清理计划;
  • 新增问题阻断规则;
  • 对高风险问题的单独处理。

7.2 抑制不是修复

如果确认某个 SpotBugs 报告是误报,可以使用工具支持的抑制机制;如果某一段代码是生成代码,也可以排除。但每次抑制都应说明:

  • 为什么该报告不适用;
  • 为什么不能改写代码消除风险;
  • 抑制的范围为何只覆盖必要位置;
  • 谁审查了该决定;
  • 何时重新验证。

不应使用过宽的抑制:

@SuppressWarnings("all")

或者把整个包、整个模块排除,只因为少数规则不便处理。过宽抑制会破坏工具的观测范围,使“通过”失去含义。

Checkstyle、Error Prone 和 SpotBugs 的抑制机制并不完全相同。一个工具中的 @SuppressWarnings 不一定会影响另一个工具,必须根据具体工具和版本的文档验证。


八、四类工具发现的是不同问题

下面的代码可以同时说明它们的边界:

public final class AccountService {
    private Account account;

    public String displayName() {
        if (account != null) {
            return account.name();
        }
        return "";
    }

    public boolean same(Account other) {
        return account == other;
    }
}

可能的分析结果分别是:

Checkstyle

它可能报告:

  • 缺少 Javadoc;
  • 方法或字段命名不符合项目规则;
  • 行宽、空格或声明顺序问题。

它通常不判断 account == other 是否符合业务语义。

Error Prone

如果类型和规则匹配,它可能提示引用相等比较可能不是作者想要的值相等比较。但这不是绝对错误:

if (cachedAccount == account) {
    // 明确判断是否为同一个对象
}

这里比较对象身份可能正是设计意图。因此诊断需要人工判断。

SpotBugs

它可能关注:

  • 可疑的空值处理;
  • 并发访问字段;
  • equals/hashCode 约定;
  • API 误用;
  • 某些资源未关闭模式。

如果 account 被多线程读写,SpotBugs 可能发现一部分风险,但不能凭一条报告推导完整的线程安全结论。

覆盖率

测试如果只执行:

new AccountService().displayName();

可能覆盖了 account == null 的路径,却没有验证:

  • 有账户时返回姓名;
  • same 的语义;
  • 并发访问;
  • Account.name() 的异常行为。

因此四类结果不能互相替换。


九、测试和覆盖率的正确使用方式

9.1 先测试行为,再追逐数字

对于条件:

public String classify(int score) {
    if (score < 0) {
        throw new IllegalArgumentException("score must not be negative");
    }
    if (score >= 60) {
        return "pass";
    }
    return "fail";
}

有效测试至少应覆盖:

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;

final class ClassifierTest {
    private final Classifier classifier = new Classifier();

    @Test
    void negativeScoreIsRejected() {
        assertThrows(
            IllegalArgumentException.class,
            () -> classifier.classify(-1)
        );
    }

    @Test
    void sixtyIsPassingBoundary() {
        assertEquals("pass", classifier.classify(60));
    }

    @Test
    void fiftyNineIsFailingBoundary() {
        assertEquals("fail", classifier.classify(59));
    }
}

这里的重点不是为了让覆盖率工具看到三个分支,而是验证边界定义:

  • -1 是否非法;
  • 60 是否属于通过;
  • 59 是否属于不通过。

如果只为覆盖率增加一个调用,但没有断言,可能产生“覆盖率上升、缺陷发现能力不变”的假进步。

9.2 变更覆盖率的计算

设提交前代码集合为 PP,提交新增或修改的可执行行集合为 ΔP\Delta P,其中被测试执行的行是 ΔPc\Delta P_c,则变更覆盖率可写为:

Changed Coverage=ΔPcΔP\text{Changed Coverage} = \frac{|\Delta P_c|}{|\Delta P|}

它比总覆盖率更能约束新代码,但计算依赖变更识别、源码映射和报告合并。以下情况会使它不稳定:

  • 重命名文件导致工具无法匹配旧文件;
  • 代码格式化造成大量行变化;
  • 生成代码被误判为变更代码;
  • 合并提交的 diff 范围定义不同;
  • 分支合并后使用了错误的基准提交。

所以 CI 必须固定基准分支、差异算法和报告生成方式。


十、Java 25 LTS 下的版本和兼容性边界

Java 25 是文章范围内的运行时和编译目标,但上述工具并不都属于 Java SE。需要分别确认:

  1. Java 语言和标准库行为
    由 Java Language Specification 和 Java SE API 规范定义。例如引用相等、短路求值、异常规则属于语言或平台语义。

  2. Checkstyle 的 Java 语法支持
    新语法能否被解析,取决于 Checkstyle 版本。JDK 能编译某个语法,并不表示旧版 Checkstyle 能解析它。

  3. Error Prone 的编译器集成
    取决于 Error Prone、构建插件和 JDK 编译器实现之间的兼容性。必须使用发布说明中明确支持当前 JDK 的组合。

  4. SpotBugs 的字节码支持
    取决于 SpotBugs 及其字节码解析依赖是否能读取 Java 25 生成的 class 文件。源码能编译,不代表旧分析器能分析。

  5. 覆盖率工具的 class 文件支持
    插桩器和报告器需要支持目标字节码版本;否则可能出现“测试通过但无法生成覆盖率”的故障。

升级到 Java 25 时,应在 CI 中执行一个最小验证工程:

public final class Java25SmokeTest {
    public static void main(String[] args) {
        System.out.println(Runtime.version());
    }
}

验证顺序应包括:

JDK 25 编译成功
→ Checkstyle 能解析所有主源码
→ Error Prone 能完成编译
→ SpotBugs 能读取生成的 class
→ 测试能运行
→ 覆盖率能插桩并生成 XML
→ 门禁能正确读取 XML 并拒绝一个故意不达标的构建

最后一步很重要。只验证“正常构建能通过”,不能证明门禁真的有效。应临时加入一个不满足阈值或触发规则的最小变更,确认 CI 返回失败,再恢复代码并重新验证。


十一、常见错误配置与诊断方法

11.1 只启用 Checkstyle

表现:

代码格式全部通过
测试也通过
生产环境出现空指针或资源泄漏

原因是 Checkstyle 只观察源代码结构,不能替代字节码数据流分析和运行测试。

诊断方法:

  • 查看是否实际执行了 SpotBugs;
  • 查看 SpotBugs 输入目录是否包含最新 class 文件;
  • 检查测试是否覆盖异常和边界路径;
  • 检查报告是否被 CI 上传但未参与门禁。

11.2 只看总体覆盖率

表现:

总覆盖率 85%
关键支付分支没有测试

原因是大模块或简单代码掩盖了关键代码缺口。

改进时应拆分统计范围,并明确:

  • 业务核心包的分支阈值;
  • 变更代码阈值;
  • 异常路径和边界值测试;
  • 集成测试是否单独统计。

11.3 SpotBugs 没有结果就当作安全

表现:

SpotBugs 报告为空

这可能表示:

  • 确实没有匹配到缺陷;
  • 没有扫描到目标类;
  • classpath 不完整;
  • 工具不支持当前字节码;
  • 扫描范围配置错误。

验证方法是故意加入一个已知可识别的缺陷样例,确认工具能报告它;再删除样例,避免把“工具未运行”误判成“代码没有问题”。

11.4 工具版本漂移

表现:

开发机通过,CI 失败
同一提交在不同日期产生不同报告

常见原因是:

  • 使用浮动版本;
  • Maven 或 Gradle 缓存了不同依赖;
  • 本地 JDK 与 CI JDK 不同;
  • Checkstyle、Error Prone、SpotBugs 或覆盖率工具自动升级;
  • 报告规则集没有纳入版本控制。

解决方式是锁定:

  • JDK 发行版和版本;
  • 构建工具版本;
  • 每个分析器及插件版本;
  • 规则配置;
  • 依赖解析结果。

升级时应单独提交工具升级变更,避免把业务代码变化和大量新诊断混在同一个提交中。


十二、如何设计一条可信的质量门禁

一个实用的门禁应满足四个条件。

条件一:每个失败都可定位

报告至少应带有:

  • 文件;
  • 行号;
  • 规则或缺陷类型;
  • 严重级别;
  • 可读消息;
  • 对应构建或提交编号。

否则开发者只能看到“质量检查失败”,却无法快速修复。

条件二:默认阻断真实风险,而不是所有噪声

可以采用分层策略:

Checkstyle error              → 阻断
Error Prone error              → 阻断
SpotBugs 高优先级新增问题      → 阻断
测试失败                       → 阻断
报告生成失败                   → 阻断
总分支覆盖率低于阈值            → 阻断
低置信度静态分析问题             → 先报告,人工确认
已登记的遗留问题                → 不重复阻断

这里的“高优先级”“新增”“遗留”必须由项目配置和基线具体定义。

条件三:质量数据不能静默缺失

以下情况不应被视为通过:

没有找到覆盖率 XML
没有找到 SpotBugs 报告
测试命令没有执行
只扫描了部分模块
分析器因兼容性问题退出

否则门禁实际判断的是“有没有报告”,而不是“代码是否满足要求”。

条件四:失败后能够恢复

失败恢复路径应明确:

  1. 查看 CI 中第一个失败阶段;
  2. 下载机器可读报告和 HTML 报告;
  3. 在本地使用相同 JDK 和工具版本重现;
  4. 修复代码或提交有审查依据的最小抑制;
  5. 重新运行同一检查;
  6. 若是工具故障,修复构建环境后重新生成基线或报告;
  7. 不直接删除门禁步骤来恢复绿色构建。

结语:把工具放在它能观察的位置

Checkstyle、Error Prone、SpotBugs 和覆盖率工具的核心差异,可以归纳为输入和时机:

工具或机制 主要输入 主要发现内容 不能证明
Checkstyle Java 源代码 结构、命名、格式、部分简单规则 运行时数据流和业务正确性
Error Prone 编译过程、语法树、类型信息 高风险 Java 写法、编译期语义问题 外部系统状态和完整业务逻辑
SpotBugs .class 字节码及依赖 数据流、控制流和缺陷模式 所有运行时路径和业务意图
覆盖率 测试执行数据 测试实际经过的代码 未执行代码是否必然错误、已执行代码是否断言正确
门禁 前述报告和退出码 是否允许合并或继续发布 自动替代人工设计审查

在 Java 25 LTS 项目中,可靠的做法不是追求一个更大的“质量分数”,而是保持这条因果链完整:

源代码规则检查
→ 编译期语义检查
→ 字节码缺陷分析
→ 测试执行
→ 覆盖率计算
→ 明确定义的门禁决策

只要每一阶段的输入、输出、失败含义和版本兼容性都被明确记录,质量工具才会从“CI 上的一堆报告”变成可以持续约束代码演进的工程系统。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。