契约检查交给脚本,而不是审阅者
一句话总结
改了一个编号的 diff,人眼是看不出来的。所以 schema 的兼容性检查应该由脚本来做,而不是由评审者来做——解析两个 .proto,按消息对照编号,把文档中“不安全的变更”清单转写成机器能读的形式。
为什么需要它
上一个模块重现了标题里的那起事件。修改编号的变更能通过编译,单元测试也能通过,在代码评审中,它看起来只是有两个数字改了的一行。评审者不可能每次都记得“这个编号以前是什么”。而半年前删掉的字段的编号,就更没有人记得了。最佳实践文档写道“绝对不要复用编号,即使你认为没有人在用”,意思就是这种记忆靠不住。
机器是记得的。把旧的 .proto 和新的 .proto 并排放在一起对照编号,就能抓住所有人会漏掉的东西。这项检查是把文档中的更新消息类型规则照抄下来,所以没有需要判断的地方,把它放进 CI,合并之前就会运行。这个模块会用标准库亲手做出这个检查器。
工作原理
比较什么。 wire 兼容性是按消息、以字段编号为键来看的。名称是次要的。所以检查器的数据结构,只需要 {메시지: {번호: (이름, 타입)}}(占位符依次为消息、编号、名称、类型)再加上预留编号和预留名称的集合就够了。嵌套消息用 Outer.Inner 命名,另外单独比较——如果只看外层消息,就会漏掉内层的删除。
规则来自文档。 下表是检查器给出的判定,右边是依据。
| 判定 | 条件 | 依据 |
|---|---|---|
| REMOVED_NOT_RESERVED | 旧编号在新文件中不存在,也不在 reserved 中 | 删除是安全的,但不能再使用这个编号 → 用 reserved 来阻止 |
| RENUMBERED | 同一个名称换了另一个编号 | 修改编号相当于删除后再新建——不安全 |
| TYPE_CHANGED | 编号相同、名称相同,类型不同 | 几乎不要改类型(部分类型有条件兼容) |
| NUMBER_REUSED | 同一个编号对应了不同的名称、不同的类型 | 复用编号会使解码产生歧义 |
| REUSED_RESERVED | 新文件把旧文件中 reserved 的编号当作字段使用 | 不要把编号从预留列表里拿出来用 |
| WARN RENAMED | 编号相同、类型相同,只有名称不同 | wire 里没有名称——二进制不受影响,JSON、TextProto 会受影响 |
只把改名当作警告,原因在编码文档里——字节里只有编号和 wire type,名称是读取方看着 .proto 补上的。只改名称,字节连一个比特都不会变。不过对于像 ProtoJSON 这样会序列化名称的格式的使用者来说,这是会造成破坏的变更,所以检查器要通知,但不阻止。如果区分不了这一点,检查器每次都会亮红灯,而红灯太频繁的检查器会被无视。
规则的优先级。 一个原因只应该输出一行。id 从 1 号移到 2 号,“1 消失了(REMOVED)”和“2 上来了一个不同的名称(RENAMED)”也同时成立,但原因只有一个——改了编号,所以只输出 RENUMBERED,并且不再继续检查那个编号。如果编号在新文件中仍然存在,就不算 REMOVED。
解析要宽容,判定要严格。 真实的 .proto 里混杂着注释、跨越多行的声明、像 [deprecated = true] 这样的选项,以及像 reserved 9 to 11 这样的范围。如果解析器被这些东西绊倒,人们就会把检查器关掉。而判定则一步也不能退让——格式稍有不同也要能读,但编号复用一定要判为失败。在 Python 中,可以用正则表达式去掉注释,找到 message 이름 {(占位符为名称)并数大括号来配对,再用 타입 이름 = 번호;(占位符依次为类型、名称、编号)的正则表达式读取里面的内容。
FIELD = re.compile(r"(?:\b(optional|repeated)\s+)?([A-Za-z_][\w.]*)\s+([A-Za-z_]\w*)\s*=\s*(\d+)\s*(?:\[[^\]]*\])?\s*;")
输出契约。 检查器是一个工具。它既要输出给人读的行,也要给出给机器读的退出码——每个违规占一行,以判定名称开头,并包含消息名称和编号。有违规就 exit 1,没有就在最后一行打印 OK 并 exit 0。警告不影响退出码。有了这份契约,才能用 shell 脚本遍历目录,只要有一个失败,就拦住 CI。
检查器抓不住的东西。 文档中有条件兼容的清单——把 int32 改成 int64——只有在能控制部署顺序时才是安全的。检查器不知道那个顺序,所以用 TYPE_CHANGED 来拦住才是对的。反过来,默认值的含义变化(“0 现在不再表示‘未定’,而是表示‘免费’”),字节和 schema 文本都没有变化,任何检查器都抓不住。最佳实践文档之所以另外写明“几乎不要改变字段的默认值”,原因就在这里。检查器不是用来取代评审的,而是让评审者不必把时间花在对照数字上。
在现场相遇的样子
第一天把 schema 兼容性检查放进 CI 时,通常会遇到一件事——仓库里的几十个旧 schema 一下子全都变红了。因为没有预留就删掉的编号,已经积攒了好几年。这时如果把规则关掉,就和没有工具一样了。应该先提交一个清理 PR,把 reserved 补到旧文件里。给已删除的编号做预留,什么时候做都是安全的变更,所以没有风险。
第二种是“警告疲劳”。如果把改名当作错误,每修一个拼写错误,检查都会被拦住,人们就学会了 --no-verify。之后,真正的违规也会一起被放过去。区分警告和错误并不是奢侈,而是检查器得以存活的条件。
第三种是嵌套消息。检查器只看最顶层的消息,结果 Order.Item.count 的类型变更就这样被合并了。写解析器时,只要有一行把名称命名为 Outer.Inner,就能避免这起事故。
下一项实验要做什么
编写 /root/grpc/compat/protocheck.py。先用 --dump 确认解析器能读取 fixture 和第一次见到的文件(注释、范围预留、嵌套、选项),再逐条添加规则——没有预留的删除、编号变更、类型变更与编号复用、预留编号复用,以及只给出警告的改名。运行 8 对 fixture 生成报告,最后编写按目录运行的 check-all.sh。评分器除了 fixture 之外,还会构造指导语中没有出现的 .proto 对,送给你的脚本检查,所以不能把 fixture 的名称写死。