工程实践··约 6 分钟·2,244

质量门禁的假绿灯:一次 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.maxViolationsgate.checkstyle.maxViolationsgate.jacoco.linegate.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> 节点往上查,链条是这样的:

  1. 工程编译目标是 Java 21,class 文件 major version 65
  2. PMD 6.55.0 传递依赖 org.ow2.asm:asm:9.4,这一版 asm 只认到 class 文件 major version 64(Java 20)
  3. PMD 读 class 文件时抛 IllegalArgumentException: Unsupported class file major version 6512 个源文件全部解析失败
  4. maven-pmd-pluginskipPmdError 参数默认值是 true,文档里的原话是「pmd execution errors are ignored」

于是处理错误被静默吞掉,报告里只剩下 <error>check 目标看到 0 个违规,构建通过。

结论很难看:这套阿里规约门禁在此之前从未真正生效过。我此前记录的那句「PMD 0 违规」,实际含义是「PMD 一个文件都没分析成功」。

试过的错路

排查中有几个方向是我先走错、又退回来的,记下来免得下次再走一遍。

怀疑 targetJdk 没设。 我一度想显式设 targetJdk=21,让 PMD 知道目标版本。查了才发现 PMD 6.55 的 targetJdk 只接受 1.3~1.89~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、用 optionalLayerallowEmptyShould 豁免。

门禁修好之后抓到了什么

假绿灯修好之后,门禁立刻开始还债。首轮开启时抓到的问题里,有两个是我自己写出来的真缺陷:

  • TraceIdFilter 把请求头原值直接回写响应头 → HTTP 响应头注入(SpotBugs HRS_REQUEST_PARAMETER_TO_HTTP_HEADER)→ 改成字符白名单校验加派生新值
  • WebMvcConfig 构造器直接持有外部传入的可变 ListEI_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 上是同一个绿勾。

目录