Java开发规约与代码规约插件:IDE扫描到CI增量门禁
简介:面向 Java 后端开发者与团队代码规范建设者,这份资料围绕阿里巴巴代码规约整理,可用于新项目落地统一编码风格、代码评审前自查以及团队规范培训。压缩包共 5 个文件,约 20.14MB,包含两份不同版本的《阿里巴巴Java开发手册》PDF,便于系统查阅规约条目;另有两份 XML 格式的 Eclipse 代码格式化与代码模板配置,以及一个可直接导入的插件压缩包,覆盖 IDE 侧的规约检查与风格同步需求。已有 488 人学习,说明其在 Java 规范入门与团队协作场景中具有一定参考价值。读者可据此快速了解命名、异常、集合、并发等常见约束,并借助插件与配置减少手工排查成本,适合需要把规约落到日常开发流程中的初中级开发者。
1. 一份 Java 开发规约,为什么值得单独拆一遍
去年接手一个跑了六年的订单系统,编译一次过,单测全绿,上线后隔三差五 OOM、空指针、金额对不上。翻了两天代码,问题不在架构,在「随手」:包装类用==比大小、线程池用工具类一把梭建无界队列、SimpleDateFormat当静态成员共享。这些都不是设计缺陷,是没人在写代码那一刻拦住它。
这份代码规约文档加配套的代码规约插件,干的就是这件事——把「随手」变成机器能拦下来的规则。文档给判断依据和正反例,插件把其中可判定的部分做成 IDE 检查项和规则集。适合三类人:接手存量 Java 项目的、要给团队定规范的、准备把检查接进提测闸门的。它不教你写架构,但能让你少在凌晨被告警叫醒。
2. 规约手册的地形图:十几个章节里只有五块值得先啃
拿到这份代码规约文档,很多人的第一反应是从第一页翻到最后一页,翻完记住的只有「命名要驼峰」。这不是文档的问题,是读法的问题。手册本身按主题分章,命名风格、常量定义、代码格式、OOP 规约、日期时间、集合处理、并发处理、控制语句、注释规约、异常日志、单元测试、安全规约、MySQL 数据库、工程结构等各自成篇,条款用「强制 / 推荐 / 参考」三级标注,正文里配正例和反例。
真正决定落地效果的,是你先啃哪几章。我的排序是:命名与常量 → 集合处理 → 并发处理 → 异常日志 → MySQL 建表。这五块在存量项目里出现频率最高,改动成本最低,被机器检出的比例也最高。OOP 规约、控制语句、注释规约放在第二批,安全规约和工程结构放在最后——不是不重要,是它们对上下文依赖太强,容易变成团队吵架的引子。
2.1 三级强制力:强制、推荐、参考怎么区分对待
手册的条款标注不是装饰。强制项代表「违反后一定有可预见的故障或维护成本」,比如线程池必须显式指定参数、SimpleDateFormat不能共享、finally里不能return。这类条款应该直接进 CI 门禁,扫出来就阻断。
推荐项代表「多数场景下更优,但有合理例外」,比如方法行数、注释密度、类的职责拆分。这类适合做成 IDE 警告,人来判断,不进阻断。
参考项更多是风格共识,比如包的命名习惯、字段排列顺序。这类只进格式化模板和 Code Review 清单,不上机器。
我一般会把手册里的强制项手工标一遍,形成一张「P0 清单」,再对照插件的分级看是否对得上。插件默认的三级分类(阻断 / 严重 / 主要)和手册的强制力不是一一映射,有错位,后面第 5 章会讲这个坑。
2.2 条款的读法:先看反例,再看正例
手册每条条款基本是「条款 + 说明 + 正例 + 反例」的结构。多数人只看条款那一行,其实信息量最大的是反例。反例告诉你的是「这条规则在防什么样的具体事故」,而不是「什么样的代码不好看」。
举个典型:集合初始化容量那条,条款只说「HashMap初始化时指定容量」。看反例才知道,它防的是默认容量 16、负载因子 0.75 的情况下,元素数量增长到 12 以上就触发扩容,扩容要重建哈希表,在大批量 put 的场景下是实打实的 CPU 抖动。知道这个,你才会在写批量装配的地方主动去算容量,而不是在所有 new 的地方无脑加参数。
读法建议是:第一遍只读条款标题,把你能立刻想到反例的条款划掉(说明你已经会了);第二遍只读剩下的条款的反例;第三遍读正例,看官方推荐的写法和你平时的写法差在哪。
2.3 哪些条款机器能扫,哪些只能靠人
这一节是整份文档能不能真正落地的分水岭。手册里大约一半以上的条款是可判定的语法或调用模式,比如「常量命名全大写」「不能使用Executors创建线程池」「switch每个 case 必须有终止语句」「方法参数不能超过限定个数」。这类交给插件和 PMD 规则集,零争议。
另一半依赖语义,机器扫不了或者扫了也是误报重灾区:方法职责是否单一、注释是否解释了「为什么」而不是「做什么」、异常该在哪一层捕获。这类只能进 Code Review 清单,或者做成团队内部的设计评审项。
我做落地时会给每个类别标一个「机器覆盖率」,形成下面这张表。这张表的价值在于:当有人说「规约没用,扫完了还是出问题」时,你能指出哪些条款压根没进扫描器。
| 章节类别 | 存量项目触发频率 | 首次落地优先级 | 机器可判定比例(经验值) |
|---|---|---|---|
| 命名风格 | 极高 | P0 | 接近全部 |
| 常量定义 | 高 | P0 | 接近全部 |
| 集合处理 | 高 | P0 | 大部分可判定 |
| 并发处理 | 中高 | P1 | 部分可判定 |
| 异常日志 | 极高 | P0 | 部分可判定 |
| 注释规约 | 高 | P1 | Javadoc 格式类可判定 |
| MySQL 建表 | 中 | P1 | 需 SQL 审核工具配合 |
| 单元测试 | 低 | P2 | 部分可判定 |
提示:表格里的「机器可判定比例」是经验值,不同项目差异很大。存量项目里前置的 Lombok、老版本框架会显著拉低可判定比例,评估时以实际扫描结果为准,不要拿这张表当验收标准。
3. 代码规约插件从装到跑:IDE 内扫描的完整链路
讲完文档,接下来是代码规约插件这条链路。文档解决「知道」,插件解决「当场知道」。很多人把插件当成一个「装了就心安」的装饰,实际上它的价值集中在两个场景:写代码时的实时提示,以及提交前的全量扫描。前者靠内联高亮,后者靠批量检查出报告。
先把原理说清楚,不然后面的参数调不明白。插件本质是把手册条款翻译成 IDE 的 Inspection(检查项),每个检查项有独立的开关和严重级别。实时检测走的是编辑器增量分析,只分析当前文件及其依赖;批量扫描走的是全项目索引,会把所有文件跑一遍。这两条路径的规则集合是同一套,但触发时机和开销完全不同——这也是为什么有人觉得「明明没提示,一扫描一堆问题」。
3.1 插件的工作模型:检查项、严重级别与规则源
插件内部的检查项分三类落地方式:纯语法模式匹配(比如命名正则)、调用链模式匹配(比如检测是否调用了某个工厂方法)、以及少量基于类型推断的判断(比如包装类比较)。这三类的误报率依次升高,所以你会看到同一个项目里,命名类问题几乎全中,而并发类问题常需要人工复核。
严重级别对应关系需要在设置里确认一遍。IDE 的 Inspection 面板里,每个检查项都可以单独设为 Error / Warning / Weak Warning / None。插件安装后会给出一套默认级别,但这套默认值和你团队的强制力清单大概率不一致。我一般会把手册里的强制项对应的检查项全部调到 Error,推荐项保持 Warning,参考项关掉——理由很简单,满屏黄色波浪线等于没有波浪线。
规则源方面,插件自带一份内置规则集,另外支持自定义规则文件。内置规则集会随插件版本更新,这是好事也是坏事:好事是条款覆盖面越来越全,坏事是升级后可能冒出一批新检查项,把老的 CI 流水线打成红的。
3.2 在线安装与离线 zip 安装,两条路径的差异
在线安装最省事。在 IDE 的设置里找到插件管理,搜索「代码规约」这类关键词,找到官方插件后直接安装、重启。重启后右键菜单和顶部菜单里会多出扫描入口。
但企业内网环境经常连不上插件市场,这时候要走离线 zip。流程是:在一台能上网的机器上从插件市场下载对应 IDE 版本的 zip 包,拷到目标机器,打开设置里的插件管理,点右上角的设置图标,选「从磁盘安装插件」,选中 zip,重启。
版本匹配是这里唯一需要小心的地方。插件对 IDE 主版本有依赖,装到不兼容的版本上,轻则功能缺失,重则整个 IDE 启动时报插件加载失败。稳妥的做法是先确认 IDE 的构建号,再去插件市场页面对照兼容范围。
装完之后可以顺手确认一下插件目录是否真的落地,避免「装完提示成功但没生效」这种玄学。macOS 下的默认插件根目录是~/Library/Application Support/JetBrains/<产品名><版本>/plugins,Linux 和 Windows 换成各自的配置目录即可。
# 列出 IDE 插件根目录,确认规约插件对应的目录已经出现 IDE_PLUGINS="$HOME/Library/Application Support/JetBrains" ls -1 "$IDE_PLUGINS" # 进入具体产品版本目录后看 plugins 下新增的条目 ls -lt "$IDE_PLUGINS"/*/plugins | head -n 20这段命令的逻辑很简单:先确认 IDE 的配置根目录存在,再按修改时间倒序列出插件目录内容,最新落地的那一项基本就是刚装的插件。参数上唯一要改的是IDE_PLUGINS这个变量,Windows 下换成%APPDATA%\JetBrains,Linux 下是~/.local/share/JetBrains或~/.config/JetBrains,具体取决于发行版和安装方式。
注意:如果
ls结果里出现的是.disabled后缀的目录,说明插件被 IDE 主动禁用了,通常是版本不兼容导致的,这时候不要反复重装,先去看 IDE 的启动日志。
3.3 一次全量扫描:面板、分级过滤、逐条定位
扫描入口一般在编辑器的右键菜单里,名字类似「编码规约扫描」。选中项目根目录或某个包,触发扫描,结果会在底部弹出一个专用面板。
面板里最重要的三样东西:左侧的问题列表、中间的分级筛选、右侧的代码定位。分级筛选默认展示阻断、严重、主要三级,我建议第一次扫描时只看阻断级,把严重和主要先折叠起来。原因很现实:一个中等规模的存量项目,全量扫描出来的问题数量很容易到四位数,一次看全部只会让你想关掉插件。
逐条定位时有个提效技巧——不要按顺序一条条修。先在面板里按规则名排序,把所有「同一条规则命中」的问题聚在一起批量处理。比如命名类问题集中在一个包,一次性改完再重新扫描,比一条条点过去快得多。
扫描结果支持导出,导出的报告可以直接作为整改清单的初稿。导出格式通常是文本或 XML,前者给人看,后者给工具吃。我一般在第一次接入时导一份 XML 存档,作为「基线报告」,后面每次整改后重扫,对比数量变化,比凭感觉判断「好像少了点」靠谱。
3.4 实时检测的开关与误报抑制
实时检测默认是开着的,但它的体验取决于你怎么配置。全开的状态下,编辑器里到处是波浪线,尤其是接手存量项目的前两周,会严重干扰阅读。
我的做法是分阶段:第一阶段关掉实时检测,只用批量扫描建立基线;第二阶段按包逐步开启,从新增代码所在的包开始;第三阶段全量开启,同时把确认过的误报规则关掉。
误报抑制有两条路。一条是在 IDE 层面关掉具体检查项,适合「这条规则我们团队整体不采用」的情况。另一条是在代码里加抑制注释,适合「这一行确实需要例外」的情况,常见写法是在该行上方加注解或注释标记,具体语法看插件文档。第二条路要克制,一个文件里出现三处以上的抑制注释,说明要么规则不适合这类代码,要么这段代码该重构了。
4. 高频条款的代码级改造:六段能直接抄的改法
文档看完了,插件也装上了,接下来是真正花时间的部分:把扫出来的问题改掉。这一章挑六组在存量项目里命中率最高、改起来最容易翻车的条款,每组给反面写法、正面写法和参数说明。代码都是 Java,JDK 8 及以上通用。
4.1 集合三连坑:asList、subList、foreach 里删元素
这三个坑的共同点是:编译不报错,单元测试大概率也不报错,线上特定数据量下才炸。
第一个坑,Arrays.asList返回的是固定长度的列表视图,底层是数组,不支持结构性修改。
// 反面:asList 返回固定长度列表,add/remove 直接抛异常 List<String> fixed = Arrays.asList("a", "b"); fixed.add("c"); // UnsupportedOperationException // 正面:需要可变列表就包一层,或者直接用构造器 List<String> mutable = new ArrayList<>(Arrays.asList("a", "b")); mutable.add("c");逻辑说明:Arrays.asList返回的是java.util.Arrays$ArrayList,一个内部类,它实现了set但不实现add和remove。参数说明:new ArrayList<>(Collection)这个构造器会做一次数组拷贝,容量按传入集合大小的 1.0 倍分配,如果后续还要追加大量元素,建议用带初始容量的构造器new ArrayList<>(n)手动指定。
第二个坑,subList返回的是原列表的视图,不是拷贝。原列表发生结构性修改后,视图上任何操作都会抛ConcurrentModificationException。
List<Integer> origin = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); List<Integer> view = origin.subList(0, 2); origin.add(6); // 结构性修改 view.get(0); // ConcurrentModificationException // 需要独立对象就显式复制 List<Integer> copy = new ArrayList<>(origin.subList(0, 2));参数说明:subList(fromIndex, toIndex)是左闭右开,toIndex可以等于 size。判断是否需要拷贝的标准很简单——这个子列表的生命周期是否超出当前方法,超出就拷贝。
第三个坑,for-each里删元素。增强 for 循环底层是迭代器,直接调集合的remove会让迭代器失效。
// 反面 for (String s : list) { if ("x".equals(s)) { list.remove(s); // ConcurrentModificationException } } // 正面一:显式迭代器 Iterator<String> it = list.iterator(); while (it.hasNext()) { if ("x".equals(it.next())) { it.remove(); } } // 正面二:JDK 8 的 removeIf,语义最清晰 list.removeIf("x"::equals);逻辑说明:Iterator.remove会同步更新modCount和迭代器的expectedModCount,所以不会抛异常。JDK 8 之后优先用removeIf,代码短且意图明确。注意removeIf内部也是迭代器实现,不要在 lambda 里再操作原集合。
4.2 并发三件事:线程池、ThreadLocal、时间格式化
线程池那条是手册里最容易被无视的强制项。工具类的几个工厂方法看起来方便,实际上把队列类型和容量都写死了,其中两个用的是无界队列,任务堆积时直接吃满堆内存。
// 反面:无界队列 + 默认拒绝策略 ExecutorService bad = Executors.newFixedThreadPool(8); // 正面:显式指定七个参数 ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(256), // 有界队列 r -> { Thread t = new Thread(r); t.setName("order-worker-" + t.getId()); // 线程命名,排障时救命 return t; }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:回退到调用线程 );参数说明:核心线程数和最大线程数的取值取决于任务是 CPU 密集还是 IO 密集,CPU 密集一般取核数加一,IO 密集可以放大到核数的数倍,但一定要配有界队列。拒绝策略里CallerRunsPolicy会让提交任务的线程自己执行,起到天然的背压作用,比直接丢弃安全,代价是会阻塞调用方——在 Web 请求线程里用要评估超时。
ThreadLocal那条防的是内存泄漏。线程池里的线程会被复用,如果不清理,ThreadLocal里挂的对象会一直跟着线程活到线程销毁。
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>(); public void handle() { try { CTX.set(loadContext()); // 业务逻辑 } finally { CTX.remove(); // 必须放在 finally,异常路径也要清 } }逻辑说明:ThreadLocalMap的 key 是弱引用,value 是强引用,key 被回收后 value 还在,这就是泄漏的来源。参数上没有可调的,唯一的要求是remove必须在finally里。
时间格式化那条,SimpleDateFormat是可变对象,多线程共享会解析出错乱结果甚至抛数字格式异常。
// 反面:静态共享 private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 正面:DateTimeFormatter 不可变且线程安全,可以直接做静态常量 private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String text = LocalDateTime.now().format(DTF);参数说明:DateTimeFormatter的所有实例都是不可变的,ofPattern里的模式串和SimpleDateFormat基本一致,但年份用yyyy(纪元年)而不是YYYY(周历年),后者在跨年周会出现日期偏移,这是另一个高频事故点。
4.3 异常与日志:别吞、别拼串、别在 finally 里 return
异常这块最常见的违规是空 catch 块。手册的态度很明确:捕获了就要处理,处理不了就往外抛。
// 反面:吞掉异常,出问题时日志里什么都没有 try { reloadConfig(); } catch (Exception e) { } // 正面:按可处理的异常类型捕获,记录上下文,保留原始异常 try { reloadConfig(); } catch (IOException e) { log.error("配置重载失败, path={}", configPath, e); throw new IllegalStateException("配置重载失败: " + configPath, e); }逻辑说明:log.error的最后一个参数传异常对象,日志框架会打印完整堆栈;如果把异常拼进消息字符串里,堆栈就丢了,排障时只能看到一个 message。参数说明:占位符{}的数量必须和参数个数对齐,多传的参数会被忽略,少传会导致占位符原样输出。
finally里return那条,危害是它会覆盖try块里的返回值和异常。
// 反面:finally 里的 return 会吞掉 try 中抛出的异常 public int bad() { try { throw new RuntimeException("boom"); } finally { return 1; // 异常被丢弃,方法返回 1 } }逻辑说明:JVM 规范里finally块的执行会覆盖try块的完成状态。正确写法是finally只做资源释放,返回值统一在try里给出。
4.4 包装类与 BigDecimal:==和equals的分水岭
包装类的==比较是个经典陷阱,因为缓存机制让它时对时错。
Integer a = 127, b = 127; System.out.println(a == b); // true,命中缓存 Integer c = 128, d = 128; System.out.println(c == d); // false,超出缓存范围 System.out.println(Objects.equals(c, d)); // true,永远用这个参数说明:Integer缓存范围是 -128 到 127,这个范围可以用 JVM 参数调整上界,但不要依赖它。所有包装类比较一律用equals或Objects.equals,后者对 null 安全。
BigDecimal有两个坑,一个是构造,一个是比较。
System.out.println(new BigDecimal(0.1)); // 0.1000000000000000055511151231257827... System.out.println(BigDecimal.valueOf(0.1)); // 0.1 System.out.println(new BigDecimal("0.1")); // 0.1 BigDecimal x = new BigDecimal("1.0"); BigDecimal y = new BigDecimal("1.00"); System.out.println(x.equals(y)); // false,scale 不同 System.out.println(x.compareTo(y)); // 0,数值相等逻辑说明:new BigDecimal(double)会把 double 的二进制误差原样带进来,必须用字符串构造器或valueOf。equals同时比较数值和标度,金额比较统一用compareTo。
4.5 POJO 与命名:布尔字段的 is 前缀陷阱
手册里明确建议布尔类型的字段不要加is前缀。原因不在语法,在序列化框架。部分框架在生成 getter 时会对isXxx做特殊处理,字段叫isDeleted,生成的方法可能是getIsDeleted也可能是isDeleted,反序列化时对不上就变成 false。
// 反面 public class Order { private Boolean isDeleted; } // 正面 public class Order { private Boolean deleted; public Boolean getDeleted() { return deleted; } public void setDeleted(Boolean deleted) { this.deleted = deleted; } }命名这块还有两条容易忽视的:常量必须全大写下划线分隔,且不允许魔法值直接出现在代码里;类名用大驼峰,方法名、参数名、成员变量用小驼峰,包名全小写单数。这些条款机器能扫,但改起来量大,建议配合 IDE 的重构功能批量处理,别手改。
5. 避坑排查:五条踩过的血泪记录
这一章全部来自实际落地过程,每条按「现象 → 原因 → 解决」写。看完能省掉至少两周的来回折腾。
5.1 插件装上了,扫描入口却是灰的
现象:插件管理里显示已安装且已启用,重启后右键菜单里的扫描项是禁用状态,点了没反应。
原因:最常见的是插件版本和 IDE 构建号不匹配。插件加载时做了版本校验,不匹配时不会抛异常,只是把功能入口置灰,日志里只有一行 WARN。第二种可能是当前打开的文件不属于 Java 模块,扫描器只对 Java 源文件生效。
解决:先去 IDE 的帮助菜单里看构建号,对照插件市场的兼容列表确认版本;不行就卸载重装离线 zip。确认文件类型这块,看项目结构里该目录是否被标记为源码根目录,如果是从别的构建工具导入的项目,源码根经常丢。
5.2 老项目一开全量扫描,两千条阻断级
现象:第一次全量扫描,阻断级问题一千多条,团队直接放弃整改。
原因:存量项目多年没有规范约束,加上早期 JDK 版本低,很多条款压根没法遵守。一次性全量整改的工程量远超预期,且改动面大,回归测试成本高。
解决:建立基线,只对增量代码设门禁。具体做法是第一次扫描后导出报告存档,把当前问题数记为基线;CI 里只扫描本次变更的文件,阻断级为零才允许合并。存量部分按模块分批清理,每清理一个模块就把基线降一次,用趋势图而不是绝对值来考核。
5.3 格式化模板和规约打架
现象:IDE 保存时自动格式化,把代码格式化成 A 风格,规约扫描说应该是 B 风格,来回报错。
原因:IDE 的代码风格配置和手册的格式章节不是同一套来源,缩进、换行、空格规则存在冲突。最常见的是缩进——手册要求 4 个空格,团队沿用的模板是 2 个空格或者 Tab。
解决:以手册为准,导出一份统一的代码风格 XML,放进代码仓库,团队成员导入同一份。格式化动作放在保存时自动触发,规约检查放在提交前,两者顺序不能反,否则会出现「刚格式化完又被扫描改回去」的死循环。这块没什么技巧,就是统一配置源。
5.4 同一份规则集,本地和 CI 结果对不上
现象:本地扫描零问题,CI 上同一份代码扫出一堆,或者反过来。
原因:三种可能。一是插件内置规则集版本不同,本地是旧版插件,CI 用的是新版;二是 CI 里跑的是独立的静态分析工具(比如 PMD),规则文件路径指向了默认规则集而不是规约规则集;三是编码问题,CI 容器默认字符集不是 UTF-8,中文注释被当成乱码后触发了注释类规则。
解决:把规则集文件从插件里抽出来,作为独立文件提交到仓库,本地和 CI 都引用同一个路径,版本随代码一起管理。字符集在构建脚本里显式指定,不要依赖运行环境的默认值。改完在 CI 日志里打印一次规则集文件的校验和,确保两边加载的是同一份。
5.5 升级 IDE 之后规则集体失效
现象:IDE 小版本升级后,原有的检查项全部变回默认级别,之前手动调过的严重级别没了。
原因:IDE 升级时会迁移配置,但插件的检查项配置存在版本隔离的目录下,跨大版本迁移经常丢。另一个诱因是插件本身也升级了,新版本重命名了部分检查项的 ID,旧配置对不上新 ID。
解决:把检查项的严重级别配置导出成文件,纳入版本管理,升级后重新导入。更彻底的做法是不依赖 IDE 配置,把阻断逻辑全部下沉到 CI 的规则集文件里,IDE 只做提示,CI 做判定。这样 IDE 怎么升级都不影响门禁。
6. 接进提测闸门:增量扫描脚本与三级验收口径
前面五章讲的是「知道」和「会改」,最后落到「怎么让它自动跑」。全量扫描在存量项目里没有可执行性,真正能长期跑下去的是增量扫描——只扫这次改动碰到的文件。
下面这个脚本用 Git 的差异文件列表驱动,把变更的 Java 文件复制到临时目录,再交给静态分析工具跑规约规则集。复制这一步不能省,多数分析工具的目录参数只接受目录,不认文件列表。
#!/usr/bin/env bash # 增量规约扫描:只扫本次改动新增或修改的 Java 文件 set -euo pipefail BASE_REF="${1:-origin/main}" # 对比基线,CI 里一般传目标分支 RULESET="${RULESET:-$HOME/rulesets/java}" # 规约规则集目录 OUT_DIR="target/pmd-report" mkdir -p "$OUT_DIR" # 1. 取出变更文件列表,只保留新增和修改的 Java 文件 git diff --name-only --diff-filter=ACM "$BASE_REF"...HEAD -- '*.java' > /tmp/changed.txt if [ ! -s /tmp/changed.txt ]; then echo "no java changes, skip" exit 0 fi # 2. 复制到临时目录,保持原有相对路径,避免包名与路径不匹配导致误报 TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT while IFS= read -r f; do mkdir -p "$TMP_DIR/$(dirname "$f")" cp "$f" "$TMP_DIR/$f" done < /tmp/changed.txt # 3. 跑分析,输出文本报告;非零退出码不代表脚本失败,报告交给下一步判定 pmd -R "$RULESET" -d "$TMP_DIR" -f text -r "$OUT_DIR/incremental.txt" || true # 4. 按级别统计数量,阻断级大于零就返回非零 BLOCKER=$(grep -c '^.*Blockers' "$OUT_DIR/incremental.txt" 2>/dev/null || echo 0) echo "blocker=$BLOCKER" [ "$BLOCKER" -eq 0 ]参数说明几个关键点:BASE_REF在 CI 里一般传目标分支名,本地调试可以传HEAD~1;RULESET必须指向从插件里抽出来的那份规则集目录,不要用工具默认的,默认规则集里没有规约条款;--diff-filter=ACM只取新增、复制、修改三类,删除的文件不参与扫描。第 4 步的grep模式依赖具体工具的输出格式,PMD 不同大版本的报告头不一样,接之前先跑一次看实际输出,别直接抄。PMD 7 之后命令行入口从pmd改成pmd check,规则集参数也变了,老脚本升级时会直接报参数错误。
门禁口径我一般分三级,写进 CI 配置里,不靠口头约定:
| 门禁级别 | 触发条件 | 处理方式 |
|---|---|---|
| 阻断 | 增量扫描阻断级问题数大于 0 | 合并请求打回,必须修完重跑 |
| 警告 | 严重级问题数超过该模块基线 | 允许合并,登记到整改清单,两周内清理 |
| 提示 | 主要级问题数环比上升 | 日报里统计趋势,不进合并流程 |
这套口径跑起来之后,最容易出问题的是「阻断级定义」——插件默认的阻断级里有一些条款在特定业务下确实无法遵守,硬卡会导致大家绕过流程。处理办法是把这些条款从规则集里显式移除,在仓库里留一条注释说明为什么移除,而不是让大家在代码里加抑制注释。
从那以后我每次接入新项目,都先跑一遍全量扫描存档做基线,再谈增量门禁,绝不反过来。跳过基线直接上卡点,团队第一周就会把扫描流程绕过去,后面再想推就难了。希望帮到你。
本文还有配套的精品资源,点击获取