Checkstyle、SpotBugs 与 PMD:Java 静态质量门禁
Java 项目经常同时出现 Checkstyle、SpotBugs 和 PMD,但“都能扫描 Java”不代表三者重复。Checkstyle 约束源码风格和结构,SpotBugs 从编译后的 JVM 字节码寻找缺陷模式,PMD 基于源码语法树执行可配置规则。架构师需要设计的不是工具数量,而是每一层检查什么、何时运行、如何失败、谁能抑制以及升级后怎样避免全仓库永久红灯。
三个绿灯为什么仍会漏掉缺陷
一个 Java 服务在合并请求里同时跑了 Checkstyle、PMD 和 SpotBugs,三项都是绿灯,线上却仍然发生空指针。复盘时才发现:Checkstyle 只检查了主模块源码,PMD 使用的默认规则集在插件升级后发生变化,SpotBugs 在编译前启动并输出 No classes found,流水线又把分析错误当成普通报告上传。工具都“执行过”,有效分析却没有发生。
三种工具观察的是不同对象。Checkstyle 读取单个源文件的 Token 和语法树,适合约束命名、导入、布局与局部结构;PMD 解析源码 AST,并在 classpath 完整时使用类型信息发现易错模式、复杂度和重复代码;SpotBugs 分析编译后的 JVM 字节码与调用关系,寻找已知缺陷模式。测试、代码评审、SAST 和运行时观测仍承担它们各自的证明责任,静态扫描绿灯不能被宣传成“代码正确”。
| 工具 | 分析对象 | 擅长回答 | 必要前置 | 不能代替 |
|---|---|---|---|---|
| Checkstyle | Java 源文件、Token/AST | 命名、导入、布局和结构是否符合团队规范 | 可读取源码 | 真实缺陷分析 |
| SpotBugs | .class 字节码及依赖关系 | 编译产物中是否出现已知缺陷模式 | 先完成编译 | 源码格式检查 |
| PMD | 源码 AST 与类型信息 | 是否存在复杂、重复、易错或不良设计模式 | 源码;部分规则需要正确 auxclasspath | 字节码行为检查 |
先证明构建模型可信
扫描器接入前,Maven Wrapper 或 Gradle Wrapper 必须能在 CI 使用的 JDK 上完成编译,私服和代理也必须可用。SpotBugs 没有 .class 与依赖 classpath 就没有分析对象;PMD 的类型解析缺失依赖时也可能降低结果可信度。先记录构建基线:
java -version
./mvnw --version
./gradlew --versionWindows PowerShell 使用 ./mvnw.cmd 与 ./gradlew.bat。规则文件进入仓库固定目录,例如 config/checkstyle/、config/spotbugs/ 和 config/pmd/;主源码、测试源码、生成代码与第三方代码的归属也要在 source set 中明确,而不是靠宽泛抑制猜测。
Checkstyle 的配置模型、SpotBugs 的 Filter和 PMD 的规则集解决的问题并不相同。SpotBugs 可以按既有 bug collection 匹配历史实例;Checkstyle 与 PMD 没有完全等价的通用实例基线。PMD incremental analysis 只是性能缓存,不能隐藏历史违规。这个差异会直接决定存量治理方案。
Maven:让插件版本和引擎版本都可见
父 POM 用 pluginManagement 锁版本和公共配置,真正需要执行的模块仍要在 plugins 中声明插件。只写进 pluginManagement 不会自动运行。
<properties>
<maven.checkstyle.plugin.version>3.6.0</maven.checkstyle.plugin.version>
<checkstyle.version>13.7.0</checkstyle.version>
<spotbugs.plugin.version>4.10.2.0</spotbugs.plugin.version>
<maven.pmd.plugin.version>3.28.0</maven.pmd.plugin.version>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>${maven.checkstyle.plugin.version}</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>${checkstyle.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>${spotbugs.plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>${maven.pmd.plugin.version}</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<executions>
<execution>
<id>checkstyle-main</id>
<phase>validate</phase>
<goals><goal>check</goal></goals>
<configuration>
<configLocation>${maven.multiModuleProjectDirectory}/config/checkstyle/checkstyle.xml</configLocation>
<suppressionsLocation>${maven.multiModuleProjectDirectory}/config/checkstyle/suppressions.xml</suppressionsLocation>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<executions>
<execution>
<id>spotbugs-main</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<effort>Max</effort>
<threshold>Low</threshold>
<failOnError>true</failOnError>
<xmlOutput>true</xmlOutput>
<excludeFilterFile>${maven.multiModuleProjectDirectory}/config/spotbugs/exclude.xml</excludeFilterFile>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<executions>
<execution>
<id>pmd-main</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
<goal>cpd-check</goal>
</goals>
<configuration>
<rulesets>
<ruleset>${maven.multiModuleProjectDirectory}/config/pmd/ruleset.xml</ruleset>
</rulesets>
<failOnViolation>true</failOnViolation>
<printFailingErrors>true</printFailingErrors>
<minimumTokens>100</minimumTokens>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>这组版本用于说明“插件与引擎分别锁定”的做法:Checkstyle 13.x 要求运行 Maven 的 JDK 至少为 21;仍运行 JDK 8 或 17 的构建不能照抄该引擎版本。Maven PMD Plugin 3.28.0 自带 PMD 7.17.0,若覆盖 PMD 运行时依赖,还要重新验证规则路径、目标 JDK 与插件兼容性。升级前用 ./mvnw help:effective-pom 和 ./mvnw dependency:resolve-plugins确认真正生效的版本,不能只看属性名。
Gradle:统一插件但保留任务边界
plugins {
id 'java'
id 'checkstyle'
id 'pmd'
id 'com.github.spotbugs' version '6.5.8'
}
checkstyle {
toolVersion = '13.7.0'
configFile = rootProject.file('config/checkstyle/checkstyle.xml')
}
pmd {
toolVersion = '7.24.0'
ruleSets = []
ruleSetFiles = files(rootProject.file('config/pmd/ruleset.xml'))
}Gradle 内建 Checkstyle/PMD 插件,SpotBugs 使用独立插件。当前 Gradle 文档列出的 PMD 受测上限是 7.24.0,即使 PMD 独立发行版已经更新,也不应越过构建工具的兼容矩阵直接升级。多项目仓库通过 convention plugin 统一配置,避免把几十行任务配置复制到每个 subprojects 块。
Checkstyle:规则与抑制分离
config/checkstyle/checkstyle.xml:
<?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"/>
<module name="SuppressionFilter">
<property name="file" value="${checkstyle.suppressions.file}"/>
</module>
<module name="TreeWalker">
<module name="AvoidStarImport"/>
<module name="NeedBraces"/>
<module name="TypeName"/>
<module name="MethodName"/>
</module>
</module>抑制文件只保留最小匹配范围:
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
<suppressions>
<suppress files="GeneratedClient\.java" checks="MethodName"/>
</suppressions>按行号抑制在代码移动后容易漂移;宽泛 files=".*" 则可能吞掉所有新增问题。需要源码注解抑制时,还要正确配置 SuppressWarningsHolder 和 SuppressWarningsFilter,并非写了 @SuppressWarnings 就自动生效。
SpotBugs:Filter 与历史基线不是一件事
<FindBugsFilter>
<Match>
<Class name="~.*Generated.*"/>
<Bug category="STYLE"/>
</Match>
</FindBugsFilter>include/exclude filter 按类、方法、Bug pattern、category 等规则匹配一类结果。excludeBugsFile/baselineFile 按旧报告中的缺陷实例匹配历史问题。@SuppressFBWarnings 是源码级局部决策,应填写 justification。
不要把 filter 当 baseline:前者可能永久排除未来同类问题,后者用于隔离当前已知实例。也不要用 maxAllowedViolations 作为长期基线,因为“总数没增加”仍可能用一个新高风险问题替换一个旧低风险问题。
PMD:显式 ruleset,不依赖默认集合
<?xml version="1.0"?>
<ruleset name="team-java"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>团队 Java 源码规则</description>
<rule ref="category/java/errorprone.xml/BrokenNullCheck"/>
<rule ref="category/java/errorprone.xml/CloseResource"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>
</ruleset>显式引用单条规则比引用整个 category 稳定:PMD 修复假阴性或向 category 增加规则时,整类引用可能让小版本升级直接改变门禁结果。PMD 支持 @SuppressWarnings("PMD.RuleName")、NOPMD、violationSuppressRegex 和 violationSuppressXPath。优先修代码或调整规则属性,其次才是局部抑制。增量缓存只减少重复计算,最终报告仍应与完整扫描一致,不能用于隐藏存量问题。
创建一个有意违规的类:
package example;
import java.util.*;
public class bad_name {
private void unused() {
}
public String value() {
String text = null;
return text.trim();
}
}不能用一次 clean verify 证明三个坏样例都被识别。Checkstyle 绑定在 validate,它失败后 Maven 会立即停止当前 reactor,后续 compile 与 verify 不会执行,SpotBugs 和 PMD 报告自然也不会存在。生产门禁保持尽早失败;验证规则有效性时则分别运行三个 execution,并显式跳过其他门禁:
restore_errexit=0
case $- in
*e*) restore_errexit=1; set +e ;;
esac
./mvnw -DskipTests checkstyle:check@checkstyle-main
checkstyle_rc=$?
./mvnw -DskipTests \
-Dcheckstyle.skip=true -Dpmd.skip=true \
compile spotbugs:check@spotbugs-main
spotbugs_rc=$?
./mvnw -DskipTests \
-Dcheckstyle.skip=true -Dspotbugs.skip=true \
pmd:check@pmd-main
pmd_rc=$?
if [ "$restore_errexit" -eq 1 ]; then set -e; fi
printf 'checkstyle=%s spotbugs=%s pmd=%s\n' \
"$checkstyle_rc" "$spotbugs_rc" "$pmd_rc"
if [ "$checkstyle_rc" -eq 0 ] || [ "$spotbugs_rc" -eq 0 ] || [ "$pmd_rc" -eq 0 ]; then
printf '%s\n' '反向实验失败:至少一个 Java 门禁没有拦住故意违规输入' >&2
exit 1
fi三项状态都必须非零,并分别核对对应证据:
target/checkstyle-result.xml 包含 TypeName 与 AvoidStarImport。target/spotbugsXml.xml 包含对确定为空值执行 trim() 的空指针缺陷模式;若只有 missing class 或 analysis error,不能算命中成功。target/pmd.xml 包含 UnusedPrivateMethod。
target/cpd.xml 只有达到 minimumTokens 的重复片段才会出现结果;这个短样例不应为了凑数伪造 CPD 违规。
这里的 checkstyle.skip、pmd.skip 与 spotbugs.skip 只隔离反向实验,不应写进生产流水线。SpotBugs 命令先经过 compile,保证分析对象和 classpath 已生成;PMD 直接调用已声明的 execution,避免再次触发 validate 阶段的 Checkstyle。
Gradle 对应入口通常是:
./gradlew checkstyleMain pmdMain spotbugsMain
./gradlew check把类名改为 QualitySmoke,改成显式 import,删除未使用私有方法,并在调用 trim() 前处理 null。重新执行 clean verify 后,任务应返回 0,旧报告也应由 clean 生命周期清除。最后删除实验类并再次运行验证,避免把故意违规样例留在生产源码集。
Maven 多模块
推荐把规则文件制成独立 quality-rules 构建制品,或统一放在根仓库固定路径。前者适合多个仓库共享,后者适合单一 monorepo。父 POM 负责锁版本和默认规则,子模块只声明是否参与、源码边界和必要例外。
执行顺序建议:
validate 或本地快速任务执行 Checkstyle。compile 后执行 PMD 与 SpotBugs;SpotBugs必须能看到完整 classpath。verify 聚合门禁并上传报告。
聚合 POM、BOM、纯资源模块不强行执行 Java 扫描。
父 POM 只放 pluginManagement 不会执行插件;反过来,在父与子模块都重复声明 execution,可能造成重复扫描。用 mvn help:effective-pom 检查最终模型。
Gradle 多项目
使用 build-logic convention plugin:
plugins {
java
checkstyle
pmd
id("com.github.spotbugs")
}
checkstyle {
configFile = rootProject.file("config/checkstyle/checkstyle.xml")
}
tasks.withType<Checkstyle>().configureEach {
reports {
xml.required.set(true)
html.required.set(true)
}
}根任务负责依赖各子项目的检查任务,但报告路径必须按项目隔离,防止并发写同一文件。生成源码应通过 source set 或任务配置排除,而不是加入覆盖全仓库的 suppression。
CI 分层
PR:运行全部主源码规则,机器报告作为制品;只有经过批准的 baseline 才允许历史债务不过门。主分支:补充测试源码、聚合报告和趋势统计。定时任务:影子运行候选新版本或更严格规则,不立刻阻断交付。
清理与回滚
Maven 报告和缓存应落在各模块 target/,Gradle 报告落在各项目 build/。实验结束后先确认没有需要留存的差异,再用构建工具清理自己的产物:
./mvnw clean
./gradlew clean
git status --short不要把 target/spotbugsXml.xml、SARIF 或 HTML 当成无害临时文件:它们可能包含内部包名、文件路径、类和方法信息。CI 制品应有源码同级访问控制与保留期,本机报告不应被误提交。
门禁升级的回滚单元包含父 POM 或 convention plugin、规则文件、抑制文件、Wrapper 与版本目录。回退时恢复上一组经过验证的文件,清理构建产物,再运行同一条 clean verify 或 clean check;只降插件、不降规则,或只恢复规则、不恢复引擎,都会产生一套从未验证过的新组合。若规则已自动改写源码,源码变更要作为独立提交撤销,不能靠关闭门禁掩盖。
# Maven 查看最终插件配置
./mvnw help:effective-pom > target/effective-pom.xml
# 只运行一个模块及其依赖
./mvnw -pl service-a -am verify
# Gradle 查看任务依赖与实际执行原因
./gradlew check --dry-run
./gradlew spotbugsMain --info
# PMD CLI 生成 SARIF,并启用增量计算缓存
pmd check --dir src/main/java \
--rulesets config/pmd/ruleset.xml \
--cache .pmd/cache.bin \
--format sarif \
--report-file build/reports/pmd/main.sarif报告判断顺序应为:任务是否真的扫描了目标文件、分析是否报错、违规数量和类型、抑制是否生效、CI 是否按预期失败。只有“生成了 HTML”不能证明门禁生效。
| 现象 | 判断与原因 | 修复与再验证 |
|---|---|---|
| 本地通过、CI 出现大量 Checkstyle 违规 | JDK、换行、字符集、插件或规则文件不同 | 锁定 Wrapper/插件/引擎版本,显式 UTF-8,再比较 effective config |
SpotBugs 报 No classes found 或结果为空 | 在编译前运行、分析错目录或模块没有 JVM class | 先执行 compile,检查 class 目录和任务依赖 |
| SpotBugs 缺少依赖类并产生不可信结果 | aux classpath 不完整或依赖解析失败 | 先修复依赖下载和 classpath;不能把 analysis error 当作“零缺陷” |
| PMD 升级后规则找不到 | 规则路径移动、删除或 PMD 主版本迁移 | 查看迁移指南,修 ruleset,影子运行后再切门禁 |
| 抑制后新问题也消失 | Filter/正则范围过宽 | 缩小到文件、规则和具体符号,增加反向测试证明新问题仍会失败 |
| 根项目通过但子模块没扫描 | 插件只在 pluginManagement,或 convention plugin 未应用 | 查 effective POM/Gradle task graph,逐模块核对报告 |
| 并行构建报告互相覆盖 | 多模块写相同输出路径 | 输出路径加入 module/project 标识后重跑 |
| PMD cache 后结果异常 | 缓存损坏或配置边界未被正确识别 | 用 --no-cache 做一次全量对照;缓存不是合规证据本身 |
三款工具本身通常不需要业务账号,但构建过程可能访问 Maven/Gradle 私服、插件仓库和规则制品库:
仓库凭证放在 CI Secret、Maven settings.xml 或受控 Gradle credentials 中,不写入 POM、规则文件或命令行。Fork PR 不应得到内部制品库的高权限 Token;必要时使用代理缓存或只读临时凭证。XML、SARIF 和 HTML 可能包含源码路径、类名、方法名和缺陷描述,制品访问权限要与源码仓库一致。
自定义 Check、Detector 或 PMD Rule 是构建时执行的代码,只从受控仓库加载,并纳入供应链审查。代理或内部 CA 失败时,先验证仓库 TLS 与证书链,不要通过关闭证书校验长期绕过。
建立一份可审计的规则合同:
| 项目 | 必填内容 |
|---|---|
| 规则来源 | 工具、规则 ID、规则集版本 |
| 执行面 | IDE 提示、本地任务、PR CI、主分支或定时任务 |
| 失败标准 | 严重性、置信度、是否只限新问题 |
| 例外 | 原因、owner、审批人、到期时间、关联任务 |
| 证据 | XML/SARIF 路径、构建 URL、工具与 JDK 版本 |
| 升级 | 影子期、差异报告、切换日期和回滚版本 |
规则 owner 不应只是“平台团队”。Java 技术负责人判断规则技术价值,安全负责人管理安全类检测器,研发效能团队维护构建接入,业务团队负责修复和解释局部例外。
三种扫描都绿,不代表代码正确
静态工具只覆盖已表达的规则和可观察模型。判断标准不是“工具数大于等于三”,而是关键故障模式是否还有测试、评审、SAST 和运行时证据。门禁报告要明确覆盖边界,禁止宣传为零风险证明。
多模块 classpath 不完整会制造假阴性
SpotBugs 与需要类型解析的 PMD 规则依赖完整 classpath。典型现象是 missing class 警告、分析错误或某些规则突然不再命中。CI 应把 analysis error 与 violation 分开统计,并对 analysis error 默认失败;否则“扫描器没看懂”会被展示成通过。
存量债务不能靠永久放宽阈值治理
只允许 500 个问题并不能证明第 501 个才是新增问题。SpotBugs 可使用实例 baseline;Checkstyle/PMD 更适合通过变更文件门禁、分批修复或外部差异工具治理。每次 baseline 变更都要审查新增、消失和指纹漂移,并设置收敛目标。
抑制是代码决策,不是消音按钮
出现大量 NOPMD、@SuppressFBWarnings 或宽泛 suppression 时,统计“抑制新增量”和“到期未清理量”。规则升级后抽样复核旧抑制;规则 ID、消息和 AST 变化都可能让抑制失效或错误扩张。
升级会改变门禁含义
引擎、新规则、JDK parser 和插件任何一项升级都可能改变结果。先在非阻断任务运行旧、新两套版本,输出新增/消失/分析错误差异;通过后再合并版本锁。回滚必须同时恢复插件、引擎和规则制品,不能只降一个组件。
构建耗时必须按反馈层分配
Checkstyle 通常适合高频运行;SpotBugs 对大型字节码图更重;PMD 耗时受规则与类型解析影响。用真实仓库 P50/P95 记录耗时,PR 保留价值最高的完整门禁,候选规则放定时影子任务。禁止为了速度把主分支最终复核也关掉。
报告保存本身存在数据风险
SARIF 可包含文件 URI、代码位置和消息,HTML 可能带源码片段。上传前确认路径脱敏、制品权限和保留期;外部代码扫描平台还要额外确认源码出境、地域和合同边界。
已明确 Checkstyle、SpotBugs、PMD 的分析对象和职责边界。Maven/Gradle Wrapper、插件、引擎和规则集均已锁定版本。SpotBugs 在编译后执行,classpath 与 analysis error 已纳入门禁。
多模块配置来自单一治理入口,且报告不会相互覆盖。至少一个故意违规样例能让每个目标任务按预期失败。XML 或 SARIF 已归档,控制台成功不作为唯一证据。
Filter、suppression、baseline 和 incremental cache 没有混用概念。每条例外都有原因、owner、期限和审计入口。升级前运行了旧新版本差异扫描,并准备完整回滚版本。
报告、仓库凭证和自定义规则制品符合最小权限要求。
