构建契约检查器
目标
编写 protocheck.py,比较两个 .proto,逐行输出 wire 兼容性的违规项。
逐条添加规则,让它通过 8 对 fixture 和评分器暗中准备的那几对,
最后编写按目录运行的 check-all.sh,使它成为可以放进 CI 的形态。
为什么重要
在上一个实验中,你已经看到,只要改动一个编号,旧客户端就会在没有任何错误的情况下读出错误的值。 这种改动在代码评审中很难被注意到——diff 里只是两个数字 变了,编译和测试也都能通过。这不是一种人每次都能抓住的 失误。所以契约检查应该由脚本来做,而不是由评审者来做。 检查器的规则全部来自官方文档的“安全的变更、不安全的变更”清单, 你要做的,就是把那份清单转写成机器能读的形式。
步骤
- 创建
/root/grpc/compat/protocheck.py,让python3 protocheck.py --dump <file.proto>逐行输出:每个消息的字段按<메시지> <번호> <타입> <이름>(占位符依次为消息、编号、类型、名称)输出,预留按<메시지> reserved <번호>(占位符依次为消息、编号)和<메시지> reserved "<이름>"(占位符依次为消息、名称)输出。忽略注释(//、/* */)、空白和选项([deprecated=true]),把9 to 11展开成三个编号,嵌套消息称作Outer.Inner。 - 给
python3 protocheck.py <old.proto> <new.proto>加入第一条规则——旧编号在新文件中不存在、也不在 reserved 中,就输出REMOVED_NOT_RESERVED <메시지>.<이름>=<번호>(占位符依次为消息、名称、编号)。有违规就 exit 1,没有就在最后一行打印OK并 exit 0。 - 如果同一个名称移到了另一个编号,就输出
RENUMBERED <메시지>.<이름> <옛번호>-><새번호>(占位符依次为消息、名称、旧编号、新编号)。 - 如果编号相同、名称相同而类型不同,就输出
TYPE_CHANGED <메시지>.<이름>=<번호> <옛타입>-><새타입>(占位符依次为消息、名称、编号、旧类型、新类型);如果编号相同而名称和类型都不同,就输出NUMBER_REUSED <메시지>#<번호> <옛이름>:<옛타입>-><새이름>:<새타입>(占位符依次为消息、编号、旧名称、旧类型、新名称、新类型)。 - 如果新文件把旧文件 reserved 的编号(包括范围)当作字段使用,就输出
REUSED_RESERVED <메시지>#<번호> <이름>(占位符依次为消息、编号、名称)。 - 如果编号相同、类型相同而只有名称不同,就输出
WARN RENAMED <메시지>#<번호> <옛이름>-><새이름>(占位符依次为消息、编号、旧名称、新名称),但不要影响退出码(只有警告时是OK和 exit 0)。 - 把
/opt/app/grpc/compat/中的 8 对全部运行一遍,以每行一条<case> OK或<case> FAIL的格式写入/root/grpc/compat/07-report.txt。 - 编写
/root/grpc/compat/check-all.sh <디렉터리>(占位符为目录)——对其下每个含有old.proto和new.proto的子目录运行检查器,输出<case>: OK/<case>: FAIL,只要有一个 FAIL 就 exit 1。
参考
- 规则的原始出处:proto3——更新消息类型、Proto 最佳实践。
- fixture:
safe_add、remove_no_reserved、renumber、type_change、reuse_reserved、rename_only、nested、safe_reserved。每个目录的new.proto注释里写着改了什么。 - 评分器除了 fixture 之外,还会临时构造指导语中没有出现的 .proto 对,送给你的脚本检查。如果把 fixture 名称或结果写死,当场就会失败。判定只依据退出码和输出。
- 规则的优先级:如果某个名称在新文件的别处以另一个编号存在,就报告 RENUMBERED,并且不再继续检查那个编号。如果编号在新文件中仍然存在,就不算 REMOVED。
- 解析器要像上一个实验的评分器那样宽容——
syntax = "proto3" ;也必须能读。
编写读取 .proto 的解析器
创建 /root/grpc/compat/protocheck.py,让 --dump <file.proto> 逐行输出:字段按 <메시지> <번호> <타입> <이름>(占位符依次为消息、编号、类型、名称)输出,预留按 <메시지> reserved <번호>(占位符依次为消息、编号)/ <메시지> reserved "<이름>"(占位符依次为消息、名称)输出。
先去掉注释(re.sub 两次),再找到 message 이름 {(占位符为名称)并数大括号来配对,然后读取里面的内容。如果里面还有 message,就递归,但名称要命名为 Outer.Inner。字段用 타입 이름 = 번호;(占位符依次为类型、名称、编号)这一条正则表达式就能捕获,前面的 optional/repeated 丢掉。
reserved 9 to 11, 15; 先用逗号拆开,如果有 to,就展开成 range。reserved "foo"; 是名称预留。
评分器除了 fixture 之外,还会送入混有注释、怪异空白、范围预留、嵌套、选项的文件。
抓住没有预留就删掉的编号
给 python3 protocheck.py <old> <new> 加入 REMOVED_NOT_RESERVED <메시지>.<이름>=<번호>(占位符依次为消息、名称、编号)规则。有违规就 exit 1,没有就在最后一行打印 OK 并 exit 0。
按消息逐个查看旧文件的编号,看它在新文件中是否存在,如果不存在,再看它是否在新文件的 reserved 中。两者都不是,就是违规。remove_no_reserved 和 nested(Customer.tier)应该失败,safe_add 和 safe_reserved 应该是 OK。
退出码:只有存在至少一个违规时才 sys.exit(1),否则 print("OK") 之后返回 0。
抓住编号被挪动的情况
加入 RENUMBERED <메시지>.<이름> <옛번호>-><새번호>(占位符依次为消息、名称、旧编号、新编号)规则。
先在新文件里建一个“名称 → 编号”的字典,一行就能搞定:旧名称在这个字典里,但编号不同,就是违规。这种情况下,要跳过针对该编号的其他检查(REMOVED 等)——一个原因输出一行比较好。
renumber fixture 应该输出 id 和 qty 两行。
抓住类型变更和编号复用
加入 TYPE_CHANGED <메시지>.<이름>=<번호> <옛타입>-><새타입>(占位符依次为消息、名称、编号、旧类型、新类型)和 NUMBER_REUSED <메시지>#<번호> <옛이름>:<옛타입>-><새이름>:<새타입>(占位符依次为消息、编号、旧名称、旧类型、新名称、新类型)规则。
同一个编号在两边都有,且类型不同时:名称相同就是 TYPE_CHANGED,名称也不同就是 NUMBER_REUSED。编号在新文件中仍然存在,所以不算 REMOVED。
嵌套消息(Order.Item)里的类型变更也必须能被抓住——如果解析器已经把名称命名成了 Outer.Inner,按消息比较的逻辑就能原样通用。
抓住取用已预留编号的情况
加入 REUSED_RESERVED <메시지>#<번호> <이름>(占位符依次为消息、编号、名称)规则。旧文件的 reserved 范围(10 to 12)也包括在内。
如果新文件的字段编号在旧文件的 reserved 集合里,就是违规。如果在第 1 步里已经把范围展开,一个 in 就够了。reuse_reserved fixture,以及评分器构造的 reserved 10 to 12 + memo = 11 这一对必须失败,而在同一个文件里使用 13 则是 OK。
改名只给警告
如果编号相同、类型相同而只有名称不同,就输出 WARN RENAMED <메시지>#<번호> <옛이름>-><새이름>(占位符依次为消息、编号、旧名称、新名称),但不要影响退出码。只有警告时是 OK 和 exit 0。
把警告和错误收集到不同的列表里,退出码只看错误列表。警告比错误先输出,没有错误时最后输出 OK。rename_only 是一行 WARN 加 OK;评分器构造的“改名 + 改类型”这一对,会同时出现 WARN 和 TYPE_CHANGED,并且 exit 1。
把 8 对 fixture 全部运行一遍
把 /opt/app/grpc/compat/ 中的 8 对全部运行一遍,以每行一条 <case> OK 或 <case> FAIL 的格式写入 /root/grpc/compat/07-report.txt。
用 for d in /opt/app/grpc/compat/*/; do ...; done 运行,并用退出码来决定 OK/FAIL。不要手写——评分器会重新运行你的脚本,把结果与报告对照,还会与真正的答案对照。三者都相同才算通过。
按目录运行的检查脚本
编写 /root/grpc/compat/check-all.sh <디렉터리>(占位符为目录)——对每个含有 old.proto 和 new.proto 的子目录运行检查器,输出 <case>: OK / <case>: FAIL,只要有一个 FAIL 就 exit 1。
遍历作为参数传入的目录,但只看两个文件都有的子目录(文件和空目录要跳过)。统计失败的数量,最后 exit。评分器除了 fixture 目录之外,还会创建两个名称不同的临时目录来运行——如果把路径写死,就会失败。
如果以这个脚本所在的目录为基准来确定 protocheck.py 的位置($(dirname "$0")),无论从哪里调用都可以。