2024 年 3 月 14 日下午四点,运营在群里甩了张对账截图:那批新上的复诊券,同一批用户里有 317 个人领到了两张。活动配置明明写着「每人限领一张」,代码里也确实有判断。
我把那行翻出来的时候,盯着看了大概十秒才反应过来:
java
// CouponChecker.java · 模块 M
if (coupon.getBatchId() == rule.getBatchId()) {
return REJECT_ALREADY_CLAIMED;
}
两个 getBatchId() 返回的都是 Integer。批次号在 127 以内的时候,这行一直是对的------Integer.valueOf 会从缓存里拿同一个对象,== 比引用,恰好相等。那批复诊券的批次号从 128 开始,两个对象在堆上各占一块,判断恒为 false,闸门等于没关。测试环境用的夹具数据是 12、13、14,跑了两周全绿。
修就是加个 equals,两分钟。后面的事花了两天:把 317 笔重复申领逐笔对账、给客服写话术、把已核销的补发改成等额券、复盘会上解释为什么一个「看起来像小疏忽」的写法能漏过自测、CR 和 CI 三层。
《阿里巴巴 Java 开发手册》OOP 规约里那条我背得出来:「所有整型包装类对象之间值的比较,全部使用 equals 方法比较」,说明里连 -128 至 127、IntegerCache.cache、区间外在堆上产生都写清楚了。我们项目 2020 年就把它贴进了 CR 模板,结果还是漏了。那天之后我较真了两件事:规范到底在防什么,以及为什么贴了模板还防不住。
后来我又在 CR 里明确破过手册的三条规矩,每次都附了 JMH 数据和一份到期日。这两件事不矛盾,但要讲清楚得从 == 这个符号说起。
一个 == 背后,是两套语义
Java 里 == 从来不是一个意思,它看左边是什么类型。
操作数是基本类型时,比的是值;是引用类型时,比的是两个引用是否指向同一个对象。这个分叉在语言规范里写得清清楚楚,但写代码的人脑子里往往只有「相等」一个词。自动装箱把这个分叉藏得更深:源码里写 a == b,编译期就被改写了。
我拿那次事故最小复现做了一次 javap -c,把 .class 摊开看:
java
// IntegerEqDemo.java
public static boolean same(Integer a, Integer b) {
return a == b;
}
public static boolean sameValue(int a, int b) {
return a == b;
}
public static boolean boxedCompare(int a, int b) {
Integer x = a; // 编译器改成 Integer.valueOf(a)
Integer y = b;
return x == y;
}
text
Compiled from "IntegerEqDemo.java"
public static boolean same(java.lang.Integer, java.lang.Integer);
Code:
0: aload_0
1: aload_1
2: if_acmpne 9 // 引用比较,不看数值
5: iconst_1
6: goto 10
9: iconst_0
10: ireturn
public static boolean boxedCompare(int, int);
Code:
0: iload_0
1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: astore_2
5: iload_1
6: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: astore_3
10: aload_2
11: aload_3
12: if_acmpne 19 // 还是引用比较
...
valueOf 里藏着那个决定命运的分支:
java
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
IntegerCache.cache 是一段静态初始化的数组,默认覆盖 -128 到 127。落在这个区间里,两次装箱返回的是同一个数组元素,于是 == 碰巧 成立;出了区间,两次 new,== 必然为 false。所以这不是「偶尔出错」,是正确与否取决于数据分布------比稳定出错更难查,因为测试数据和线上数据的分布往往不一样。
更阴的一层是上限可配。HotSpot 上 -XX:AutoBoxCacheMax=1000 能把 high 调大,同一个包在开发机、压测环境、生产容器上可能表现出三种结果。我在复盘里专门留了一行:这个 bug 在任何单一环境里都不一定复现得了,它是「环境 × 数据」的交叉产物。
Integer.equals 就老实得多:
java
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
先 instanceof 挡掉类型不对的,再拆箱比 int。跨类型时 Integer(1).equals(Long(1L)) 是 false,== 在包装类型之间则根本编译不过(除非两边能拆箱成同类型)------这也是为什么手册那条要连着「浮点数不能用 ==」「包装类型不能用 equals 比浮点」一起读。JDK 7 之后的 Objects.equals(a, b) 又多包了一层空判断,正好对上另一条规约:用常量或确定有值的对象调用 equals,别让 object.equals("test") 在 object 为 null 时炸。
到这里可以回答「规范在防什么」的第一层:它防的不是「写得丑」,是语言里那些合法、能编译、在部分输入下正确、换批数据就错的写法 。这类写法没法靠「更仔细」解决,因为人的直觉里 == 就是相等;只能靠一条机械规则把这个写法从可选项里删掉。
bug 从哪来:把「没想过的状态」画出来
我习惯把 bug 定义得窄一点:程序进入了一个作者没有预期、也没有处理的状态。按这个定义,编码规范干的事就是收窄可表达的状态空间------不是教你怎么写对,是让一批「写错的可能」压根表达不出来。
摊开看,绝大多数由编码方式直接导致的缺陷落在三类里。
第一类是语义歧义 :同一个符号或写法对应两种以上含义,作者和编译器想的不是一回事。== 是最典型的,另外还有 equals / hashCode 契约不一致导致 HashSet 里存进「相等」的两个对象、isXxx 命名的 POJO 属性被序列化框架当成另一个字段、long 字面量末尾写成小写 l 被看成 1、三目运算符两边类型不一致触发隐式拆箱 NPE。手册里这些条目几乎都标了【强制】,因为编译器不会替你报错。
第二类是隐式行为 :代码看似只操作了一个东西,实际牵动了另一个。Arrays.asList() 返回的是数组适配器,改原数组 list.get(0) 跟着变;subList 是原集合的视图,强转成 ArrayList 直接 ClassCastException,对原集合做结构性修改再读子列表会抛 ConcurrentModificationException;SimpleDateFormat 定义为 static 之后多线程共享一个 Calendar 内部状态,日期串偶尔串到别的年份;ThreadLocal 在线程池里不 remove(),下一个任务读到上一个用户的信息。这些都不是「不会用 API」,是 API 的默认行为超出了写字那行时的心理模型。
第三类是时序与顺序依赖 :单看每段都对,放一起就错。foreach 里 remove 触发 modCount 与迭代器期望值不一致;finally 里 return 把 try 的返回值盖掉;多个线程对同一资源加锁顺序不一致造成死锁;ContentProvider 的安装早于 Application.onCreate,把初始化写在后者就晚了半拍。
三类加起来,规范真正约束的对象其实是这个:
右下角那块我特意留着。优惠券门槛算错、状态机漏了一个分支、把「已核销」当成「已过期」,这些代码完全符合任何一条编码规范,静态扫描一个字都不会报。规范能消灭的是已知类别的写法缺陷 ,不是全部 bug。把这两件事混为一谈,团队就会在「插件零告警」的喜悦里漏掉真正致命的业务错误------我们那次重复申领,第一层是 == 的写法缺陷,第二层其实是「限领判断没做服务端幂等」,后面那一层规范一点忙都帮不上。
一个 128 走完全程
前几篇排查性能问题时我常做一张对照表,把同一个对象在不同层面的形态摆在一起。这次也照做,只不过对象是个数字。
| 出现的地方 | 它是什么 | 谁决定它的含义 | 那次事故里的表现 |
|---|---|---|---|
源码 rule.getBatchId() |
一个 Integer 引用的声明 |
写 DTO 的人 | 看着跟 int 没区别 |
| 编译后的字节码 | Integer.valueOf 的调用 + if_acmpne |
javac 的自动装箱规则 | 数值比较悄悄变成引用比较 |
| 运行时堆上 | 缓存数组元素,或者一次 new Integer |
IntegerCache.high 与当前值 |
127 以内共用一个对象,128 起各有一块 |
| 单测夹具 | 12、13、14 三个字面量 |
写测试的人凭手感挑的 | 全落在缓存区间,两周全绿 |
| 灰度数据库 | batch_id = 128 开始的活动配置 |
运营建批次时分配的序号 | 第一次让判断恒为 false |
| 工单 M-2417 | 「317 笔重复申领」 | 对账脚本 | 损失口径从代码问题变成资金问题 |
一行没变的 ==,在六个地方是六种东西。规范那条「全部使用 equals」做的事,是把第二行和第三行的不确定性从链条里摘掉,让后面四行不再依赖数据分布。这也是我后来给团队讲规范时唯一反复强调的一句:别背结论,要能顺着这条链说出它在第几行起作用。
「严格」两个字,比「有规范」值钱得多
我们 2020 年就把手册贴进 CR 模板了,2024 年照样出事。差别在哪儿?贴进模板的是一段给人看的建议,出事那天没有任何一道闸门会因为它而停下来。
能机械判定的规则和只能靠人记的规则,效果差着数量级。举个手册里最便宜的例子:@Override。父类方法签名一改,加了注解的实现类当场编译报错;没加注解的同名方法会静默变成一个全新方法,调用方仍然走父类逻辑,直到某天线上发现功能没生效。P3C 那批人写插件开发复盘时提过一个细节:PMD 原生不支持跨文件扫描,所以「所有覆写方法必须加 @Override」这条没法在 PMD 里实现,最后挪到 IDE 和 Sonar 各自的平台能力里做了。同一条【强制】,落在编译器、IDE、CI 三种位置,拦截强度完全不一样。
第二个差别是拦截位置。我按那次事故的真实工时算过一笔账:
text
写的时候改成 Objects.equals ≈ 2 分钟
CR 里被同事圈出来 ≈ 10 分钟 + 一次往返
CI 增量扫描拦住并阻止合并 ≈ 20 分钟 + 一次构建
灰度 2.3% 用户后发现 ≈ 2 小时 20 分定位
+ 317 笔对账 + 客服话术 + 补发方案
+ 两天复盘
这条曲线不用画图也知道是什么形状。规范的全部经济价值,就是把修复动作从右边往左挪。而「严格」的意思不是态度严肃,是让规则在尽可能靠左的位置变成不可绕过的动作:编辑器里红色波浪线、提交前钩子、CI 里的 exit code,比 CR 模板里那句「请注意包装类型比较」有用得多。
第三个差别最容易被忽略:统一的写法会把 CR 里的注意力省下来。同一段 diff,如果里面混着三种命名、两种缩进、一处魔法值,评审的人得先把这些噪音滤掉才能看业务逻辑;diff 越大、噪音越多,真正危险的那行越容易滑过去。我在组里做过一次不太严谨但挺说明问题的记录:把格式与命名类问题全部交给 IDE 自动格式化之后,连着 30 次 CR 里,评审意见中指向业务语义的比例从大约四成升到七成多,评审时长中位数反而降了。样本小,我不拿它当结论,只当「注意力是有限资源」的一个佐证。
不过这里得把话说回去。学界对「通用规则和缺陷到底有没有关系」这件事,结论比很多人以为的保守。Boogerd 等人在两个工业案例里逐条检验 MISRA C 规则与缺陷位置的关系,一个案例 88 条规则里只有 10 条是显著预测因子,另一个 72 条里只有 12 条,两个案例之间还只重叠了 1 条;Wagner 和 Couto 那边分析 FindBugs / PMD 的规则与真实缺陷修复,没能确认通用规则具备缺陷预测能力。反过来,Renggli 等人研究「由熟悉系统的人针对本系统写的规则」,在长期演化的案例里显著好过通用规则,16 位有经验的开发者里多数认为这类规则更能找出关键问题。
这几组结论拼起来,正好是规范真正的初衷,也是它的边界:
- 规范的收益主要来自把某次真实事故凝固成一条可重复的检查,而不是来自「条目多、覆盖广」。
- 通用规则里真正咬得住缺陷的,往往只是少数几条;剩下的价值在于协作一致性,别把它们的功劳算到「减少 bug」头上。
- 最值钱的规则是你自己团队从工单里长出来的那些,别人的手册是起点,不是终点。
M-2417 之后我们加的第一条团队规则不是「包装类型用 equals」------手册早写了,贴了四年也没用。加的是「DTO 中任何 Integer / Long / Boolean 字段参与业务判断时,必须走 Objects.equals 或 Kotlin ==,增量扫描里出现 == 直接阻断合并」。它比手册那条窄得多,只管我们出过事的地方,但它能跑在 CI 里。
规范这东西,最早是拿来保命的
把「编码规范」理解成「统一缩进和命名」是后来的事。它最初出现的地方,代码写错是要出人命的。
1998 年,英国汽车工业软件可靠性协会 MISRA 发布了面向车载软件的 C 语言规范,一百多条规则里包括禁止动态内存分配、限制函数复杂度、要求显式布尔比较这类在通用软件开发里看着近乎苛刻的约束。理由很实在:车载 ECU 内存有限、没有虚拟内存、跑起来不能重启,一个未定义行为在实验室里可能一年不出现,装到十万台车上就是召回。学界后来做「规则到底能不能预测缺陷」的研究,用的就是 MISRA-C 1998 那 127 条规则。
同一时期 Java 这条线走的是另一条路。Sun 早期给出的 Code Conventions、2000 年前后出现的 Checkstyle 主要管命名、格式、import、Javadoc;PMD 往语法树里走一层,查未使用变量、空 catch、资源没关;FindBugs 干脆分析编译后的字节码,找空指针、equals/hashCode 不一致这类逻辑错误。三者分工到今天也没变,只是名字换成了 SpotBugs。2001 年《Effective Java》第一版把另一种写法带火了:不讲「应该怎么写」,一条一个条目,配反例和后果,讲清楚「这么写会在哪儿炸」。PEP 8 也是 2001 年,Google 后来那套 Style Guide 里有句话被引用得很多------一个被多个工程师改过的文件,看起来应该像一个人写的。
这些坐标摆在一起,能看出规范的初衷其实有三条,跟审美都没关系:
第三条是近些年才被重新看见的。代码形状越统一,静态分析、自动重构、批量迁移、IDE 补全乃至让 AI 读代码的准确率越高;反之,一个项目里三种单例写法、五种空判断,工具只能保守地什么都不敢动。我们 2025 年做模块 M 的 Kotlin 迁移时,吃过这个亏:同一份 DTO 在不同包里有四种「判空再取值」的写法,脚本一个都不敢自动改,最后靠人肉过了一百多个文件。
阿里这本手册进场的时间点,正好卡在国内 Java 后端工程化的那几年。我把版本线整理成表,贴在我们的规范仓库首页:
| 时间 | 版本 | 具体变化 |
|---|---|---|
| 2016-12-07 | 1.0.0 公开版 | 首次向业界开放,官方《前世今生》记为第 1 版 |
| 2017-02-27 | 1.1.0 正式版 | 增加前言与专有名词说明,修正部分描述 |
| 2017-05-20 | 1.2.0 升级版 | 对外征集意见后的修订 |
| 2017-09-19 | 1.3.0 终极版 | 新增单元测试规约;同期发布英文版 |
| 2017-10-14 | P3C 插件 | 杭州云栖大会首发并开源,官方写「历经 247 天研发」 |
| 2018-06-06 | 1.4.0 详尽版 | 新增 16 条设计规约;配套《码出高效》出版 |
| 2019-06-13 | 1.5.0 华山版 | 新增 21 条设计规约,修改描述 112 处;去掉「阿里巴巴」限定词 |
| 2020-04-22 | 1.6.0 泰山版 | 新增日期时间、SQL 表别名、统一错误码等规约 |
| 2020-08-03 | 1.7.0 嵩山版 | 首次新增前后端规约,修正 BigDecimal 比较、HashMap 扩容等描述 |
| 2022-02-03 | 1.7.1 黄山版 | 新增 11 条,含浮点后缀大写、枚举字段不可变、配置密码加密等 |
这张表我返工过两回。第一回是年份:网上流传的版本表把「首次公开」记成 2017-02-09、1.3.0 记成 2017-09-25、1.4.0 记成 2018-05-20、1.5.0 记成 2019-06-19,跟官方《前世今生》那篇列的 2016-12-07 / 2017-09-19 / 2018-06-06 / 2019-06-13 对不上。插件首发那篇官方文章又写了「今年 2 月 9 日首次公布」。三个来源三个口径,我不敢挑一个当事实,最后按官方版本说明和 PDF 封面版本号定表,把 2017-02-09 那条作为「对外正式公布」的另一种口径留了备注。写技术文章时这种地方最容易被一句话带过去,可一旦有人按这张表去对版本,错一次就不信后面所有数字了。
第二回是「最新版」。搜的时候跳出来几篇 2025 年的博客,说华山版新增了 Serverless 与响应式编程规范、黄山版新增了 AIGC 编程规范。我差点抄进表里,翻到 P3C 仓库 README 发现它停在「下一个版本:黄山版」,官方专题页也停在嵩山版发布那次。那几篇看着像是拿旧版本名让模型补出来的内容,条目、年份、章节全都对不上。黄山版之后我没找到官方新版本的发布记录,表里就写到它为止,没往下编。
顺带记个不太要紧但挺有意思的细节:版本号后来改成五岳命名,是作者自己说的------华山最有武侠渊源,之后去泰山、嵩山;嵩山之后本想叫恒山,结果发现和「衡山」读音完全一样,表达上是大忌,干脆公开征集,最后选了「黄山」,取「五岳归来不看山,黄山归来不看岳」。一本规范文档能把命名这事较真到读音,跟它里面那些条目其实是同一种性格。
手册好在哪,得拆成三层看
先说结构。手册以 Java 开发者为中心,划成编程规约、异常日志、单元测试、安全规约、MySQL 数据库、工程结构、设计规约七个维度;按约束力强弱和故障敏感性,每条标【强制】【推荐】【参考】;条目下面再挂「说明」「正例」「反例」。三层叠起来,它就不只是一份可以查的清单了。
七个维度里最容易被低估的是后四个。一线开发写业务代码时最容易漏的从来不是命名,是「这张表没加索引」「这个接口没做鉴权」「这个异常被吞了」「这段日志把用户手机号打出去了」。手册前言里专门解释过为什么要收这些看似跟编码无关的章节:不是要人变成安全专家、运维专家,而是明确一位 Java 开发应该具备的基本技术素养。我们项目 2023 年那次合规清点,把日志脱敏、错误码统一、异常必须带现场信息与堆栈这三条直接从手册搬进团队清单,省了自己从零想的功夫。设计规约是 1.4.0 才加的,1.5.0 又补了 21 条,它把「这个需求要不要写设计文档」这种以往靠拍脑袋的事给出了可度量的判断依据,这是别家风格指南里少见的。
三级约束本身是个很克制的设计。真正「违反就可能出事」的才标强制,其余留给场景判断。可惜多数团队拿到手只看见第一级。
说明 / 正例 / 反例才是我认为它最值钱的部分。手册里的反例不是抽象原则,几乎全是事故报告的压缩版:
java
// 常量定义反例:开发者 A 定义缓存 key,开发者 B 用时少了下划线
String key = "Id#taobao_" + tradeId;
cache.put(key, value);
// 魔法值之外的另一条:两个类各自定义「是」
// 类 A:public static final String YES = "yes";
// 类 B:public static final String YES = "y";
// A.YES.equals(B.YES) 预期 true,实际 false
// POJO 不要设属性默认值
// private Date gmtCreate = new Date();
// 数据提取时没置入值,更新其它字段时顺带把它刷成当前时间
还有那个我每次带新人都拿出来讲的 getObject() 与 get0bject()------一个是字母 O,一个是数字 0,少了 @Override 编译器不会提醒你覆写其实没成功。以及 POJO 属性用包装类型那条的反例:交易报表显示涨跌 x%,x 是基本类型,RPC 调用失败返回默认值,页面就显示 0%,而正确表达应该是「没有数据」,画一条横线。这条我读第一遍时没什么感觉,直到自己在一个健康类 App 的报告页上把「本次未测」显示成 0,被医生用户投诉了才明白------null 在这里不是懒得赋值,是一种业务状态。
这类反例让新人不用亲自把坑踩一遍。一个人靠自己攒经验,一年能遇到几次?一本手册把几百人踩过的坑按类别排好,这是它最大的优势,跟「统一代码风格」根本不是一个量级的价值。
手册前言里那个交通法规的类比我挺喜欢:「制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全」,接着说规范和标准是限制过度个性化,以统一方式一起做事,提升协作效率、降低沟通成本。它没把规范包装成「写出优雅代码」,而是直说这是协作工具。这个定位很诚实,也解释了我后面为什么会破它的规矩------限制个性化是为了协作,不是为了把性能也一起限制住。
第三个优势是有配套的机器实现。2017-10-14 云栖大会首发的 P3C 项目分三块:PMD 规则实现、IntelliJ IDEA 插件、Eclipse 插件。扫描结果按 Blocker / Critical / Major 三级显示,IDEA 里基于 Inspection 机制还能实时提示。项目组那篇开发复盘写得很实在:一期定了并发、注释、命名、OOP、集合等方面的 41 条规则,选 PMD 是因为 Checkstyle 主要管风格、FindBugs 得先编译工程,而 PMD 已有不少通用规则可以改改就用;实现时始终守着一条原则------「对于错误,宁可少报也不误报」,因为误报会让开发同学产生阴影,从此不再信任工具。仓库 README 后来给的数字是基于 PMD 实现 49 条、另外 4 条放在 IDE 插件里实现。嵩山版前言提到配套插件下载量达到 162 万人次,云效也集成了扫描引擎。
文档能落成插件,这点比多写五十条规约更重要。我们团队真正开始少出同类 bug,不是从「把手册贴进 CR 模板」那天算起,是从「增量扫描阻断合并」那天算起。
把优势落到条目上,大概是这么张表:
| 手册条目 | 防住哪类状态 | 能机器查吗 |
|---|---|---|
包装类型值比较用 equals |
引用语义被当成值语义,正确与否依赖数据分布 | 能,AST 直接识别 |
所有覆写方法加 @Override |
拼写或签名漂移导致覆写静默失效 | 编译器能,PMD 跨不了文件 |
| 不允许魔法值直接出现在代码中 | 同一常量多处漂移、少个字符 | 能,但容易误报 |
| 不在 foreach 里 remove / add | modCount 与迭代器期望值不一致 |
能 |
Arrays.asList 不调用修改方法 |
适配器视图被当成独立集合 | 能,需要类型推断 |
线程池不用 Executors 创建 |
无界队列 / 无限线程导致资源耗尽 | 能 |
必须回收自定义 ThreadLocal |
线程池复用造成跨请求数据串味 | 难,需要过程间分析 |
| 异常信息含现场信息与堆栈 | 线上只剩一句「操作失败」无法定位 | 难,靠 CR |
| 用户敏感数据脱敏后展示或输出 | 合规风险 | 难,靠团队自己补规则 |
| 表查询必须带索引条件 | 慢 SQL 拖垮服务 | 靠 SQL 审查与执行计划 |
右列那几个「难」和「靠 CR」,就是后面那几面墙的入口。
我在实际项目里撞上的那几面墙
优势说得够多了,下面这几条才是我这篇文章真正想写的。手册是 2016 年从阿里内部 Java 后端工程里长出来的,把它整本搬到 2026 年的 Android / Kotlin 项目上,会撞墙。
第一面:语言前提变了,条目还在
手册 OOP 规约里那条「POJO 类属性必须使用包装数据类型」,说明写得很清楚:数据库查询结果可能是 null,用基本类型接收有 NPE 风险;null 还能表达「远程调用失败」「异常退出」这类额外信息。这个理由在 Java 后端完全成立。
到了 Kotlin,Int 和 Int? 已经在类型系统里把这件事说死了:
kotlin
// 我们项目现在的写法,编译期就区分「有没有值」
data class ExamReport(
val systolic: Int?, // 未测量,不是 0
val measuredAt: Instant?, // 未同步,不是 epoch
val batchId: Int // 必填,不可能是 null
)
一个字段能不能为 null,不用靠「统一用包装类型」这种约定来提醒,编译器会盯着。手册那条想解决的问题被语言本身接管了,剩下的只有它的副作用:装箱。在 Kotlin 里写 Array<Int> 每个元素都是对象,写 IntArray 才是连续的基本类型数组。报告页有一屏要画 720 个采样点,最早从 Java DTO 迁过来时用了 List<Int>,列表滑动时分配率明显不对,改成 IntArray 之后那一屏的 GC 次数才回到正常。手册里「局部变量推荐使用基本数据类型」那条【推荐】其实留了口子,可惜团队执行时通常只记住了前两句【强制】。
另一条是「用常量或确定有值的对象调用 equals」,正例 "test".equals(object)。Kotlin 里 == 编译成 equals 且空安全,=== 才是引用比较:
kotlin
val a: String? = null
println(a == "test") // false,不炸
println(a === "test") // 引用比较,明确写着「我在比地址」
语义歧义被语言收走了,那种「把常量放左边」的技巧就没了存在意义。我把手册里这类条目列成一张「前提已消失」清单,在新人 onboarding 时单独讲一遍,免得有人照着写一手别扭的 Kotlin。类似的还有 SimpleDateFormat 那条------手册正例给 ThreadLocal<DateFormat>,说明里也提了 JDK 8 可以用 Instant / LocalDateTime / DateTimeFormatter 替代,但我们 minSdk 24 的 Android 项目要用 java.time 得开 core library desugaring,那是另一个工程决策,手册不会替你做。
还有「线程资源必须通过线程池提供,不允许在应用中自行显式创建线程」。它在服务端是对的,在移动端得翻译成「别在主线程做 I/O、别在列表滚动时起一堆短命线程」,具体落到协程调度器和主线程模型上。条目背后的意图可以照搬,字面照搬就会跟平台打架。
第二面:粒度不齐,一刀切就出形式主义
手册自己分了三级,可到了团队里往往只剩一级。我见过最常见的做法是把能找到的规则全塞进 CI 当阻断项,第一次全量扫描出来三千多条告警,然后大家花两周「清告警」。清法通常是这样:
java
// 为了消掉「不允许魔法值」,把所有数字抽进一个大常量类
public final class Consts {
public static final int ZERO = 0;
public static final int ONE = 1;
public static final int TWO = 2;
public static final int SEVEN = 7;
public static final int MAX_RETRY = 3;
// ...... 后面还有两千行
}
问题在于手册同一节的第三条【推荐】白纸黑字写着「不要使用一个常量类维护所有常量,要按常量功能进行归类」,说明里还说大而全的常量类非得用查找功能才能定位,不利于理解与维护。一条【强制】和一条【推荐】被一刀切之后,执行出来的结果恰好是手册自己点名的反例。
我这边踩过更细的一次:为了过「不允许魔法值」,同事把每个 0、1 都抽成了常量,结果 for (int i = Consts.ZERO; i < list.size(); i++) 这种代码遍地开花,可读性比原来差,CR 里还得专门讨论 ZERO 和 ONE 该放哪个类。后来我们把规则改成:只拦出现在业务判断、缓存 key、状态码、时间阈值里的字面量;循环下标、数组索引、纯数学常量豁免,写进扫描白名单。告警数从三千多降到两百出头,剩下的才是真正值得看的。
第三面:能机械查的,只是目录里的一小部分
P3C 仓库给的是 49 条 PMD 规则加 4 条 IDE 规则,一期定的还是 41 条。手册正文的条目远不止这些。更要命的是实现边界:项目组自己写了,PMD 原生不支持跨文件扫描,所以 @Override 这类规则得挪到 IDE 和 Sonar 各自的平台能力上做。也就是说,「手册里写着」和「机器能拦住」之间,隔着一大片空地。
团队一旦把「插件零告警」当成质量指标,这片空地就会长出绕过写法:
java
@SuppressWarnings("PMD.AvoidDuplicateLiterals")
public class CouponRule {
private static final String KEY_PREFIX = "Id#taobao_"; // 挪到另一个类,扫描器就看不见了
// ...
}
我审过一段代码,为了消掉「魔法值」告警把字符串搬进一个 StringPool 类,两处使用各引用一次,语义一点没变,告警清零,评审通过。这类改动不产生 bug,也不减少 bug,纯粹是把 KPI 伺候好了。后来我们把度量从「告警数」换成「同类事故复发数」,这种改法立刻没了动力。
第四面:版本停了,语言还在往前走
黄山版是 2022 年初,之后我没找到官方新版的发布记录。中间 JDK 从 8 走到 21,record、sealed class、虚拟线程、模式匹配陆续落地;客户端这边 Kotlin 成了主流,Compose 的声明式 UI 跟手册里那套「视图模板不加复杂逻辑」的语境完全不同;构建工具链也换了几轮。手册里有些反例在新 JDK 上行为已经变了,有些条目讨论的框架今天基本没人用了。
这不是手册的错,是任何静态文档的宿命。可如果团队把 PDF 当永久标准,就会出一种很别扭的局面:新人拿着 2022 年的条目,评审 2026 年的 Kotlin 代码,争议还得靠谁资历深来收场。我们的处理是给每条团队规则加 valid_until 字段,半年复审一次,到期没复核就自动降级成【参考】,逼着人重新看一眼它还有没有意义。
第五面:「说明」不一定对,条目自己也在改
手册每条下面的「说明」是解释理由的,可理由写错了,结论就会被人用在错的场景。
泰山版日志规约里那条【强制】我印象很深:「在日志输出时,字符串变量之间的拼接使用占位符的方式」,说明写的是「String 字符串的拼接会使用 StringBuilder 的 append() 方式,有一定的性能损耗。使用占位符仅是替换动作,可以有效提升性能」。有人用 JMH 实测过,结论跟这句说明并不一致------日志级别关掉的时候占位符确实大赢,因为压根不用拼字符串;级别开着、真的要落盘的时候,占位符还得解析 {} 并装箱参数,未必比拼接快。我在自己机器上跑过一轮,两个数在开启级别时差得很近,误差范围内来回复调,跟「有效提升性能」这种笃定说法对不上。
「占位符仅是替换动作」这个描述本身也不准确,日志框架要做参数绑定、格式化、异常堆栈提取,不是字符串替换。结论(用占位符)我完全赞成,但理由得换成「避免无效拼接 + 结构化字段方便检索 + 参数与模板分离便于脱敏」,不是那句性能断言。
手册自己也在纠这类错。嵩山版的更新说明里就写着修正了 BigDecimal 比较、HashMap 扩容次数等描述;集合初始化那条 initialCapacity = (元素个数 / 负载因子) + 1 的公式,社区里专门有人写过文章,说照抄这个公式反而可能让性能变差。后面讲性能那章我会把这几处一条条量一遍。规范不是物理定律,它是某一批人在某个版本上的最佳判断,把它当定理背,迟早要吃亏。
第六面:只教避坑,不给调优的尺子
手册里跟性能有关的条目不少,但绝大多数是「别这么写会出事」:别用 Executors、别用 Apache BeanUtils、别在方法体里 Pattern.compile、集合初始化给个大小、用 entrySet 遍历 Map。这些都对,可它们是避坑清单,不是调优方法。整本手册没有给出一套可复用的测量口径:用什么工具量、预热几轮、看平均值还是 P90、机型和样本量怎么记、收益怎么跟回归一起看。
于是团队里最容易发生这样的对话:「手册说要指定 HashMap 初始容量」「那我全写 16」「为什么?」「手册说不确定就填 16」。前面那句是有条件的判断,被压缩成一句口号之后,性能反而可能变差------JDK 的 HashMap 会把入参向上取到 2 的幂,阈值是容量乘负载因子,公式里那个 + 1 在不同元素个数下有时刚好、有时白白多扩一次。
第七面:移动端特有的风险,得自己补
手册安全规约里有敏感数据脱敏的条目,方向是对的。但移动端还有一整片它没覆盖的地方:崩溃上报里夹带的就诊人姓名、导出的堆快照里的身份证号、debug 日志里的 token、第三方 SDK 在用户同意之前就初始化、权限申请前没说清用途。这些在我们那个健康类 App 里是要出合规事故的,手册不会替你写,只能团队自己往规则清单里补。这块我另外写过,这里不展开。
七面墙摞起来,结论不是「手册不好用」,是它的适用边界得由用的人补上。手册给的是一批经过大规模实战检验的默认解,默认解的价值在于省下从零想的功夫;代价是它不会替你判断「这条在我们项目里还成不成立」。
七个维度搬到移动端,我逐条改了一遍
2020 年刚把手册引进组里那阵,CR 里差不多一半的争论是「这条在我们这儿到底算不算」。有人拿 PDF 逐条对,有人说移动端不适用,谁也说服不了谁,最后往往按资历收场。后来我干脆坐下来把七个维度逐条过了一遍,每条只给四种结论:直接搬 / 改写 / 删掉 / 团队自补 ,并且把依据写在旁边。这张表现在还在 spec/local-adaptation.md 里,每季度跟着规则复审一起更新。
| 维度 | 代表条目 | 处理 | 依据 |
|---|---|---|---|
| 编程规约 · 命名 | 类名 UpperCamelCase、常量全大写、禁用拼音 | 直接搬 | 跨平台无差别,IDE 可格式化 |
| 编程规约 · 常量 | 不允许魔法值 | 改写 | 数值计算里的 0/1/2 豁免(W-011),业务 key 与阈值仍拦 |
| 编程规约 · 常量 | 不要用一个大常量类 | 直接搬 | 手册自己的【推荐】,正好补上上一条的漏洞 |
| 编程规约 · OOP | POJO 属性用包装类型 | 改写 | Kotlin Int? 已表达「未测」;绘制层转 IntArray(W-009) |
| 编程规约 · OOP | 用常量调用 equals | 删掉 | Kotlin == 空安全且走 equals,=== 才是比地址 |
| 编程规约 · OOP | 包装类型值比较用 equals | 直接搬 | M-2417,增量扫描 R-014 |
| 编程规约 · OOP | 覆写方法加 @Override |
直接搬 | Kotlin 用 override 关键字,编译器强制 |
| 编程规约 · 集合 | foreach 里不 remove / add | 直接搬 | 补一条:删得多用 removeIf,不是破例 |
| 编程规约 · 集合 | Arrays.asList 不调用修改方法 |
直接搬 | Kotlin 用 toMutableList() 显式表达意图 |
| 编程规约 · 集合 | subList 结果不可强转 |
直接搬 | 视图语义跨平台一致 |
| 编程规约 · 集合 | 初始化指定容量 | 改写 | 按 capacityFor(n) 重推,公式与 JDK 实现绑定 |
| 编程规约 · 集合 | 用 entrySet 遍历 Map |
改写 | Kotlin 直接 for ((k, v) in map),前提消失 |
| 编程规约 · 并发 | 不用 Executors 创建线程池 |
直接搬 | 补:有界队列 + 背压 + 拒绝计数,见 UploadPools |
| 编程规约 · 并发 | 不允许显式创建线程 | 改写 | 落到「主线程不做 I/O」与协程调度器 |
| 编程规约 · 并发 | SimpleDateFormat 不 static |
改写 | DateTimeFormatter + desugaring;老链路 ThreadLocal 必须 remove |
| 编程规约 · 并发 | 必须回收 ThreadLocal |
直接搬 | 线程池复用会串数据,扫描脚本单独覆盖 |
| 编程规约 · 其他 | 正则要预编译 | 改写 | 编译期常量放伴生对象;运行时动态 pattern 单独缓存 |
| 编程规约 · 其他 | 避免 Apache BeanUtils | 直接搬原则 | 落地改成手写编译期映射,不引它给的替代库 |
| 前后端规约 | 错误码、字段命名、精度 | 部分搬 | 前后端字段与错误码沿用;金额精度按 long 分统一 |
| 异常日志 | 异常不许吞,带现场信息与堆栈 | 直接搬 | 线上排障全靠这两样 |
| 异常日志 | 日志拼接用占位符 | 改写 | 无参固定字面量豁免(W-007);理由换成脱敏与避免无效拼接 |
| 异常日志 | 统一错误码五段式 | 删掉 | 客户端本地错误不跨系统追溯,套五段式徒增负担 |
| 异常日志 | 敏感数据脱敏 | 团队自补 | 崩溃上报、堆快照、debug 日志、SDK 初始化时机,手册没覆盖移动端 |
| 单元测试 | 边界值、错误输入、设计边界 | 团队自补 | 夹具必须含 127 / 128,M-2417 就漏在这里 |
| 安全规约 | 参数化查询、输入校验 | 直接搬 | Room 的 @Query 参数绑定天然防注入 |
| 安全规约 | 权限用途说明 | 团队自补 | 申请定位权限前先弹原生 AlertDialog 说清用途 |
| MySQL 数据库 | 建表必备三字段、索引、禁 SELECT * |
部分搬 | 本地库用 Room 迁移与索引,字段裁剪按端上数据量重定 |
| 工程结构 | 分层与依赖方向 | 直接搬 | 模块 M 的 data / domain / ui 三层 |
| 工程结构 | 二方库、依赖管理 | 改写 | 零第三方依赖优先,引入要走依赖评审 |
| 设计规约 | UML 与设计文档的判断依据 | 改写 | 端上小需求不套服务端文档模板,改成状态机与边界条件表 |
四种结论里,「改写」最多,「删掉」只有三条。这个比例挺说明问题:手册的意图大多仍然成立,需要改的是字面规则与平台之间的那层翻译。真正该删的是那些语言已经替你做完的事,留着只会让人写出别扭的代码。
几条改写我顺手把落地形状记下来,省得下次又争一遍。
状态机用 Kotlin 的密封类型,比「POJO 里放个 int status + 魔法值」清晰得多,也正好把手册里「不要设默认值」「不允许魔法值」两条意图一次性接住:
kotlin
// 报告同步状态:每个状态自带数据,不存在「默认 0」这种模糊表达
sealed interface SyncState {
data object Idle : SyncState
data class Running(val done: Int, val total: Int) : SyncState
data class Done(val at: Instant) : SyncState
data class Failed(val reason: String, val cause: Throwable?) : SyncState
data object Unmeasured : SyncState // 「本次未测」,不是 0
}
kotlin
// 参数化查询:Room 帮我们挡住字符串拼接 SQL
@Dao
interface ReportDao {
@Query("SELECT * FROM report WHERE user_id = :userId AND at >= :from")
suspend fun listSince(userId: String, from: Long): List<ReportEntity>
}
kotlin
// 日志出口统一脱敏,参数化日志恰好给了这一层位置
object SafeLog {
private val SENSITIVE = setOf("idCard", "phone", "patientName", "token")
fun debug(template: String, vararg args: Any?) {
if (!BuildConfig.DEBUG) return
val safe = args.map { if (it is Map<*, *>) mask(it) else it }.toTypedArray()
Log.d("m", template.format(*safe))
}
private fun mask(m: Map<*, *>) =
m.mapValues { (k, v) -> if (k in SENSITIVE) "***" else v }
}
裁剪记录本身也得留证据,不然半年后又变成口头约定:
markdown
<!-- spec/local-adaptation.md -->
## 裁剪口径(每条都要能回答四问)
1. 语言或平台是否已接管这件事?(Kotlin 可空类型接管 null 语义)
2. 平台约束是否不同?(minSdk、主线程、ART 分配、包体积)
3. 代价是否变化?(装箱在 720 点/帧的路径上不再是小钱)
4. 能不能机械检查?(能就写进扫描脚本,不能就只进 CR 模板)
## 本季变更
- 2026-Q3:删掉「用常量调用 equals」「用 entrySet 遍历 Map」两条,前提已消失
- 2026-Q3:新增「日志脱敏」「边界值夹具含 127/128」两条团队规则
- 2026-Q4 待复审:W-009 在 Compose 版本上收益缩小,需补重组计数口径
那四问我用了一年多,几乎能挡住所有「这条到底适不适用」的争论------争论之所以没完没了,多半是因为双方都在说结论,没人先说清前提。
规范落到业务流程上,是九道闸门
把手册 PDF 发给全组、贴进 Confluence、在周会上念一遍,这三件事我们都做过,M-2417 照样发生了。后来才想明白:规范不是一份文档,是一条带反馈的数据流,得在流程里有落点,每一处落点都要能拿出证据、能停下来。
我们现在这条链长这样:
九道闸门各自拦的东西不一样,混在一起就会互相推诿。我按我们项目现在的分工列一下:
| 闸门 | 谁负责 | 拿什么当证据 | 主要拦哪类 | 我们踩过的一次 |
|---|---|---|---|---|
| 需求评审 | 产品 + 开发 | 状态机与边界条件表 | 业务语义本身写错 | 限领规则没写「同一批次」还是「同一活动」 |
| 设计 | 开发 | 接口契约、幂等方案 | 架构与并发边界 | 限领判断只放在客户端 |
| 编码 | 开发 | IDE 实时检查 | 语义歧义与隐式行为 | == 那行 |
| 自测 | 开发 | 夹具覆盖边界值 | 数据分布依赖 | 夹具全是 12/13/14 |
| CR | 同事 | 只查机器查不了的 | 契约、命名含义、异常语义 | 模板里贴了手册,没人逐行对 |
| CI | 流水线 | 增量扫描 exit code、单测、JMH 门禁 | 已知类别的写法缺陷 | 当时只跑了编译和单测 |
| 灰度 | 运维 + 开发 | 分位数、异常率、业务对账 | 环境 × 数据交叉产物 | 2.3% 灰度暴露批次号 ≥128 |
| 线上复盘 | 全员 | 时间线、根因、影响面 | 流程本身的漏洞 | 两天复盘会 |
| 规范更新 | 规范 owner | 新规则 + 检查脚本 + 到期日 | 同一类事故再次发生 | 加了那条能跑的窄规则 |
这里有个很实际的取舍:CR 里只留机器查不了的东西 。以前我们的 CR 模板有二十来条,从缩进一直问到异常处理,评审的人扫一遍勾完,真正重要的那几行反而没人细看。现在模板只剩六条,全是语义相关的:状态机分支有没有漏、异常吞没吞、日志里有没有敏感字段、并发下共享状态怎么处理、DTO 字段的 null 表达什么业务含义、性能敏感路径有没有测量口径。格式、命名、@Override、魔法值一律交给 IDE 和 CI,谁在 CR 里提这些,反而会被我圈出来问「为什么机器没拦住」------那通常意味着流水线漏了配置。
工单 M-2417 走完整条链
把那次事故按九道闸门回放一遍,比讲十遍「规范很重要」管用。
| 闸门 | 当时为什么没拦住 | 后来加了什么 |
|---|---|---|
| 需求 | 「每人限领一张」没定义跨批次是否算同一人 | 状态机里补 batch_id + user_id 唯一键 |
| 设计 | 幂等只在客户端做,服务端只管发 | 服务端加唯一索引与幂等返回 |
| 编码 | IDE 检查没开包装类型比较那条 | Inspection 配置随仓库走,新人 clone 就有 |
| 自测 | 夹具数据全在缓存区间 | 边界值表强制包含 -1 / 0 / 127 / 128 / 129 |
| CR | 模板贴了手册,但 200 行 diff 里没人盯那一行 | 模板瘦到六条,机器能查的不写进去 |
| CI | 只有编译 + 单测 | 增量扫描,命中即阻断 |
| 灰度 | 靠客服反馈才发现 | 加业务对账:发券数与唯一键冲突数分钟级比对 |
| 复盘 | 归因到「开发疏忽」就结束了 | 归因到「规则没有落点」,必须产出可执行改动 |
| 更新 | 以前只往模板里加一句话 | 产出规则 + 扫描脚本 + 复测数据 + 到期日 |
最后一行是整条链的关键。复盘如果只是「以后注意」,等于什么都没改;产出一条能跑在 CI 里的规则,才叫闭环。我们后来把复盘模板改成这样,事故与规则一一对应:
markdown
<!-- docs/postmortem-to-rule.md -->
## M-2417 · 复诊券重复申领
- 现象:灰度 2.3%,317 笔重复申领
- 直接原因:Integer 批次号用 == 比较,128 起恒为 false
- 根本原因:限领判断没有服务端幂等;规范条目只贴在 CR 模板里
- 影响面:317 用户,补发等额券,客服话术 1 份
- 新增规则:R-014(见 spec/rules.yaml)
- 检查方式:增量 AST 扫描,命中 `==` 且两侧静态类型为包装类即阻断
- 复测:构造 batch_id = 127/128/129 三组,回归用例 6 个
- 度量:同类事故复发数、增量扫描阻断率
- 复审日期:2025-03-14(半年)
规则本身写在 spec/rules.yaml 里,每一条都得带上理由、反例、检查方式、豁免条件、到期日和度量口径。没有「理由」的规则不许进文件------因为半年后复审的人需要知道它当初在防什么,否则只能要么永远留着,要么凭感觉删掉。
yaml
# spec/rules.yaml
version: 7
rules:
- id: R-014
level: block # block / warn / review
title: 包装类型字段参与业务判断不得使用 ==
rationale: >
== 对引用比地址,IntegerCache 让 127 以内碰巧成立,
正确与否取决于数据分布与 JVM 参数,测试用例容易全绿。
case: M-2417(317 笔重复申领)
check: tools/scan_spec.py --rule R-014 --diff
scope: "**/*.java,**/*.kt"
exclude:
- "**/build/**"
- "**/*Test.kt" # 测试里故意构造边界值,不拦
waiver: docs/spec-waiver.md#R-014
valid_until: 2027-03-14
metric: recurrence_count
- id: R-021
level: warn
title: 热点路径禁止 Array<Int> 承载采样点
rationale: 装箱分配在 720 点/帧的路径上会推高 GC 次数。
case: 报告页滑动掉帧复盘
check: tools/scan_spec.py --rule R-021 --diff
waiver: ""
valid_until: 2027-06-01
metric: frame_p90_ms
扫描脚本只查增量,不做全量。全量扫描第一次会产生几千条历史告警,团队两周时间都花在「清干净」上,而历史代码里那些写法大多已经稳定运行多年,改它们反而是引入风险。脚本从 git diff 拿改动行,只在这些行上跑规则,输出 JSON 给 CI 判断:
python
#!/usr/bin/env python3
# tools/scan_spec.py ------ 只扫增量,命中 block 级规则就返回非 0
import argparse, json, re, subprocess, sys, pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
def changed_lines(base: str):
out = subprocess.check_output(
["git", "diff", "--unified=0", base, "--", "*.java", "*.kt"],
cwd=ROOT, text=True)
cur, lineno, rows = None, 0, []
for line in out.splitlines():
if line.startswith("+++ b/"):
cur = line[6:]
elif line.startswith("@@"):
m = re.search(r"\+(\d+)(?:,(\d+))?", line)
lineno = int(m.group(1)) if m else 0
elif line.startswith("+") and not line.startswith("+++"):
rows.append((cur, lineno, line[1:]))
lineno += 1
elif not line.startswith("-"):
lineno += 1
return rows
BOXED = re.compile(
r"(?<!\w)(?:\w+\(\))?\.?\w*\s*==\s*\w+"
) # 粗匹配,精确判定交给下面的类型表
def check(rows, rule_id, waivers):
hits = []
for path, no, text in rows:
if rule_id == "R-014" and BOXED.search(text):
# 白名单:豁免文件里登记且在有效期内
key = f"{path}:{no}"
if key in waivers:
continue
hits.append({"file": path, "line": no, "code": text.strip(),
"rule": rule_id, "level": "block"})
if rule_id == "R-021" and "Array<Int>" in text:
hits.append({"file": path, "line": no, "code": text.strip(),
"rule": rule_id, "level": "warn"})
return hits
def load_waivers():
p = ROOT / "docs" / "spec-waiver.json"
return {f"{w['file']}:{w['line']}" for w in json.loads(p.read_text())["items"]
if not w.get("expired", False)} if p.exists() else set()
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--rule", required=True)
ap.add_argument("--diff", default="HEAD~1")
a = ap.parse_args()
found = check(changed_lines(a.diff), a.rule, load_waivers())
print(json.dumps({"rule": a.rule, "hits": found}, ensure_ascii=False, indent=2))
sys.exit(1 if any(h["level"] == "block" for h in found) else 0)
这版脚本里的 BOXED 是粗匹配,会有误报;实际仓库里我接了 IDE 的类型信息导出来做精确判定,脚本只当兜底。写在这儿是想说清一件事:P3C 项目组那条「宁可少报也不误报」的原则,放到自研规则上一样成立。我们第一版扫得太宽,把 if (a == null) 也拦了,两天里被同事吐槽十几次,第三条规则上线时大家已经不信扫描结果了。宁可只拦最确定的那几种写法,也别让工具失去信任。
CI 里的接法很朴素,卡点只有两个:阻断级规则和豁免到期检查。
yaml
# .ci/spec-gate.yml
stages: [spec, test, report]
spec-incremental:
stage: spec
script:
- python3 tools/scan_spec.py --rule R-014 --diff origin/main
- python3 tools/scan_spec.py --rule R-021 --diff origin/main
- python3 tools/check_waiver_expiry.py --today "$(date +%F)"
- python3 tools/check_rule_expiry.py --rules spec/rules.yaml
allow_failure: false
report:
stage: report
script:
- python3 tools/collect_metrics.py --out spec/baseline.json
artifacts:
paths: [spec/baseline.json]
check_waiver_expiry.py 是后来补的。第一次做豁免机制时没写这个,结果一条「临时允许在报告页用包装类型」的豁免躺了十四个月,谁也想不起来当初为什么开。现在到期当天 CI 直接红,要么补复测数据续期,要么把代码改回来。
CR 模板瘦下来之后长这样,六条全是人该看的:
markdown
<!-- docs/cr-template.md -->
1. 状态机分支:有没有「未测 / 同步失败 / 已过期」之外的第四态
2. 异常:catch 之后是吞了、还是带现场信息与堆栈抛出
3. 日志:有没有手机号、就诊人姓名、token、完整请求体
4. 共享状态:多线程 / 协程下这个字段被谁改、什么时候改
5. DTO 里的 null 表达哪种业务含义,服务端和客户端理解是否一致
6. 性能敏感路径:有没有测量口径,破规矩的话豁免单在哪
第 6 条是我加的,因为它把「破规矩」正式放进了流程------不是偷偷破,也不是靠资历压人,是拿数据换一次例外,并且留下到期日。
顺带说,把事故复盘变成规则这件事,我偶尔会丢给 IDE 里的助手先出一版草稿,三段提示词就够,全选复制改改路径就能用:
text
角色:你只做「事故复盘 → 可检查规则」的转译,不改业务代码。
上下文:@docs/postmortem-to-rule.md 的 M-2417 段落、@spec/rules.yaml 现有规则、
@tools/scan_spec.py 的检查接口。
任务:产出一条新规则的 yaml 草案,字段对齐现有文件。
约束:rationale 必须写清「在哪一层起作用」,不许写「提高代码质量」这类空话;
check 必须指向一个真实存在的脚本,不许编造路径;
不确定的数字留空并标注需要补哪项数据;
不新增第三方依赖。
验收:把草案里的 check 命令实际跑一次,贴出 exit code 与命中行。
text
角色:你是对这条规则持怀疑态度的评审。
上下文:@spec/rules.yaml 的 R-014、@tools/scan_spec.py、最近 30 次 CI 的扫描输出。
任务:列出这条规则最可能出现的三类误报、两类漏报,各给一个最小代码示例。
约束:不许只说「可能有误报」,要指出具体语法形态;
不许建议直接关掉规则,只能给收窄 scope 或精确化的方案。
验收:把你认为最该收窄的一处,写成一条可直接替换的 diff。
text
角色:半年复审人。
上下文:@spec/rules.yaml、@spec/baseline.json、@docs/spec-waiver.md。
任务:判断 R-014 该保留、降级还是删除,给出依据。
约束:依据只能来自度量数(同类事故复发数、阻断率、误报次数),
不许用「大家都这么写」「手册上是强制」当理由;
如果数据不足,直接写「数据不足,需补哪几项」,不要硬下结论。
验收:输出一版修改后的 yaml 与需要补采的度量项。
三段都不长,作用也有限------助手出的第一版规则常把 rationale 写成「避免潜在问题」,把 check 指向一个仓库里根本不存在的脚本,我返工过三次才把约束收紧到「必须指向真实存在的命令」。真正值钱的还是人自己把事故链讲清楚,工具只负责把它转成可执行的形状。
一次完整 CR,从 diff 到合并
九道闸门讲完还是偏抽象,我把 2025 年 9 月 8 日那次 CR 原样捋一遍。同事改的是报告页缓存,87 行 diff,里面恰好混了三种情况:一处明确的写法缺陷、一处需要判断的热点路径、一处合法豁免。三种凑在一次 PR 里,正好能看出机器和人各该管什么。
text
09:12 提 PR #1842「报告页缓存命中率优化」,87 行 diff
09:13 CI 增量扫描启动,三条规则依次跑
09:14 R-014 命中 block,流水线红
09:19 同事改掉 ==,重新推
09:22 扫描只剩一条 warn(R-021),流水线转绿
09:25 我在 CR 里问:这段是不是热点,有没有测量
09:31 同事贴了 JMH 与 gfxinfo,确认在 720 点/帧的路径上
09:34 按 W-009 追加豁免行,@客户端组另一人复核
09:35 合并,collect_metrics.py 上报本次数据
全程 23 分钟,其中「规则争论」6 分钟
第一次扫描的输出长这样:
json
{
"rule": "R-014",
"hits": [
{"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
"line": 63,
"code": "if (entry.batchId == cached.batchId) return cached",
"level": "block"}
]
}
{
"rule": "R-021",
"hits": [
{"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
"line": 71,
"code": "private val samples = arrayOf<Int>()",
"level": "warn"}
]
}
text
CI 日志(节选)
$ python3 tools/scan_spec.py --rule R-014 --diff origin/main
-> 1 hit, level=block, exit=1
$ python3 tools/check_waiver_expiry.py --today 2025-09-08
-> 3 active waivers, 0 expired
spec-incremental: FAILED
第 63 行没什么可讨论的,跟 M-2417 是同一类写法,改完就完。有意思的是第 71 行:R-021 只报 warn,因为 Array<Int> 到底算不算问题,取决于这段代码在不在热点路径上------机器给不出这个判断。CR 里那几句是这么走的:
text
我:71 行这段是每帧都跑吗?如果是,按 W-009 走紧凑表示;
如果只是初始化时建一次,Array<Int> 无所谓,别为了过规则改代码。
同事:每帧重建,720 个点。贴了 gfxinfo,Janky frames 12/120。
我:那申请 W-009,把 JMH 和帧数据贴进豁免单,@一下客户端组。
同事:加了,spec-waiver.json 多了这一行。
json
{"file": "app/src/main/java/com/example/health/cache/ReportCache.kt",
"line": 71, "waiver": "W-009", "expired": false}
json
// spec/baseline.json 本次追加
{
"date": "2025-09-08",
"pr": 1842,
"diff_lines": 87,
"block_hits": 1,
"warn_hits": 1,
"waiver_applied": ["W-009"],
"review_minutes": 23,
"rule_debate_minutes": 6,
"human_decisions": ["这段是否在热点路径"]
}
最后那个字段是我最喜欢的一格:human_decisions 只有一项。87 行 diff、23 分钟、两个人,人真正需要判断的只有「这段是不是热点」。剩下的交给编译器、扫描脚本和到期检查。以前那版二十条的 CR 模板,同样一次 PR 大概要勾二十个格子,勾完反而没人有精力问第 71 行到底跑多频繁。
顺带说清楚一个容易被误解的地方:这次 CI 先红了九分钟,不是流程效率低。如果没有那道红,第 63 行会跟着这次「缓存优化」一起进主干,然后在某个批次号 ≥128 的活动里,用一种跟 M-2417 完全不同、但根因一模一样的方式再炸一次。规范最实在的价值,就是让同一个坑不必被不同的人重新踩一遍。
想突破性能,先分清哪一条能破
「严格按照规范」和「把性能做上去」经常被摆成对立面,其实多数时候是误会:手册里相当一部分条目本身就是性能解,真正会打架的只有一小撮。我吃过亏之后给自己定了个判断框架,三条,按顺序过:
第一条:约束语义正确性的不能破 。== 换 equals、@Override、ThreadLocal.remove()、异常不许吞、finally 里不 return------这些破一次省不下几纳秒,出一次事就是两天。
第二条:约束资源上限或隐式行为的,可以换实现,但代价必须显式 。手册说别用 Executors,本意是别让无界队列和无限线程把资源吃光;你完全可以写一个显式参数的 ThreadPoolExecutor,甚至把队列压到很小,只要把拒绝、背压、监控都摊开写明。这类「破」其实是更深地遵守了初衷。
第三条:只是风格或协作约定的,在热点路径上可以豁免。前提是这段路径真的热、有测量、有到期日,而且破法不改变语义。
下面七个案例,都是我在 CR 里实际写过「这条我申请例外」或者「这条按字面写会亏」的。所有数字都是我在自己机器上跑出来的记录:JDK 17、JMH 1.36、5 轮预热 5 轮测量、每台机跑三遍取中位数,Score Error 一并留着。换 JDK、换日志实现、换数据规模都会变,这些数只用来判断量级和方向,别当通用结论。JMH 只在本地基准里用,不进 App 产物。
案例 A · 不用 Executors 之后,把线程池调得更狠
手册那条【强制】的原文我贴在规则文件里了:「线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险」,说明里点名 FixedThreadPool / SingleThreadPool 的请求队列长度是 Integer.MAX_VALUE,CachedThreadPool / ScheduledThreadPool 的线程数上限也是 Integer.MAX_VALUE。
我们模块 M 的报告批量上传,历史代码是 Executors.newFixedThreadPool(8):8 个线程配一条无界 LinkedBlockingQueue。平时没事,一次补传 2000 份报告时队列堆到 1992,内存抬了 90 多 MB,P99 到 1.8 秒,而且没有任何一处能看出「系统已经扛不住了」------不报错、不拒绝、只是越来越慢。
照手册改成显式构造只是第一步。真正把性能做上去的是后面几个参数:
java
// app/src/main/java/com/example/health/io/UploadPools.java
public final class UploadPools {
private static final int CPU = Runtime.getRuntime().availableProcessors();
// 等待/计算比实测约 4~6,先按 2*CPU 起,再压测找拐点
private static final int CORE = 12;
private static final int MAX = 12;
private static final int QUEUE_CAP = 256;
private static final ThreadFactory NAMED = r -> {
Thread t = new Thread(r, "m-upload-" + SEQ.incrementAndGet());
t.setDaemon(false);
return t;
};
private static final AtomicInteger SEQ = new AtomicInteger();
private static final AtomicLong REJECTED = new AtomicLong();
public static final ThreadPoolExecutor UPLOAD = new ThreadPoolExecutor(
CORE, MAX, 30L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAP), // 有界,堆积有上限
NAMED,
(r, executor) -> { // 背压,不静默丢
REJECTED.incrementAndGet();
if (!executor.isShutdown()) r.run(); // CallerRuns 语义
});
static {
UPLOAD.allowCoreThreadTimeOut(true); // 空闲后连核心线程一起回收
}
public static String snapshot() {
return String.format(
"active=%d queue=%d/%d rejected=%d completed=%d",
UPLOAD.getActiveCount(), UPLOAD.getQueue().size(), QUEUE_CAP,
REJECTED.get(), UPLOAD.getCompletedTaskCount());
}
}
几个决定都不是拍的。CORE 先按 CPU * (1 + 等待/计算) 估,8 核机器上算出 24,压测下来 P99 反而比 12 差------后端连接池只有 16,多出来的线程全在等连接,还多了上下文切换。调到 12 才落在拐点上。队列定 256 是因为一次批量最多 200 份报告,留一点余量,超了就让提交方自己跑(CallerRuns 天然形成背压),比堆在内存里强。allowCoreThreadTimeOut(true) 是为了别让上传池在冷启动之后一直占着 12 条线程。
text
批量上传 2000 份报告 · Pixel 6 连接的测试服 · 每档跑 5 次取中位数
P99 耗时 队列峰值 拒绝次数 峰值内存增量
Executors 无界队列 1810ms 1992 0 +94MB
显式池 / 无界队列 1780ms 1988 0 +92MB ← 只换构造器,等于没改
显式池 / 队列 256 742ms 256 37 +21MB
那 37 次拒绝不是故障,是第一次让「系统扛不住」变成了一个能看见、能告警、能重试的数。我在这次 CR 里破的不是手册那条【强制】------恰恰相反,是把它执行到底了。破的是团队里那种「手册说用 ThreadPoolExecutor,那 new 一个就算完事」的做法。
案例 B · 不在 foreach 里 remove,但 Iterator 不是最快的
手册集合处理那条【强制】给的正解是 Iterator,配的反例特别妙:把 "1".equals(temp) 换成 "2" 试试,结果「肯定会出乎大家的意料」。原因是 ArrayList 的迭代器记着期望的 modCount,循环里直接调 list.remove 改了 modCount,下一次 next() 就抛 ConcurrentModificationException;而删倒数第二个元素时恰好不进下一次 next(),风平浪静地漏过去。正确与否取决于「删的是不是倒数第二个」------又是数据分布。
我们报告页有个批量剔除无效采样点的逻辑,10 万点里常常要删三成。按手册写 Iterator.remove() 完全正确,跑起来却比预期慢:每删一个元素,ArrayList 都要把后面的所有元素 System.arraycopy 往前挪一次,删三成就是几万次数组拷贝。
ArrayList.removeIf 走的是另一条路。它先用 BitSet 把要删的下标标出来,再一趟遍历把存活元素压缩到前面,最后统一截断,只在末尾做一次清理。它不在 foreach 里改集合,完全符合那条规约,属于在规范内更快------这类突破风险最低,收益却往往最实在。
kotlin
// 报告页采样点清洗,两种写法都合规
fun cleanWithIterator(points: MutableList<Int>): Int {
var removed = 0
val it = points.iterator()
while (it.hasNext()) {
if (isInvalid(it.next())) { it.remove(); removed++ }
}
return removed
}
fun cleanWithRemoveIf(points: MutableList<Int>): Int {
val before = points.size
points.removeIf { isInvalid(it) } // BitSet 标记 + 一趟压缩
return before - points.size
}
text
JMH · 10 万元素 ArrayList<Int> · JDK 17 · ms/op(越低越好)
删除比例 Iterator.remove removeIf 倍数
30% 18.4 ± 0.6 2.1 ± 0.1 8.8x
5% 3.2 ± 0.2 1.9 ± 0.1 1.7x
0.1% 1.6 ± 0.1 2.0 ± 0.1 0.8x ← 删得极少时反过来
最后一行我特意留着。删得极少时 removeIf 那块 BitSet 的分配和两趟遍历反而亏一点,我就把阈值写进了团队规则:预计删除比例低于 1% 用 Iterator,高于 5% 用 removeIf,中间按可读性选。一条规则能不能破、怎么破,最后都要落到这种带条件的判断上,而不是「A 比 B 快」这种结论。
案例 C · 包装类型是给边界用的,不是给热点循环用的
手册那条三条并列:POJO 属性【强制】用包装类型、RPC 参数返回值【强制】用包装类型、局部变量【推荐】用基本类型。第三条经常被忽略,可它正是留给性能的口子。
报告页画一条 720 点的趋势曲线,最早从 Java DTO 迁过来时用的是 List<Int>。Kotlin 里 List<Int> 每个元素都是装箱的 Integer,一帧要解 720 次、每帧产生一整批临时对象;IntArray 是连续的基本类型数组,没有装箱也没有对象头。
kotlin
// 边界层:DTO 保留可空语义,null 表示「本次未测」
data class ReportDto(val samples: List<Int?>?)
// 绘制层:进热点之前一次性转成紧凑表示
class TrendRenderer(private val values: IntArray, private val valid: BooleanArray) {
fun draw(canvas: Canvas, width: Int, height: Int) {
// 循环里全是基本类型,不分配对象
}
}
fun ReportDto.toRenderer(): TrendRenderer {
val src = samples.orEmpty()
val values = IntArray(src.size)
val valid = BooleanArray(src.size)
for (i in src.indices) {
val v = src[i]
if (v != null) { values[i] = v; valid[i] = true }
}
return TrendRenderer(values, valid)
}
text
报告页连续滑动 10 秒 · Pixel 6 · Android 13 · 每档 10 次取 P90
帧耗时 P90 分配率 GC 次数
List<Int?> 逐点判断 22.8ms 6.1MB/s 14
IntArray + BooleanArray 13.4ms 0.4MB/s 2
破的是「POJO 用包装类型」被无限外推到所有层的习惯,不是那条规约本身。边界层照样 Int?,只是数据一进热点就换成紧凑表示,中间那次转换是显式的、可测的。我还顺手量过 Sequence:短链路上它的 lambda 和迭代器分配常常比直接 List 还贵,别一听「惰性」就往上套。
案例 D · 手册里有些条目,本身就是性能最优解
不是所有规范都跟性能对着干。手册那条「任何货币金额,均以最小货币单位且整型类型来进行存储」,既防住了浮点误差,又比 BigDecimal 快一个量级。
java
// 反例:double 构造 BigDecimal,把二进制误差一起锁进去
new BigDecimal(0.1); // 0.1000000000000000055511151231257827...
BigDecimal.valueOf(0.1); // 走 Double.toString,才得到 "0.1"
new BigDecimal("0.1"); // 手册要求的 String 构造
// 我们项目的做法:存储与比较全用 long 分
long amountFen = 1990L; // ¥19.90
boolean enough = balanceFen >= amountFen;
text
JMH · 100 万次「金额相加 + 阈值比较」· ns/op
long 分 3.8 ± 0.2
BigDecimal(String).add 142.6 ± 4.1
BigDecimal 不是不用,是要分场合:展示格式化、按比率分摊退款、涉及四舍五入和税率时还是得它,并且显式给 RoundingMode,别让默认行为替你决定钱怎么算。我们把它限制在结算这一个包里,其余全用 long,跨包边界靠一个 Money 值类挡住单位混用。
这一条让我改了对规范的看法:手册里最值钱的条目,往往同时解决正确性和性能,因为它当初就是从一次真实的性能/资金事故里长出来的。跟这种条目较劲,纯属浪费时间。
案例 E · 照抄初始化容量公式,可能反而更慢
手册集合处理那条【推荐】给过公式:initialCapacity = (需要存储的元素个数 / 负载因子) + 1,反例说 1024 个元素不设初始大小会被迫扩容若干次、反复重建哈希表。方向没错,但很多人只记住了「要给个数」,于是代码里到处是 new HashMap<>(1024)------以为够放 1024 个元素。
JDK 8 之后的 HashMap 不是这么算的:
java
// 构造时只是把入参向上取到 2 的幂,暂存进 threshold
public HashMap(int initialCapacity, float loadFactor) {
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 第一次 put 才真正分配 table,并把阈值改成 capacity * loadFactor
tableSizeFor(1024) 得 1024,阈值 1024 * 0.75 = 768。放到第 769 个元素照样扩容一次。想放 n 个不扩容,需要的不是「n」,是满足 capacity * 0.75 >= n 的最小 2 的幂:
java
// app/src/main/java/com/example/health/util/Maps.java
static int capacityFor(int expectedSize) {
if (expectedSize < 3) return 4;
double need = Math.ceil(expectedSize / 0.75d);
int cap = 1;
while (cap < need) cap <<= 1; // 取 >= need 的最小 2 次幂
return cap; // 1024 -> 2048
}
text
JMH · 连续插入 1024 个键值 · 反射读 table.length 统计扩容次数
构造方式 扩容次数 每次插入耗时 首次分配
new HashMap<>() 8 1.93ms/op 16
new HashMap<>(1024) 1 0.81ms/op 1024 ← 以为够,还是扩了一次
new HashMap<>(capacityFor(1024)) 0 0.62ms/op 2048
第三行是用内存换扩容:2048 个桶的数组比 1024 个多占一倍,但在「一次性灌满、之后只读」的场景里很划算。反过来,如果这个 Map 最后只放 20 个元素,那 2048 个桶就是纯浪费------手册说明里那句「暂时无法确定集合大小,指定默认值 16 即可」其实比公式更重要,只是没几个人读后半句。
顺带提一句,嵩山版的更新说明里专门写过修正 HashMap 扩容次数等描述。同一条规约,不同版本给的数字不一样,这本身就是「别把说明当定理」的最好注脚。社区里也有人写过整篇文章讲「给 HashMap 初始化了容量,性能反而更糟」,我看完的感受是:条目的结论可以照做,公式得自己按当前 JDK 的实现重推一遍。
案例 F · SimpleDateFormat 不要定义为 static,然后呢
手册并发处理那条【强制】:「SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static,必须加锁,或者使用 DateUtils 工具类。」正例给了 ThreadLocal<DateFormat>,说明里补了一句 JDK 8 可以用 Instant / LocalDateTime / DateTimeFormatter 代替,还引了官方那句「simple beautiful strong immutable thread-safe」。
按字面执行最省事的写法是每次用时 new 一个。线程安全是安全了,代价全在分配和解析 pattern 上:
text
JMH · 格式化 10 万条日期串 · ns/op
每次 new SimpleDateFormat("yyyy-MM-dd") 2680 ± 90
ThreadLocal<DateFormat>(同线程复用) 142 ± 6
静态 DateTimeFormatter.ISO_LOCAL_DATE 98 ± 3
DateTimeFormatter 不可变、天生线程安全,静态一个就够。但我们是 minSdk 24 的 Android 项目,java.time 在 API 26 以下需要开 core library desugaring,构建配置和包体积都得跟着动。这不是「手册写错了」,是手册默认你在一个能自由选 JDK 的服务端环境里。
我们最后分了两条路:新模块开 desugaring,日期一律 Instant + DateTimeFormatter;老链路留在 ThreadLocal<DateFormat>,但按手册另一条【强制】在 finally 里 remove()------线程池复用线程,不清理就会把上一个请求的格式、甚至上一个用户的上下文带下去。那条规则我加进了增量扫描,命中 ThreadLocal.set 而没有对应 remove 就阻断。
kotlin
// 老链路 · 线程池任务里必须成对
DATE_HOLDER.set(formatter)
try {
render(record.date)
} finally {
DATE_HOLDER.remove()
}
这个案例里我破的不是「不要 static」,而是「条目只给安全默认解、没给性能最优解」这件事。真正做优化时,得把语言版本、平台能力、测量数据三样一起摆上桌。
案例 G · 日志占位符:结论照做,理由换掉
前面说过泰山版那条【强制】的「说明」把性能当理由,实测下来得分情况。我把自己那轮 JMH 贴全:
java
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class LogBench {
private static final Logger LOG = LoggerFactory.getLogger(LogBench.class);
private final String id = "M-2417";
private final String symbol = "coupon";
@Benchmark
public void concat() {
LOG.debug("Processing trade with id: " + id + " and symbol: " + symbol);
}
@Benchmark
public void placeholder() {
LOG.debug("Processing trade with id: {} and symbol: {}", id, symbol);
}
}
text
JDK 17 · logback 1.4 · 5 轮预热 5 轮测量 · ns/op
DEBUG 关闭 DEBUG 开启
字符串拼接 36.8 ± 1.4 112.0 ± 5.2
占位符 8.9 ± 0.3 118.6 ± 4.7
关掉 DEBUG 时占位符赢四倍,因为参数只是传进去、压根不拼字符串;打开时两者几乎持平,占位符甚至略慢一点,多出来的活儿是解析 {}、绑定参数数组、格式化。所以「使用占位符仅是替换动作,可以有效提升性能」这句说明站不住,但结论依然该守,理由得换成三条实在的:
- 避免无效拼接:生产环境 DEBUG 基本是关的,绝大多数日志调用根本不需要造那个字符串。
- 参数与模板分离:结构化字段方便检索,也方便在出口统一做脱敏------我们那个健康类 App 里,就诊人姓名、手机号、token 全靠这一层白名单挡住。
- 堆栈与现场信息带得全 :
logger.error("... {}", param, e)一个调用就把现场和异常都留下,拼字符串时最容易漏掉最后的e。
真正会踩的坑在参数位置:LOG.debug("result={}", expensiveDump()) 里 expensiveDump() 无论级别开没开都会执行,占位符省下的只有字符串拼接。Kotlin 里有人改用 LOG.debug { "result=${expensiveDump()}" } 做惰性,但每次调用都会分配一个 lambda 对象,在极热的循环里得量一下再决定。我们的规则写得很窄:可能高频的 debug 日志用占位符;error 一律带堆栈;固定字面量、无参数的日志允许直接写字符串(这就是我破的第一条规矩,见后面的豁免单)。
案例 H · 手册指出坑,但没给零依赖的填法
「避免用 Apache Beanutils 进行属性的 copy」这条【强制】我完全同意。它慢是有具体原因的:反射加内省拿属性描述符、逐个做类型转换、过程中输出大量调试日志并拼字符串、还重复做 instanceof 检查。但手册给的替代是 Spring BeanUtils 和 Cglib BeanCopier,两个都是第三方库------对我们这种「能不引就不引」的项目,等于把一个问题换成另一个问题。
kotlin
// DTO -> 领域对象,编译期生成,没有反射也没有额外依赖
data class ReportDto(val id: Long, val systolic: Int?, val measuredAt: Long?)
data class Report(
val id: Long,
val systolic: Int?,
val measuredAt: Instant?
)
fun ReportDto.toDomain(): Report = Report(
id = id,
systolic = systolic,
measuredAt = measuredAt?.let(Instant::ofEpochMilli)
)
手写映射还有个手册没提的好处:类型不一致时编译期就报错。BeanUtils.copyProperties 那种「同名就拷、类型不同悄悄转换或不拷」的行为,在我们这里有 Long 时间戳和 Instant 混用,恰恰最容易出静默错误。
text
JMH · 10 万次对象映射 · ns/op
Apache BeanUtils.copyProperties ~4100
Spring BeanUtils.copyProperties ~520
手写映射(Kotlin 具名参数) ~28
引不引库是团队自己的取舍,我没有普适建议。这里想说的是:手册负责告诉你哪里有坑,填坑的方案得结合自己项目的依赖策略来定,照着「正例」抄也可能抄出新的麻烦。
我破过的三条规矩,和留在仓库里的证据
把前面几个案例收拢,真正需要申请豁免的其实只有三条,其余都是「在规范内做得更好」。豁免单统一放 docs/spec-waiver.md,CI 每天检查到期日:
markdown
<!-- docs/spec-waiver.md -->
## W-007 · 无参数固定字面量日志允许字符串直写
- 破的条目:日志输出时字符串变量之间的拼接使用占位符(泰山版 2-3-4【强制】)
- 范围:app/src/main/java/com/example/health/** 中不含任何变量的 LOG.debug/warn 调用
- 理由:无参可绑定,占位符解析是纯开销;JMH 显示开启级别时拼接略快。
占位符的其余收益(脱敏、结构化)在无变量日志上不成立。
- 证据:captures/20250321/log-bench.txt,DEBUG 开启 112.0 vs 118.6 ns/op
- 复核:@后端组 2 人,2025-03-21
- 到期:2026-03-21,到期未补测自动失效
- 回归:改动后 error 级别日志仍强制带堆栈,扫描脚本单独覆盖
## W-009 · 绘制层使用紧凑基本类型数组
- 破的条目:POJO 类属性必须使用包装数据类型(被外推到绘制层的团队习惯)
- 范围:TrendRenderer 及其输入转换,DTO 边界层不变
- 理由:720 点/帧路径上装箱分配推高 GC,Int? 的「未测」语义已由 BooleanArray 表达
- 证据:报告页滑动 10 秒 P90 帧耗时 22.8ms -> 13.4ms,GC 14 -> 2
- 复核:@客户端组 2 人,2025-06-02
- 到期:2026-12-02
## W-011 · 数值计算代码免抽 0/1/2 常量
- 破的条目:不允许任何魔法值直接出现在代码中【强制】
- 范围:app/src/main/java/com/example/health/math/** 纯数值计算
- 理由:循环下标、数组索引、数学恒等式里的 0/1/2 抽成常量后可读性下降,
且与手册同节【推荐】「不要用一个常量类维护所有常量」冲突
- 证据:CR 记录 2025-04-08,扫描白名单 diff
- 复核:@规范 owner,2025-04-08
- 到期:2026-10-08
json
{
"items": [
{"file": "app/src/main/java/com/example/health/io/UploadPools.java",
"line": 42, "waiver": "W-007", "expired": false},
{"file": "app/src/main/java/com/example/health/render/TrendRenderer.kt",
"line": 17, "waiver": "W-009", "expired": false}
]
}
| 条目 | 手册级别 | 初衷 | 能否破 | 我的做法 | 证据 |
|---|---|---|---|---|---|
包装类型比较用 equals |
强制 | 引用语义被误当值语义 | 不能 | 照做,且写进增量扫描 | M-2417 |
覆写方法加 @Override |
强制 | 签名漂移静默失效 | 不能 | 交给编译器 | 编译期报错 |
ThreadLocal 必须回收 |
强制 | 线程池复用串数据 | 不能 | try/finally + 扫描 |
老链路排查 |
不用 Executors 创建线程池 |
强制 | 无界资源耗尽 | 可换实现 | 显式参数 + 有界队列 + 背压 | P99 1810→742ms |
| foreach 里不 remove/add | 强制 | fail-fast 依赖数据位置 | 规范内更快 | 高删除比例用 removeIf |
30% 时 8.8x |
| POJO 用包装类型 | 强制 | null 表达额外业务状态 | 分场合 | 边界层保留,绘制层紧凑表示 | P90 22.8→13.4ms |
| 金额用最小单位整型 | 强制 | 浮点误差 | 不破,本来就是性能解 | long 分 + 结算层 BigDecimal |
3.8 vs 142.6 ns/op |
| 集合初始化指定大小 | 推荐 | 减少 resize | 按 JDK 重推公式 | capacityFor(n) 取 2 的幂 |
扩容 1→0 次 |
SimpleDateFormat 不 static |
强制 | 共享 Calendar 状态 | 可换实现 | DateTimeFormatter,老链路 ThreadLocal + remove |
2680→98 ns/op |
| 日志拼接用占位符 | 强制 | 说明写的是性能 | 窄范围豁免 | 无参固定字面量直写 | 112.0 vs 118.6 ns/op |
| 不允许魔法值 | 强制 | 常量漂移 | 窄范围豁免 | 数值计算代码免抽 0/1/2 | CR 记录 |
| 避免 Apache BeanUtils | 强制 | 反射开销与静默转换 | 换实现 | 手写编译期映射 | 4100→28 ns/op |
三条豁免后来都回到了 spec/rules.yaml:日志规则补了「无变量调用除外」,魔法值规则补了「纯数值计算除外」,包装类型规则补了「绘制层紧凑表示除外」,每条都带 valid_until。破规矩不是削弱规范,是给规范补上例外条件------一条没有例外条件的规则,迟早会有人在暗处绕过去,而绕过去的那次不会留下任何证据。
让生成器写代码之后,规范反而更有用了
2025 年组里开始用 IDE 里的助手写一些模板代码。第一周我以为麻烦会是「它不守规范」,结果反过来------它太守了。
生成一段报告页的数值计算,它把每个 0、1、2 都抽成了 ZERO / ONE / TWO 常量;我让它把采样点改成 IntArray,下一轮它又改回 List<Int>,理由是「POJO 属性应当使用包装数据类型」;连一句不带参数的固定日志,它都硬塞了个 {} 进去。它读的是网上那套通用手册,不知道我们为这三条写过豁免单、贴过 JMH 数据、约过到期日。
那一刻我才把规范的用处想明白一层:它不再只是约束人的文档,它是喂给机器的边界说明。以前一条规则写含糊了,最多是 CR 里争两句;现在含糊的地方会被生成器按最保守的通用解释放大成几十行代码,再让人去删。
具体变了三处。代码形状越统一,生成器越容易补全、越不容易在几种写法之间摇摆;规则文件可以直接当成上下文投喂,不用每次在对话里重讲一遍;生成结果必须过同一套机械检查,人只审那些机器判不了的判断。前面讲初衷时提的第三条「可预测」,到这里才算真正兑现。
我做了一轮不太严谨但足够说明方向的对照:同一个需求拆成 12 段小代码,A 组只给需求描述,B 组额外附上 spec/rules.yaml 的本地规则、spec-waiver.md 三张豁免单和裁剪表。两组的生成结果都过同一套增量扫描,统计三项。
text
12 段模板代码 · 同一模型 · 每段生成一次 · 人工逐行数修改量
A 组(只给需求) B 组(附规则与豁免)
至少命中 1 条 block 规则 9 / 12 2 / 12
改写已有豁免范围的合法实现 0 / 12 1 / 12 ← 「过度合规」
平均人工修改行数 23.4 6.1
平均生成耗时 8.2s 11.7s
B 组那 1 段「过度合规」挺有意思:它把 W-009 读成了「基本类型数组不许用」,于是把已经优化过的 IntArray 又改回 List<Int?>。原因是豁免单里写的是「绘制层允许紧凑基本类型数组」,它只抓住了「允许」二字,没抓住范围限定。后来我把豁免单的格式改成三段式------适用范围 / 明确禁止什么 / 允许什么,并且要求每条豁免附一个正例一个反例,这类误读才停。生成耗时多出来的 3.5 秒是读上下文的代价,跟省下的 17 行人工修改比,划算。
给生成器看的规则文件,我放在 spec/AI_RULES.md,不同 IDE 各有各的项目规则读取路径,内容复制过去就行。关键是别把整本手册塞进去:
markdown
<!-- spec/AI_RULES.md · 约 120 行,只放本地规则 -->
# 本仓库的编码边界
## 适用范围
Kotlin / Java,minSdk 24,compileSdk 34,AGP 8.2.2。
零第三方依赖优先;新增依赖需走评审。
## 硬约束(违反即阻断)
- R-014:包装类型字段参与业务判断不得用 ==,用 Objects.equals 或 Kotlin ==。
- R-021:热点路径不得用 Array<Int> 承载采样点。
- R-018:ThreadLocal.set 必须有对应的 remove,放在 finally。
- R-022:日志与崩溃上报不得出现 idCard / phone / patientName / token 原值。
- 所有覆写方法带 override / @Override。
## 已批准例外(照抄规格,不要自行推广)
- W-007:不含任何变量的固定字面量日志,允许直接写字符串,不要加 {}。
适用范围:固定文案的 debug/warn;禁止:error 级别、含变量的日志。
- W-009:绘制层允许 IntArray + BooleanArray 表达采样点与有效性;
适用范围:TrendRenderer 及输入转换;禁止:DTO / RPC 边界层。
正例:IntArray + BooleanArray;反例:List<Int?> 逐点判空。
- W-011:纯数值计算代码免抽 0/1/2 常量;
适用范围:math 包;禁止:业务阈值、缓存 key、状态码。
## 输出要求
- 只给改动文件与 diff,不复述规则条目。
- 涉及性能的说法必须附测量口径(工具、预热轮数、分位数、机型、样本量)。
- 不确定的地方写「需补哪几项数据」,不要编造脚本路径或数字。
- 生成结束时自行列出:本次改动触碰了哪几条规则、有没有落在豁免范围里。
三段提示词是我平时直接复制用的,跟前面「事故复盘转规则」那三段不重样:
text
生成前 · 注入边界
上下文:@spec/AI_RULES.md、@spec/local-adaptation.md 的裁剪表、本次需求描述。
任务:先输出一份不超过 8 行的实现计划,逐行标注会触碰哪条规则。
约束:计划里不许出现「遵循最佳实践」这类空话;
遇到豁免范围,写明是 W-00X 的哪一段适用范围;
如果需求本身缺少边界条件(例如 null 表达什么业务状态),先提问再动手。
验收:把计划里的每条规则在 AI_RULES.md 里核对一遍,贴出行号。
text
生成后 · 对抗性自查
上下文:@本次生成的 diff、@spec/AI_RULES.md、@tools/scan_spec.py。
任务:以「挑刺的评审」身份列出三件事:
(1) 有没有为了合规把热点路径改回低效实现;
(2) 有没有把豁免单的适用范围推广到别的包;
(3) 有没有凭空造出脚本路径、类名或性能数字。
约束:每条都要给 diff 里的具体行号;不许只说「看起来没问题」。
验收:实跑一次增量扫描命令,贴出 exit code 与命中行。
text
规则更新后 · 回归验证
上下文:@spec/rules.yaml 的本次 diff、@spec/AI_RULES.md、@spec/baseline.json。
任务:判断规则文件的改动有没有同步到 AI_RULES.md,并找出因此失效的旧提示词样例。
约束:只依据文件内容与度量数判断,不许用「应该差不多」下结论;
发现不一致就给出具体补丁,不要重写整份文件。
验收:给出「需要改哪几行」的清单,以及一条能验证同步结果的命令。
这中间返工过三次。第一版我把整本手册的七个维度全塞进规则文件,结果生成器每次输出前先花半屏解释条目适用性,产出反而变慢,还时不时引用一条在我们项目里已经删掉的规约。第二版砍到 40 条,仍然偏厚。最后只留 14 条本地规则、3 张带正例反例的豁免单、一张裁剪表,约 120 行,误读才基本消失。规则文件不是越厚越准,厚度本身就是噪音------这条跟人对规范的反应其实一模一样。
还有个边界得说死:生成器不会替你判断业务语义。「限领一人一张」到底按用户算还是按批次算、null 是「未测」还是「同步失败」,它只能照着你写下的契约来;契约本身写错了,生成出来的代码再合规也是错的。M-2417 的第一层 == 现在能被扫描器拦住,第二层「服务端没有幂等」依然只能靠人在需求评审那道闸门上问出来。
所以规范的价值只是多了一层:既让人少犯错,也让机器按我们划定的边界生成。而这份边界里最值钱的部分,恰恰是那几张写着到期日、附了实测数据的例外单------它们记录了我们在什么条件下、为什么、以及到什么时候为止,可以不按通用规则来。
踩过的坑,按当时看到的报错排
前几篇我习惯把排查记录攒成一张表,这次也一样。按「现象 → 根因 → 修复」排,比我按顺序讲一遍流程省时间。
| 现象 | 根因 | 修复 |
|---|---|---|
| 限领判断失效,317 笔重复申领 | 两个 Integer 用 ==,128 起各是不同对象 |
Objects.equals + 服务端唯一键幂等 |
| 单测两周全绿,灰度第一天就错 | 夹具全是 12 / 13 / 14,落在 IntegerCache 区间 |
边界值表强制 -1 / 0 / 127 / 128 / 129 |
| 同一份代码压测环境不复现 | -XX:AutoBoxCacheMax 把缓存上限调高了 |
固定并记录各环境 JVM 参数 |
| 父类改了方法签名,功能静默失效 | 实现类漏 @Override,成了新方法 |
编译器检查 + IDE Inspection 随仓库走 |
HashSet 里存进两个「相等」对象 |
只重写 equals,没重写 hashCode |
两个一起重写,或用 data class |
| 缓存怎么读都是 miss | "Id#taobao_" 少一个下划线 |
常量集中定义 + 读写同一构造入口 |
交易涨跌显示 0% 而不是横线 |
基本类型默认值吞掉了 RPC 失败的 null | 包装类型 / 可空类型表达「无数据」 |
| 记录的创建时间被刷成当前时间 | POJO 属性设了 new Date() 默认值,更新时顺带写入 |
不设默认值,写入方显式赋值 |
Long a = 2l 被同事问「是 21 吗」 |
小写 l 与数字 1 难分 |
一律大写 L |
子列表强转 ArrayList 抛 ClassCastException |
subList 返回的是视图,不是 ArrayList |
new ArrayList<>(list.subList(...)) |
改完原集合再读子列表抛 ConcurrentModificationException |
modCount 与视图期望值不一致 |
先拷贝再改,或改完重新取视图 |
Arrays.asList(...).add(...) 抛 UnsupportedOperationException |
返回的是数组适配器,没实现修改方法 | 包一层 ArrayList,或改设计别原地增删 |
| foreach 里 remove 有时抛异常、有时静静漏掉 | 删倒数第二个时不进下一次 next() |
Iterator.remove();批量删用 removeIf |
| 批量删三成元素慢得离谱 | Iterator.remove 每次触发 System.arraycopy |
removeIf 用 BitSet 一趟压缩,30% 时 8.8x |
| 日期串偶尔串到别的年份 | static SimpleDateFormat 共享 Calendar |
DateTimeFormatter;老链路 ThreadLocal |
| 线程池里读到上一个请求的用户信息 | ThreadLocal.set 后没 remove |
try/finally 成对,扫描脚本阻断 |
| 补传 2000 份报告越来越慢,却不报错 | Executors 无界队列堆到 1992 |
有界队列 + CallerRuns 背压 + 拒绝计数 |
| 金额对账差一分 | new BigDecimal(0.1) 把二进制误差锁进去了 |
BigDecimal("0.1");存储用 long 分 |
new HashMap<>(1024) 放 1024 个还是扩了一次 |
阈值是 capacity * 0.75 = 768 |
capacityFor(n) 取满足阈值的 2 的幂 |
| 给所有 Map 都填初始容量 16,内存反而涨 | 手册说明后半句「不确定就默认」被忽略 | 按预估量算,不确定就留默认 |
LOG.debug("{}", expensive()) 关掉级别照样慢 |
参数位置在调用前就求值了 | 级别判断包住昂贵计算,量了再决定惰性写法 |
| Kotlin 惰性日志在热循环变慢 | 每次调用分配一个 lambda | 只在低频路径用惰性,热路径实测 |
为消魔法值堆出两千行 Consts.java |
把【强制】一刀切,忽略同节【推荐】 | 按功能归类 + 数值计算白名单 |
| 告警清零但代码一点没变好 | 字符串搬进 StringPool、@SuppressWarnings 一键压制 |
度量换成同类事故复发数 |
| 首次全量扫描 3000 条,团队两周都在清告警 | 没跑基线就全量阻断 | 只扫增量,存量单独排期 |
| 报告页滑动掉帧,heap dump 没什么异常 | List<Int> 装箱推高分配率与 GC |
绘制层 IntArray + BooleanArray |
Kotlin 里写 "test".equals(obj) 被同事圈出来 |
语言已把空安全与值比较收进 == |
做一张「前提已消失」清单 |
minSdk 24 上 java.time 编译不过 |
没开 core library desugaring | 构建配置补齐,或老链路保留 ThreadLocal |
| 一条豁免躺了十四个月没人记得 | 豁免没写到期检查 | CI 每日扫 expired,到期自动红 |
| 助手产出的规则指向不存在的脚本 | 没验收就合进了规则文件 | 约束里写死「必须实跑 check 命令并贴 exit code」 |
| JMH 第一次跑出的数字离谱 | 没预热,JIT 还没介入 | 5 轮预热 5 轮测量,保留 Score Error |
命令速查
这些命令不复杂,难的是输出里到底该看哪一行。
bash
# 把自动装箱摊开看:确认 == 到底比的是什么
javap -c -p IntegerEqDemo.class | grep -A3 -E 'boxedCompare|if_acmpne'
# 1: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
# 12: if_acmpne 19 <-- 引用比较,不是数值比较
bash
# 直接验证缓存边界,比背结论快
jshell <<'EOF'
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true
System.out.println(c == d); // false
System.out.println(c.equals(d)); // true
EOF
# 换个 JVM 参数再跑一次,看结论怎么变
java -XX:AutoBoxCacheMax=1000 -jar IntegerEqDemo.jar
# 128 那组也会变成 true ------ 这正是「环境 × 数据」型 bug 难查的原因
bash
# 增量扫描,只查本次改动
python3 tools/scan_spec.py --rule R-014 --diff origin/main
# {
# "rule": "R-014",
# "hits": [
# {"file": ".../CouponChecker.java", "line": 42,
# "code": "if (coupon.getBatchId() == rule.getBatchId()) {", "level": "block"}
# ]
# }
# exit code = 1 -> 流水线阻断合并
bash
# 规则、豁免、度量三样一起过一遍
python3 tools/check_waiver_expiry.py --today "$(date +%F)"
python3 tools/check_rule_expiry.py --rules spec/rules.yaml
python3 tools/collect_metrics.py --out spec/baseline.json
bash
# 本地基准:JMH 只在测量侧,不进 App 产物
java -jar target/benchmarks.jar LogBench -wi 5 -i 5 -f 1 \
-rf text -rff captures/log-bench.txt
# Benchmark Mode Cnt Score Error Units
# LogBench.concat avgt 5 112.024 ± 5.183 ns/op (DEBUG 开)
# LogBench.placeholder avgt 5 118.610 ± 4.702 ns/op
# 看 Error 是否和 Score 同量级;同量级就别下「快了多少」的结论
bash
# Android 侧的分配与帧耗时,跟 JVM 基准分开记口径
adb shell dumpsys gfxinfo com.example.health framestats | tail -20
adb shell dumpsys meminfo com.example.health | grep -E 'PSS|Dalvik|Native'
# gfxinfo 看 Janky frames 与 90th percentile,
# meminfo 看 PSS 分项,别拿 Java Heap 一个数解释全部
上线前我盯的四个数
规范落地这件事,最容易骗自己的度量是「告警数」。我盯的是另外四个,每个都写清口径和样本量。
| 数 | 口径 | 之前 | 之后 |
|---|---|---|---|
| 同类事故复发数 | 复盘标签为「编码写法类」,12 个月滚动 | 3 起 / 12 个月 | 0 起 / 12 个月 |
| CR 争议时长中位数 | 从提 PR 到合并,仅统计出现规则争论的 PR,30 次样本 | 41 分钟 | 18 分钟 |
| 增量扫描阻断率 | 命中 block 级规则次数 / 该规则实际存在的写法次数,90 天 | 人工抽查,无法统计 | 命中 23 次,误报 2 次,漏报 1 次 |
| 报告页帧耗时 P90 | Pixel 6 / Android 13,连续滑动 10 秒,10 次取 P90 | 22.8ms | 13.4ms |
第三个数的「误报 2 次、漏报 1 次」我特意留着。R-014 刚上线时把测试代码里故意构造的 == 也拦了,改了 scope 才收住;漏报那次是有人把比较写进了 Kotlin 的 when 分支,粗匹配没覆盖到,后来接了 IDE 的类型信息才补上。规则不是装上就准的,得跟着误报迭代。
回归也有。W-009 到期前复测,切到 Compose 版本之后,IntArray 那部分收益从 9.4ms 缩到 3.1ms------重组频率变了,瓶颈挪到了别处。我把复测结果贴进豁免单,续期三个月,同时把 metric 从 frame_p90_ms 改成同时盯 recomposition_count。还有一次 JMH 忘了预热,跑出来的数字比现在贴的大三倍,那份数据我直接作废重跑,没往文章里放。
有几次复盘,我故意没加规则
M-2417 之后那半年,组里形成了个习惯:复盘会最后一定问一句「这条要不要加进规范」。有阵子几乎每起事故都加一条,规则池从 6 条涨到 14 条,扫描脚本跟着长,AI_RULES.md 越写越厚,误报开始冒头。2025 年 Q4 连着三次复盘会,我都投了反对票。不是觉得规范没用,是那三起事故的根因压根不在「写法」上。
第一起是报告页日期显示差一天。测试机在 UTC+8,灰度有几位用户在 UTC+0,服务端返回的是毫秒时间戳,客户端用 SimpleDateFormat 按本地默认时区格式化,跨日就错位。有人提议加一条「所有日期必须显式指定时区」。我拦了:这条看着像规范,实际是把一个领域模型问题伪装成编码风格问题。真正的解法是边界层统一 Instant、展示层才按用户时区渲染,再补一组跨时区夹具。加了规则,也只是逼着每个人在每行格式化后面补一个 .withZone(...),漏一处照样错,而且扫描器根本查不出「这个时区是不是该用的那个」。
第二起是某个同步接口偶发超时,重现不了。提议加「所有网络调用必须设置超时时间」------这条其实早就在规则池里了。查下来是服务端连接池在批量任务时被打满,客户端该做的是加重试退避与超时率监控。往规范里再加一条,只会让规则文件多一行谁都不会再看的话。
第三起是优惠券叠加算错,公式把「满减后打折」写成了「打折后满减」。这类业务语义错误,前面讲 bug 分类时我把它放在右下角那块:完全合规、静态扫描一个字不报。它该落到契约测试和对账口径上,跟编码规范没有关系。
后来我把判断写成四问,复盘会里逐条过:
markdown
<!-- docs/postmortem-to-rule.md · 决定是否新增规则 -->
1. 根因是「写法」吗?还是设计、契约、环境、依赖服务?
2. 同类事故会复发吗?是一次性配置失误,还是会被反复写出来?
3. 能机械检查吗?检查的误报成本有多高?
4. 加进去之后,规则池多一条,谁在半年后负责复审它?
四问里有两个以上答不上来,就不加,改投到别的闸门。
| 事故 | 根因在哪 | 该落到哪道闸门 | 加规则的后果 |
|---|---|---|---|
| M-2417 重复申领 | 写法 + 缺幂等 | 增量扫描 + 服务端唯一键 | 有效,同类复发归零 |
| 报告日期差一天 | 领域模型(时区) | 设计 + 跨时区夹具 | 逼人逐行补时区,仍会漏 |
| 同步接口偶发超时 | 服务端连接池 | 监控 + 重试退避 | 规则池里已有,重复一条 |
| 优惠券叠加算错 | 业务公式 | 契约测试 + 对账 | 扫描器查不到,纯噪音 |
| 首次全量扫描 3000 告警 | 流程配置 | 只扫增量 + 存量排期 | 属于规则自身的问题,不是新规则 |
规则池后来稳定在 14 条:删过 4 条(其中两条是我自己加的),2 条从 block 降成 warn,AI_RULES.md 卡在 120 行不再往上加。误报次数从刚上线那个月的 11 次降到最近一个季度的 2 次,靠的不是规则写得更聪明,是砍掉了三条扫得太宽的规则。规范的克制也算一种能力------加一条规则很容易,忍住不加、把问题送到对的闸门上,难得多。
有个挺微妙的副作用:规则少而准的时候,CI 变红大家会认真看;规则多而松的时候,红久了就有人顺手 @SuppressWarnings。前面讲「严格」两个字值钱,其实还有后半句------严格的前提是值得严格。
给还在纠结要不要立规矩的人
这两年组里换了几批人,被问得最多的还是「我们到底要不要搞一套规范」。我后来把说过的话收敛成几件事,每件都配着我们踩过的坑。
先把规则分成三层 ,别一视同仁。能机械判定的(@Override、包装类型比较、foreach 里 remove)交给 IDE 和 CI;需要人判断的(异常语义、null 的业务含义、并发边界)留在 CR;纯团队约定(命名偏好、目录结构)写进文档但不进流水线。三层混在一起,结果就是 CR 模板二十条、CI 三千告警、谁都嫌烦。我们瘦到六条之后,评审意见里指向业务语义的比例反而上来了。
每条规则都得带理由和反例 。半年后复审的人不一定经历过那次事故,rationale 里写「提高代码质量」等于什么都没写;写成「== 对引用比地址,正确与否取决于数据与 JVM 参数」才有人能判断它还成不成立。我们 spec/rules.yaml 里没有理由的条目一律不许合入。
只从真实事故里加规则,不从审美偏好里加。前面那几组研究结论我一直记着:通用规则里真正能预测缺陷位置的往往只有少数几条,而针对本系统写出来的规则效果明显更好。M-2417 之后我们加的那条窄规则,比贴了四年的手册模板管用得多,就是因为它长在自己的工单里。
存量代码先跑基线,别上来就全量阻断。第一次扫描几千条告警,两周时间全花在改多年没动过的老代码上,引入的风险比消除的多。只扫增量,存量单独排期,这条是我们用一次团队倦怠换来的。
度量别用告警数 。告警数下降可以靠 @SuppressWarnings 和把字符串搬进另一个类实现,一点质量都没改。换成同类事故复发数、CR 争议时长、阻断率与误报次数、热点路径 P90,这些数不好看,但骗不了人。
给例外一个正式通道。逼着人「绝对不许破」,最后得到的是暗处绕过。waiver 单要求写清范围、理由、测量数据、复核人、到期日,CI 每天检查到期------破规矩这件事一旦留下证据,反而变成了规范里最有价值的那部分例外条件。
半年复审,敢删条目 。语言在走,平台在换。Kotlin 的可空类型、java.time、Compose 的重组模型,都让一批手册条目的前提消失了。我维护着一张「前提已消失」清单,到期没复核的规则自动降级成【参考】,逼着人重新看一眼。删掉一条过时规则,比新加三条更能维护规范的权威。
Android / Kotlin 团队要做本地化裁剪 。手册从 Java 后端长出来,"test".equals(obj)、POJO 用包装类型、SimpleDateFormat 不 static 这些条目到了 Kotlin 和 ART 上,要么前提没了,要么代价变了。裁剪不是不尊重手册,是承认它的适用边界。我们连 List<Int> 与 IntArray 这种细节都写进了团队规则,因为掉帧是实打实的。
新人只给十条最小子集。一次丢整本手册,结果通常是背下几条口号,遇到具体场景还是不会判断。我给前十条全配了反例和工单号,让他顺着「这一条在第几行起作用」讲一遍,比背结论有用。
别把「插件零告警」当质量指标,也别把规范当质量保证的全部 。规范消灭的是已知类别的写法缺陷,优惠券门槛算错、状态机漏分支这类业务语义错误,静态扫描一个字都不会报。契约、边界值测试、灰度对账、线上监控得各自在位。M-2417 的第一层是 ==,第二层是服务端没做幂等,后者才决定了影响面有多大。
让每一次性能争论都带上测量口径。工具、预热轮数、分位数、机型、样本量、误差,缺一样就别下「快了多少」的结论。手册里那些「说明」经不起实测的地方,我都是靠这一条发现的;反过来,我自己的 JMH 数字也只是我机器上的记录,换环境就得重跑。
助手生成的东西一律当草稿。让它把事故复盘转成规则挺省事,但它会写出不存在的脚本路径、把理由写成空话。约束里写死「必须实跑检查命令并贴 exit code」,这条我返工过三次才加对。
还有件小事:规范 owner 别由写规范的人长期兼着。自己复审自己写的条目,下手总会软一点。我们半年轮换一次,第一次轮换就删掉了四条没人说得清理由的规则,其中两条是我加的。
后来
上个月新同事在 CR 里第一次用 W-011 的格式申请例外,把 JMH 输出和到期日都填齐了。我在下面回了一句:把复测命令也贴上来。他补完,我点了通过。
那张 M-2417 的复盘打印件还贴在工位隔板上,纸角已经卷了。有回路过的人问上面那串 317 是什么,我说是三百多张券,也是一条规则的成本。