质量门禁的假绿灯:一次 PMD 静默失效的排查
构建报 0 违规并不等于检查通过,也可能是检查根本没跑起来。记录一次 PMD 因 asm 版本过旧导致全量解析失败、又被插件默认参数吞掉的排查过程。
门禁长什么样
后端门禁是阻断式的,一条命令复现全部检查:
./mvnw -B clean verify -Pquality五道关卡,阈值全是「0 违规」:
| 关卡 | 工具 | 检查内容 | 阈值 |
|---|---|---|---|
| ① | PMD + p3c-pmd 2.1.1 | 阿里 Java 开发手册的十个规则集 | 违规数 = 0 |
| ② | Checkstyle 10.21 | 风格、导入顺序、行宽 120、禁止 System.out |
违规数 = 0 |
| ③ | ArchUnit 1.5 | 分层方向、特性边界、命名后缀、禁止字段注入 | 违规数 = 0 |
| ④ | SpotBugs 4.10 | effort=Max, threshold=Medium |
缺陷数 = 0 |
| ⑤ | JaCoCo 0.8.13 | 覆盖率 | 行 ≥40%、分支 ≥30% |
阈值没有散落在各个模块里,而是集中在父 pom.xml 的 <properties> 中(gate.pmd.maxViolations、gate.checkstyle.maxViolations、gate.jacoco.line、gate.jacoco.branch),改一处全局生效。覆盖率的排除范围也定死了:配置类与纯数据载体不参与覆盖率考核,但仍然参与 SpotBugs 扫描与架构测试——不然一堆只有 getter 和 setter 的类会把分母灌成水分。
第一次跑通时的记录很漂亮:BUILD SUCCESS,26 个用例(15 个单元测试 + 11 个架构测试),Checkstyle 0 违规,SpotBugs 0 缺陷,PMD 0 违规。我把这条写进了改造记录,然后差点就翻篇了。
让我起疑的那一行日志
PMD 的执行日志里有 6 条这样的诊断:
aktStatus is NULL: maximum Iterations exceeded当时我不知道这是什么,第一反应是「规则集配错了」。这条信息后来查明是 Saxon XPath 引擎的内部诊断,非致命——也就是说,它和真正的问题没有关系。但它确实让我做了一件当时看起来多余的事:去看 PMD 的原始报告文件,而不是只看 Maven 在终端里打印的汇总。
target/pmd.xml 里的内容是:有 <error> 节点,没有任何 <violation> 节点。
一套包含十个类别、几十条规则的静态检查,跑在一个有二十来个源文件、包含统一响应体、全局异常处理器、链路追踪过滤器的工程上,一条违规都没有。如果它是真的,那很了不起;如果它是假的,那这块门禁就是装饰板。
根因:一条四环相扣的链
顺着 <error> 节点往上查,链条是这样的:
- 工程编译目标是 Java 21,class 文件 major version 65
- PMD 6.55.0 传递依赖
org.ow2.asm:asm:9.4,这一版 asm 只认到 class 文件 major version 64(Java 20) - PMD 读 class 文件时抛
IllegalArgumentException: Unsupported class file major version 65,12 个源文件全部解析失败 - 而
maven-pmd-plugin的skipPmdError参数默认值是true,文档里的原话是「pmd execution errors are ignored」
于是处理错误被静默吞掉,报告里只剩下 <error>;check 目标看到 0 个违规,构建通过。
结论很难看:这套阿里规约门禁在此之前从未真正生效过。我此前记录的那句「PMD 0 违规」,实际含义是「PMD 一个文件都没分析成功」。
试过的错路
排查中有几个方向是我先走错、又退回来的,记下来免得下次再走一遍。
怀疑 targetJdk 没设。 我一度想显式设 targetJdk=21,让 PMD 知道目标版本。查了才发现 PMD 6.55 的 targetJdk 只接受 1.3~1.8 与 9~20,传 21 会直接报 Unsupported targetJdk value '21'。所以这个参数在本工程里干脆不设,版本兼容性完全由 asm 的解析能力决定——这也解释了为什么它是链条的第 2 环而不是第 1 环。
怀疑 p3c 规则集本身。 PMD 每次都会打印一条 does not mention attribute language='java' 警告。查证下来这是 p3c 规则集的固有提示,不影响结果,属于噪音。
想升级 maven-pmd-plugin。 这个方向是错的,而且以后也不能走:插件版本必须锁在 3.21.2,它是最后一个内置 PMD 6.x 的版本。3.22+ 换成 PMD 7,而 p3c-pmd 2.1.1 依赖 PMD 6 的 BaseLanguageModule(PMD 7 已删除该类),升上去会直接 NoClassDefFoundError。升级 PMD 的路被规则集本身堵住了——这也是为什么最后只能通过抬 asm 版本来修。
修复:两道,缺一不可
<properties>
<asm.version>9.9.1</asm.version>
</properties>
<plugin>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.21.2</version>
<configuration>
<skipPmdError>false</skipPmdError>
</configuration>
<dependencies>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>${asm.version}</version>
</dependency>
</dependencies>
</plugin>第一道是让 PMD 真的能干活:在插件的 <dependencies> 里显式声明 org.ow2.asm:asm,覆盖传递进来的 9.4。这里的位置很关键——写在项目的 <dependencies> 里没有用,插件的类加载器只认插件自己的依赖树。
第二道是让失败不再静默:skipPmdError=false,任何处理错误立即让构建失败。
只做第一道也能查出错,但下次再遇到同类问题(换 JDK、p3c 升级、基础依赖变动),你会重新掉进同一个坑,而且依然看不出来。两道一起做,才是把「静默」这个类别关掉。
负向测试才是唯一的验收
修完之后跑一遍全绿,这不能说明问题——因为修复前的假绿灯也是全绿。0 违规有两种含义,构建输出区分不了它们。
我做的验收是造一条必然违规的代码当探针:
// 探针:p3c 的 EqualsAvoidNullRule 要求常量在前
if (input.equals("abc")) {
// ...
}跑 ./mvnw -B -Pquality pmd:check,期望 BUILD FAILURE 并提示 EqualsAvoidNullRule。第一次就命中了,说明规则确实在跑。移除探针,恢复全绿。
这条负向用例现在是文档里的固定自检项:换 JDK、升 PMD、动 asm 版本之后必须跑一遍。
探针本身不提交到主干。我是临时写进一个已有类里,跑完立刻删掉,只在文档里写清楚这条验证的手工步骤。把它做成常驻测试是没意义的——它每天都会报错,然后你就会习惯性地忽略它,那就又回到了「门禁不可信」的老路上。顺带一提,这也解释了为什么门禁必须能在本地一条命令复现:CI 上的绿勾不提供细节,只有在本地跑出 BUILD FAILURE、看到具体的规则名,你才知道检查真的执行了。
同样的思路我用在了架构门禁上。ArchUnit 那 11 条规则里有一条专门校验「门禁自身有效性」——扫描到的类不能为空、推导出的特性列表不能为空。理由完全相同:一套扫描不到任何东西的规则集,和一套全部通过的规则集,输出是一样的。而这套规则在接入时也确实当场抓到了自己三处缺陷(package-info 被当成实现类参与命名检查、空层被判为违规、空 should 子句直接报错),修法是排除 package-info、用 optionalLayer 和 allowEmptyShould 豁免。
门禁修好之后抓到了什么
假绿灯修好之后,门禁立刻开始还债。首轮开启时抓到的问题里,有两个是我自己写出来的真缺陷:
TraceIdFilter把请求头原值直接回写响应头 → HTTP 响应头注入(SpotBugsHRS_REQUEST_PARAMETER_TO_HTTP_HEADER)→ 改成字符白名单校验加派生新值WebMvcConfig构造器直接持有外部传入的可变List→EI_EXPOSE_REP2→ 改成List.copyOf
还有一个是规则集自己的问题:Checkstyle 的 CustomImportOrder 组顺序写错了,把 org.* 判成标准 Java 包,产生误报——修的是规则文件,不是代码。
这三件事合起来说明同一个道理:门禁的价值不在于它拦住了多少条,而在于它是可信的。一个会假绿的门禁比没有门禁更糟——没有门禁时你知道自己在裸奔,有假绿灯时你以为自己穿好了衣服。
遗留问题
- p3c 规则集的
does not mention attribute language='java'警告还在,属于噪音,暂时不处理。 - 只要 p3c-pmd 还依赖 PMD 6,
maven-pmd-plugin就锁死在 3.21.2,asm 就得靠插件依赖手动抬。这三条现在写在质量门禁文档的显著位置:插件版本、asm 版本、skipPmdError=false,改版本前必须先读。 - JDK 继续往前升(比如升到 25)时,asm 还得跟着抬。判断方法很直接:看
pmd.xml里有没有<error>,再实跑一次负向用例。 - 覆盖率门限目前是行 40%、分支 30%,偏保守。它现在的定位是「防止倒退」的下限,而不是质量目标;门禁演进计划里是把行覆盖率先提到 60%、分支 45%。
- 架构门禁只扫描生产代码,测试代码被排除——Spring Boot 测试惯用
@Autowired字段注入,不该被「禁止字段注入」这条规则误伤。这是有意留的边界,但要记住它意味着测试代码不在门禁覆盖内。
小结
如果只记一条:凡是门禁工具,都要有一条负向用例证明它会失败。构建输出里的「0 violations」是一个数字,它既可能是「检查通过」,也可能是「什么都没检查」,而这两者在 CI 上是同一个绿勾。