在旧客户端面前修改 schema
目标
保持把 v1 schema 写死在代码里的“旧客户端”不动,把 schema 改成 v2。
用新编号添加新字段,用 reserved 封存已删除的字段,并重现旧客户端如何在没有任何错误的情况下,
把故意修改了编号的字节读错。之后实测默认值与 presence 的陷阱,
最后留下带有 optional 和 reserved 的最终
schema 以及兼容性表。
为什么重要
客户端和服务器永远不会在同一个时刻部署。有回滚, 日志里也留着旧字节。所以 schema 变更是否安全,不应该看“能否通过编译”, 而应该看“旧二进制能否正确读取新字节,新二进制能否正确读取旧字节”。 wire 里没有名称,只有编号,所以修改编号, 就等于删除字段再新建一个——然而解析器不会报任何错误, 而是把被改了编号的值按旧名称读出来。这个实验的目的,就是重现这起事件,把它刻进记忆里。
步骤
- 运行
python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.bin,把输出的四行原样保存到/root/grpc/evolve/01-old.txt。 - 编写
/root/grpc/evolve/v2.proto。以syntax = "proto3";开头,在message Order中保持 v1 的int32 id = 1; int32 qty = 2; string note = 3;不变,并用新编号添加int64 unit_price和string currency。 - 在 v2.proto 中删除
note,并留下reserved 3;和reserved "note";。其余保持不变。 - 在
/root/grpc/evolve/make_v2.py中,用/opt/app/grpc/pbmini.py,按 v2.proto 中的编号对id=9001, qty=2, unit_price=15000, currency="KRW"编码,保存到/root/grpc/evolve/order_v2.bin,并把用旧客户端读取该文件的输出保存到/root/grpc/evolve/04-old-reads-v2.txt。 /opt/app/grpc/renumbered.bin是用/opt/app/grpc/renumbered.proto(重新编号后的 schema)生成的同一个订单。把用旧客户端读取的输出保存到/root/grpc/evolve/05-renumbered.txt,并在其下面写两行:truth: id=<진짜 id> qty=<진짜 qty>(占位符依次为真实的 id 与真实的 qty)和why: <왜 오류 없이 틀린 값이 나왔는지>(占位符为没有报错却得出错误值的原因)。- 用 pbmini 以两种方式生成
id=9002, currency="KRW"且qty=0的订单。/root/grpc/evolve/06-zero.bin是隐式 presence(为 0 时不发送),/root/grpc/evolve/06-zero-explicit.bin是显式 presence(0 也发送)。在/root/grpc/evolve/06-presence.txt中写四行:implicit=<16진수>、explicit=<16진수>、implicit_qty=<옛 클라이언트가 읽은 qty>、explicit_qty=<옛 클라이언트가 읽은 qty>(占位符依次为十六进制、十六进制、旧客户端读到的 qty、旧客户端读到的 qty)。 - 编写最终的
/root/grpc/evolve/Order.proto。其中要有optional int32 qty = 2;、reserved 3;+reserved "note";、unit_price和currency,以及message Customer { string name = 1; int32 tier = 2; }和 Order 中的Customer customer = <새 번호>;(占位符为新编号)。 - 在
/root/grpc/evolve/compat.md中写一个 Markdown 表格。第一列是add-new-number、remove-and-reserve、reuse-number、renumber、int32-to-string、rename-only这六个键,第二列是safe或unsafe,第三列是一行理由。
参考
- 规则的原始出处:proto3 语言指南——更新消息类型、字段 presence。
old_client.py是把 v1 schema 写死在代码里的程序。输出是id=、qty=、note=、unknown=四行。在第 6 步中,分别用它读取这两个文件。- pbmini 用法:
import sys; sys.path.insert(0, "/opt/app/grpc"); import pbmini as pb; pb.encode_message([(1, "int32", 9001), (2, "int32", 2)])。 - 评分器读取 .proto 时比较宽松(注释、空白随意)。但是编号复用、缺少 reserved、类型变更,一定会被判为失败。
- 第 8 步中的
rename-only是以二进制 wire 为准的——wire 里没有名称,所以只改名称不会影响字节(在 JSON、文本格式中则不同)。
用旧客户端读取 v1 字节
把 python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.bin 输出的四行原样保存到 /root/grpc/evolve/01-old.txt。
先执行 mkdir -p /root/grpc/evolve,再用 > 保存输出就可以了。旧客户端把字段 1 当作 id、2 当作 qty、3 当作 note 来读,其他编号则写进 unknown。不要手写,保存输出就行——评分器会重新运行同一条命令来对比。
新字段用新编号
在 /root/grpc/evolve/v2.proto 中保持 v1 的三个字段不变,并用新编号添加 int64 unit_price 和 string currency。
原有字段的编号、名称、类型一个字都不能改。新字段使用至今没有被用过的编号——4 和 5 比较自然,但用 6 和 7 也可以。19,000–19,999 是实现保留的区间,不能使用;1–15 的 tag 只占一个字节,适合常用字段。
评分器会查找 syntax = "proto3"; 和 message Order { ... },并检查字段 1、2 是否与 v1 相同,unit_price 是否为 int64、currency 是否为 string,以及它们的编号是否不是 1、2、3。
封存已删除的编号
在 v2.proto 中删除 note,并留下 reserved 3; 和 reserved "note";。
删除本身对 wire 是安全的。危险在于之后有人再次使用 3,而 reserved 就是让 protoc 把这种情况拦成编译错误的装置。编号和名称不能混在一条语句里,所以分成两行写。
reserved 3; 放在 message 块内的任何位置都可以。
旧客户端读取 v2 字节
在 /root/grpc/evolve/make_v2.py 中,用 pbmini 按 v2.proto 中的编号对 id=9001, qty=2, unit_price=15000, currency="KRW" 编码,保存到 /root/grpc/evolve/order_v2.bin,并把用旧客户端读取的输出保存到 /root/grpc/evolve/04-old-reads-v2.txt。
像 pb.encode_message([(1, "int32", 9001), (2, "int32", 2), (4, "int64", 15000), (5, "string", "KRW")]) 这样,直接使用在 v2.proto 中选定的编号。note(3) 是已删除的字段,所以不要放进去。
旧客户端不认识 4 和 5,但可以通过 wire type 得知长度并跳过,同时正确读出 id 和 qty。unknown= 那一行应该能看到这两个编号。
旧客户端读取被修改了编号的字节
把用旧客户端读取 /opt/app/grpc/renumbered.bin 的输出保存到 /root/grpc/evolve/05-renumbered.txt,并追加 truth: id=<진짜> qty=<진짜> 和 why: <이유> 两行(占位符依次为真实的 id 与 qty、原因)。
先读一读 /opt/app/grpc/renumbered.proto——有人重新编了号。renumbered.bin 就是把同一个订单(与 order_v1.bin 的值相同)按那份 schema 序列化的结果。
旧客户端仍然把编号 1 当作 id。wire type 相同(都是 VARINT),所以没有任何错误和警告,值就被换着读出来了。truth 那一行是按 renumbered.proto 读出的真实值。
qty=0 算是发送了,还是没发送?
把 id=9002, currency="KRW", qty=0 的订单分别做成 /root/grpc/evolve/06-zero.bin(隐式 presence,0 不发送)和 /root/grpc/evolve/06-zero-explicit.bin(显式 presence,0 也发送),并在 /root/grpc/evolve/06-presence.txt 中写 implicit=、explicit=、implicit_qty=、explicit_qty= 四行。
proto3 中没有标签的标量不会序列化默认值(0、"")。所以隐式一侧完全不放字段 2 的记录,而显式(optional)一侧则放入 (2, "int32", 0)——会多带上 10 00 两个字节。
分别用旧客户端读取这两个文件,并写下 qty。两者读出来都是 0——旧客户端无法区分“收到了 0”和“什么都没收到”。这就是需要 optional 的原因。
带有 optional 和 reserved 的最终 schema
编写 /root/grpc/evolve/Order.proto——optional int32 qty = 2;、reserved 3; + reserved "note";、int64 unit_price 和 string currency、message Customer { string name = 1; int32 tier = 2; },以及 Order 中的 Customer customer = <새 번호>;(占位符为新编号)。
加上 optional 标签,qty 就有了 explicit presence,可以区分发送了 0 和没发送。在二进制 wire 中,加标签本身不会改变字节(只是会把 0 发送出去)。
Customer 单独作为一个 message,在 Order 中当作类型来用。customer 的编号必须是至今没用过、也不是 reserved 的编号。
编写兼容性表
在 /root/grpc/evolve/compat.md 中写一个 Markdown 表格。第一列是 add-new-number、remove-and-reserve、reuse-number、renumber、int32-to-string、rename-only,第二列是 safe/unsafe,第三列是理由。
这一步是整理在本实验中亲眼所见的东西。第 4 步(用新编号添加)、第 3 步(删除并 reserved)、第 5 步(修改编号)分别是一行的依据。类型变更如果改变了 wire type(int32 VARINT → string LEN),旧解析器就会把值读成另一种含义。只改名称,在二进制 wire 中没有名称,所以字节相同。
判定词只写 safe 或 unsafe 中的一个——评分器看的是每行中第一个出现的判定词。