TT Lab
开始
学习 学习路径 课程

改了字段编号,旧客户端悄悄读出了错误的值

构建契约检查器

在 TT Lab 中继续学习

目标

编写 protocheck.py,比较两个 .proto,逐行输出 wire 兼容性的违规项。 逐条添加规则,让它通过 8 对 fixture 和评分器暗中准备的那几对, 最后编写按目录运行的 check-all.sh,使它成为可以放进 CI 的形态。

为什么重要

在上一个实验中,你已经看到,只要改动一个编号,旧客户端就会在没有任何错误的情况下读出错误的值。 这种改动在代码评审中很难被注意到——diff 里只是两个数字 变了,编译和测试也都能通过。这不是一种人每次都能抓住的 失误。所以契约检查应该由脚本来做,而不是由评审者来做。 检查器的规则全部来自官方文档的“安全的变更、不安全的变更”清单, 你要做的,就是把那份清单转写成机器能读的形式。

步骤

  1. 创建 /root/grpc/compat/protocheck.py,让 python3 protocheck.py --dump <file.proto> 逐行输出:每个消息的字段按 <메시지> <번호> <타입> <이름>(占位符依次为消息、编号、类型、名称)输出,预留按 <메시지> reserved <번호>(占位符依次为消息、编号)和 <메시지> reserved "<이름>"(占位符依次为消息、名称)输出。忽略注释(//、/* */)、空白和选项([deprecated=true]),把 9 to 11 展开成三个编号,嵌套消息称作 Outer.Inner。
  2. 给 python3 protocheck.py <old.proto> <new.proto> 加入第一条规则——旧编号在新文件中不存在、也不在 reserved 中,就输出 REMOVED_NOT_RESERVED <메시지>.<이름>=<번호>(占位符依次为消息、名称、编号)。有违规就 exit 1,没有就在最后一行打印 OK 并 exit 0。
  3. 如果同一个名称移到了另一个编号,就输出 RENUMBERED <메시지>.<이름> <옛번호>-><새번호>(占位符依次为消息、名称、旧编号、新编号)。
  4. 如果编号相同、名称相同而类型不同,就输出 TYPE_CHANGED <메시지>.<이름>=<번호> <옛타입>-><새타입>(占位符依次为消息、名称、编号、旧类型、新类型);如果编号相同而名称和类型都不同,就输出 NUMBER_REUSED <메시지>#<번호> <옛이름>:<옛타입>-><새이름>:<새타입>(占位符依次为消息、编号、旧名称、旧类型、新名称、新类型)。
  5. 如果新文件把旧文件 reserved 的编号(包括范围)当作字段使用,就输出 REUSED_RESERVED <메시지>#<번호> <이름>(占位符依次为消息、编号、名称)。
  6. 如果编号相同、类型相同而只有名称不同,就输出 WARN RENAMED <메시지>#<번호> <옛이름>-><새이름>(占位符依次为消息、编号、旧名称、新名称),但不要影响退出码(只有警告时是 OK 和 exit 0)。
  7. 把 /opt/app/grpc/compat/ 中的 8 对全部运行一遍,以每行一条 <case> OK 或 <case> FAIL 的格式写入 /root/grpc/compat/07-report.txt。
  8. 编写 /root/grpc/compat/check-all.sh <디렉터리>(占位符为目录)——对其下每个含有 old.proto 和 new.proto 的子目录运行检查器,输出 <case>: OK / <case>: FAIL,只要有一个 FAIL 就 exit 1。

参考

编写读取 .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")),无论从哪里调用都可以。