本节目标:用 Checkstyle 守住团队能达成共识的源码规则,用 SpotBugs 抓编译器看不见的字节码缺陷,并把两者接到
libraryd的qualityprofile 上。
适用版本: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 用两个工具,分工明确:
| 工具 | 分析对象 | 擅长发现 | 不擅长发现 |
|---|---|---|---|
| Checkstyle | Java 源码的 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即可修复。 - 质量插件放在
qualityprofile 并绑定verify,日常mvn test不被拖慢。
静态分析和编译告警都通过后,还缺最后一块:测试到底覆盖了多少代码,覆盖率低于多少应该阻断合并。下一节把 JaCoCo 接进同一条流水线。
阅读导航:上一节:2.1 编译告警:把 -Xlint 变成门禁 · 下一节:2.3 质量门禁:JaCoCo 覆盖率与 CI 流水线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。