《Java 编程实战》2.2 静态分析:Checkstyle 与 SpotBugs 的规则取舍

区分 Checkstyle 管源码风格、SpotBugs 管字节码缺陷:给出一份只保留命名、导入、大括号和魔法数字的精简规则集,用真实违规输出解释阈值与抑制范围,再用 CT_CONSTRUCTOR_THROW 说明 SpotBugs 的第一条告警为什么把异常类声明为 final 就能修复。

本节目标:用 Checkstyle 守住团队能达成共识的源码规则,用 SpotBugs 抓编译器看不见的字节码缺陷,并把两者接到 libraryd 的 quality profile 上。
适用版本:Checkstyle 10.26.1(maven-checkstyle-plugin 3.6.0)、SpotBugs 4.10.5.0、Maven 3.9.12、JDK 25.0.5。

承接的问题

2.1 的门禁让 javac 的警告变成了构建失败。但编译器只关心语言规则:类型对不对、转换是否安全、序列化有没有声明。它不会说「这个 if 没有大括号」「这个类的 equals 和 hashCode 不配对」「这个构造器抛异常可能留下半初始化对象」。

这些问题的共同点是「人看得出来,编译器不报」。靠 code review 也能看,但 review 的注意力应该放在业务边界上,不该反复讨论 import 顺序。静态分析工具的价值是把可机械判断的部分拿走,让 review 聚焦在设计和取舍上。

libraryd 用两个工具,分工明确:

工具分析对象擅长发现不擅长发现
CheckstyleJava 源码的 AST命名、导入、大括号、空块、行长、魔法数字运行时语义、并发问题、空指针路径
SpotBugs编译后的字节码equals/hashCode 不配对、构造器抛异常、资源泄漏、可疑的空值处理命名风格、代码格式

两个都跑,不等于规则越多越好。规则集一旦超出团队共识,第一次接入就会被大量历史告警淹没,最后以「先关掉」收场。本节的原则是:先选能解释清楚的规则,再谈覆盖率。

Checkstyle:只保留能达成共识的规则

libraryd 的规则集放在仓库根的 .config/checkstyle.xml,由父 POM 统一引用。内容如下:

<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
    "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
    "https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
  <property name="charset" value="UTF-8"/>
  <property name="severity" value="warning"/>

  <module name="SuppressionFilter">
    <property name="file" value="${config_loc}/checkstyle-suppressions.xml"/>
  </module>

  <module name="FileTabCharacter"/>
  <module name="LineLength">
    <property name="max" value="120"/>
  </module>

  <module name="TreeWalker">
    <module name="AvoidStarImport"/>
    <module name="UnusedImports"/>
    <module name="RedundantImport"/>
    <module name="NeedBraces"/>
    <module name="OneStatementPerLine"/>
    <module name="EmptyBlock">
      <property name="option" value="text"/>
    </module>
    <module name="EqualsHashCode"/>
    <module name="MagicNumber">
      <property name="ignoreHashCodeMethod" value="true"/>
      <property name="ignoreNumbers" value="-1, 0, 1, 2, 3, 400, 404, 409, 500, 503"/>
    </module>
    <module name="TypeName"/>
    <module name="MethodName"/>
    <module name="ConstantName"/>
    <module name="LocalVariableName"/>
  </module>
</module>

这份规则集刻意不包含 Javadoc 强制、80 列换行、final 参数、成员排序。它们要么与团队习惯冲突,要么在现代 IDE 里已经由格式化工具处理。保留的规则可以逐条解释:

  • AvoidStarImport、UnusedImports、RedundantImport 管导入,避免 import java.util.* 和残留 import 造成阅读噪音。
  • NeedBraces、OneStatementPerLine、EmptyBlock 管控制流形状,减少「少写一个大括号导致下一行无条件执行」的事故。
  • EqualsHashCode 管契约:重写 equals 必须同时重写 hashCode,否则 HashMap 行为不可预测。
  • MagicNumber 管业务常量:50 分、14 天、1000 年这类数字应该提成命名常量。协议状态码 400/404/409/500/503 在 ErrorCode 枚举里只出现一次,按 ignoreNumbers 放行,不为了规则而再造一层常量。
  • LineLength 设 120 列,是团队能接受的折中,不追求 80 列。
  • TypeName、MethodName、ConstantName、LocalVariableName 只检查命名格式,不检查命名语义。

测试代码里的字面量是断言数据,assertEquals(150, ...) 的 150 不应该提成常量。libraryd 用一份抑制文件只关掉测试目录的 MagicNumber:

<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
    "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
    "https://checkstyle.org/dtds/suppressions_1_2.dtd">
<suppressions>
  <suppress checks="MagicNumber" files="[\\/]src[\\/]test[\\/]"/>
</suppressions>

抑制范围精确到「测试目录 + MagicNumber 一条规则」,测试里的命名、导入、大括号仍然照常检查。

Maven 侧的配置需要解决一个多模块路径问题:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-checkstyle-plugin</artifactId>
  <version>3.6.0</version>
  <dependencies>
    <dependency>
      <groupId>com.puppycrawl.tools</groupId>
      <artifactId>checkstyle</artifactId>
      <version>10.26.1</version>
    </dependency>
  </dependencies>
  <executions>
    <execution>
      <id>checkstyle</id>
      <phase>verify</phase>
      <goals>
        <goal>check</goal>
      </goals>
      <configuration>
        <configLocation>${maven.multiModuleProjectDirectory}/.config/checkstyle.xml</configLocation>
        <propertyExpansion>config_loc=${maven.multiModuleProjectDirectory}/.config</propertyExpansion>
        <includeTestSourceDirectory>true</includeTestSourceDirectory>
        <failOnViolation>true</failOnViolation>
        <violationSeverity>warning</violationSeverity>
      </configuration>
    </execution>
  </executions>
</plugin>

两个路径属性值得解释。configLocation 如果用相对路径 .config/checkstyle.xml,在子模块里会按子模块的 basedir 解析,找不到父目录的文件,因此用 ${maven.multiModuleProjectDirectory} 指到 reactor 根。规则文件里的 ${config_loc} 不是 Maven 属性,Checkstyle 自己不会从 Maven 继承,必须通过 <propertyExpansion> 显式传进去,否则会报:

[ERROR] Failed during checkstyle configuration: unable to parse configuration stream:
com.puppycrawl.tools.checkstyle.api.CheckstyleException:
Property ${config_loc} has not been set

这是本机第一次运行时的真实报错。补上 propertyExpansion 后配置才能解析。

规则生效后的失败长这样。往 domain 里放一个使用星号导入、if 不带大括号的类:

[WARNING] src/main/java/com/plume/library/domain/BadStyle.java:[3,17] (imports) AvoidStarImport: 不应使用 '.*' 形式的导入 - java.util.* 。
[WARNING] src/main/java/com/plume/library/domain/BadStyle.java:[7,9] (blocks) NeedBraces: 'if' 结构必须使用大括号 '{}'。
[ERROR] Failed to execute goal ...:checkstyle:3.6.0:check (checkstyle) on project libraryd-domain:
You have 2 Checkstyle violations.

本机输出。两条都是 warning 级别,但 failOnViolation=true 让它们阻断构建。修复后重新运行,插件不再打印违规行,构建继续。

SpotBugs:从 Medium 基线开始

SpotBugs 分析字节码,能发现源码层面不容易看出的模式。libraryd 的配置:

<plugin>
  <groupId>com.github.spotbugs</groupId>
  <artifactId>spotbugs-maven-plugin</artifactId>
  <version>4.10.5.0</version>
  <executions>
    <execution>
      <id>spotbugs</id>
      <phase>verify</phase>
      <goals>
        <goal>check</goal>
      </goals>
      <configuration>
        <effort>Max</effort>
        <threshold>Medium</threshold>
        <excludeFilterFile>${maven.multiModuleProjectDirectory}/.config/spotbugs-exclude.xml</excludeFilterFile>
        <failOnError>true</failOnError>
      </configuration>
    </execution>
  </executions>
</plugin>

effort=Max 让分析更彻底,代价是构建时间增加;threshold=Medium 只报告中等及以上优先级,避免第一次接入就被低优先级建议淹没。排除文件当前是空的,但保留它并写清楚政策:

<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
  <!-- 当前没有需要豁免的告警。新增 Match 时必须写明原因和复核日期。 -->
</FindBugsFilter>

第一次对 domain 运行时,SpotBugs 抓到一条真实告警:

[ERROR] Medium: Exception thrown in class com.plume.library.domain.LibraryException
at new com.plume.library.domain.LibraryException(ErrorCode, String)
will leave the constructor. The object under construction remains partially
initialized and may be vulnerable to Finalizer attacks.
[com.plume.library.domain.LibraryException, com.plume.library.domain.LibraryException]
At LibraryException.java:[line 16] CT_CONSTRUCTOR_THROW

本机输出,两处构造器各报一次。这条告警的意思是:LibraryException 不是 final,可以被继承;如果子类实现了 finalize(),攻击者可以在对象只初始化一半时让 finalizer 运行,看到不该看到的状态。异常类通常只用来携带错误码和消息,不需要被继承。

修复只需要一个关键字:

public final class LibraryException extends RuntimeException {
    // 字段与构造器保持不变
}

把类声明为 final 后,子类攻击面消失,告警不再出现。重新运行:

[INFO] --- spotbugs:4.10.5.0:check (spotbugs) @ libraryd-domain ---
[INFO] BugInstance size is 0

本机输出。BugInstance size is 0 表示没有告警,不是「没有分析」;同一段日志前面还有 Analyzed bundle 'libraryd-domain' with 6 classes。

这条告警值得展开,因为它展示了 SpotBugs 的典型价值:编译器不会因为「类可被继承」报错,Checkstyle 也不会;只有字节码分析会把「构造器抛异常 + 非 final 类」组合成风险。修复成本极低,收益是消除了一个真实的攻击面。

在 libraryd 里落地

两个插件都放在父 POM 的 quality profile 里,绑定到 verify 阶段。下面是其中两个插件的声明,2.3 会把 JaCoCo 加进同一个 profile:

<profile>
  <id>quality</id>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-checkstyle-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>com.github.spotbugs</groupId>
        <artifactId>spotbugs-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</profile>

绑定 verify 而不是 validate,原因有两个:SpotBugs 需要 compile 产出的 .class 文件;Checkstyle 虽然只读源码,但放在测试之后可以让「测试失败」优先于「风格失败」,反馈顺序更符合直觉。libraryd 的 quality profile 还会加入 JaCoCo,2.3 会补上。

integration-tests 模块在第 2 章还没有生产代码,显式跳过三个质量插件:

<properties>
  <checkstyle.skip>true</checkstyle.skip>
  <spotbugs.skip>true</spotbugs.skip>
  <jacoco.skip>true</jacoco.skip>
</properties>

这是有意的例外,不是把阈值降到 0。该模块只有端到端测试的占位类,没有生产字节码:SpotBugs 没有分析对象,JaCoCo 没有可插桩的主类;Checkstyle 虽然能检查测试源码,但第 2 章先整体跳过,避免为一个空模块维护单独的规则口径。第 15 章加入真正的端到端测试后,再重新评估是否恢复。

完整运行:

$ mvn -Pquality verify
[INFO] libraryd-parent .................................... SUCCESS [  3.781 s]
[INFO] libraryd-domain .................................... SUCCESS [  8.052 s]
[INFO] libraryd-persistence ............................... SUCCESS [  4.737 s]
[INFO] libraryd-web ....................................... SUCCESS [  6.388 s]
[INFO] libraryd-app ....................................... SUCCESS [  6.675 s]
[INFO] libraryd-integration-tests ......................... SUCCESS [  6.421 s]
[INFO] BUILD SUCCESS

本机输出。domain 模块耗时最长,因为 SpotBugs 在 Max effort 下要分析全部字节码;这也是为什么质量检查放在 profile 里,而不是每次 mvn test 都跑。

常见错误与边界

直接套用 Sun 或 Google 的完整规则集。 这些规则集面向特定历史背景,包含大量 Javadoc 和换行要求。第一次接入就全开会得到几百条历史违规,团队通常会选择整体关闭。正确顺序是先选 5 到 10 条能达成共识的规则,稳定后再逐条增加。

violationSeverity 和 failOnViolation 配反。 如果 violationSeverity=error 而规则集里的 severity 是 warning,所有 warning 都不会被算作违规,门禁看起来通过了,其实什么都没检查。两者要对齐,或者干脆把规则集的 severity 设为 warning,插件的 violationSeverity 也设为 warning。

SpotBugs 一开始就用 threshold=Low。 低优先级告警数量大、误报多,容易让人形成「看到 SpotBugs 报错就加排除」的习惯。先用 Medium 建立基线,再逐条评估是否降到 Low。

排除文件没有原因。 一条 <Match> 写进去很容易,半年后没人知道为什么忽略。政策应该是:每条排除都写原因、负责人和复核日期;没有这三项就不合并。

Checkstyle 引擎版本与插件版本漂移。 maven-checkstyle-plugin 3.6.0 默认带的 Checkstyle 版本可能不是 10.26.1。libraryd 在插件 <dependencies> 里显式声明引擎版本,避免插件升级时规则行为跟着变。这和 1.3 的插件锁定是同一个思路。

小结

  • Checkstyle 看源码 AST,管命名、导入、大括号、空块和魔法数字;SpotBugs 看字节码,管契约、构造器和资源类缺陷。
  • 规则集先小后大,只保留能解释清楚的规则;libraryd 的规则集刻意不含 Javadoc 和 80 列换行。
  • 测试目录只抑制 MagicNumber 一条规则,其他风格仍然检查。
  • 多模块下用 ${maven.multiModuleProjectDirectory} 定位配置文件,并用 propertyExpansion 注入 ${config_loc}。
  • SpotBugs 的第一条真实告警是 CT_CONSTRUCTOR_THROW;把 LibraryException 设为 final 即可修复。
  • 质量插件放在 quality profile 并绑定 verify,日常 mvn test 不被拖慢。

静态分析和编译告警都通过后,还缺最后一块:测试到底覆盖了多少代码,覆盖率低于多少应该阻断合并。下一节把 JaCoCo 接进同一条流水线。

阅读导航:上一节:2.1 编译告警:把 -Xlint 变成门禁 · 下一节:2.3 质量门禁:JaCoCo 覆盖率与 CI 流水线 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发