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

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

亲手读写字节

在 TT Lab 中继续学习

目标

在既没有 protoc 也没有 protobuf 包的 Pod 里,只用 Python 标准库,亲手构建 protobuf wire format 的编码器和解码器。按 varint → tag → 有符号整数 → 字段 → 消息的顺序一层层搭起来,最后解码子消息和 packed repeated。 做出来的工具在下一个实验中还会用到。

为什么重要

gRPC 故障有一半是因为不知道“字节里没有名称”这件事。wire 上只携带字段编号和 wire type,名称和声明类型是由读取方的 .proto 补上的。亲手编码一遍,就能通过计算看出: 为什么不能改编号,为什么 int32 的负数要占 10 个字节, 为什么旧客户端能毫无错误地跳过新字段。亲手打开过库所隐藏的东西的人, 在 schema 评审时看到的是不一样的东西。

步骤

  1. 创建 /root/grpc/pb.py,实现 encode_varint(n) -> bytes 和 decode_varint(data, pos=0) -> (값, 다음 위치)(占位符依次为值与下一个位置)。把 1、150、300、16384 编码后的十六进制,以 값=16진수(占位符依次为值与十六进制)的格式逐行写入 /root/grpc/01-varint.txt。
  2. 加入 encode_tag(field, wire_type) -> bytes 和 decode_tag(data, pos=0) -> (번호, 와이어타입, 다음 위치)(占位符依次为编号、wire type 与下一个位置)。把 1:0、2:2、3:2、16:0 这四个 tag 的十六进制,以 번호:와이어타입=16진수(占位符依次为编号、wire type 与十六进制)的格式写入 /root/grpc/02-tag.txt。
  3. 加入 zigzag_encode(n) -> int 和 zigzag_decode(u) -> int,实际编码 int32 -1、sint32 -1、int32 -150、sint32 -150,并以 타입 값 = 16진수 (N bytes)(占位符依次为类型、值与十六进制)的格式写入 /root/grpc/03-signed.txt。int32 把 64 位补码作为 varint 发送,sint32 则把经过 ZigZag 的值作为 varint 发送。
  4. 加入 encode_field(field, kind, value) -> bytes。kind 为 int32 int64 uint32 uint64 sint32 sint64 bool string bytes。编写 /root/grpc/04-make.py,用这个函数生成 id(1)=4242, qty(2)=17, note(3)="한글 메모"(韩文,意为“韩文备注”)的订单,运行后留下 /root/grpc/04-order.bin。
  5. 加入 decode_message(data) -> [(번호, 와이어타입, 원시값), ...](占位符依次为编号、wire type 与原始值)。VARINT 是 int,LEN 是 bytes,I32/I64 是原样的 4 字节、8 字节 bytes。解码 /opt/app/grpc/order_v1.bin,以 field=N wire=W value=V 的格式逐行写入 /root/grpc/05-decode.txt(LEN 的值用十六进制)。
  6. 加入 encode_records(records) -> bytes(decode_message 的逆函数)。解码 /opt/app/grpc/order_v2.bin,对每条记录判断它的编号是否为 v1 schema(/opt/app/grpc/order_v1.proto)所认识的,并以 field=N wire=W known 或 unknown 的格式写入 /root/grpc/06-unknown.txt。
  7. /opt/app/grpc/nested.bin 的字段 6 是 Customer 子消息。用 decode_message 把它的载荷再解码一遍,并在 /root/grpc/07-nested.txt 中写三行:customer.bytes=16진수、customer.name=이름、customer.tier=숫자(占位符依次为十六进制、名称与数字)。
  8. 加入 decode_packed_varints(raw) -> [int, ...],解开 /opt/app/grpc/packed.bin 的字段 7(packed repeated int32),并在 /root/grpc/08-packed.txt 中写三行:tags=쉼표목록、packed_record=16진수(필드 7 레코드 전체)、expanded_record=16진수(원소마다 태그를 붙인 형태)(占位符依次为以逗号分隔的列表、字段 7 整条记录的十六进制、给每个元素都加上 tag 的形式的十六进制)。

参考

构建并读取 varint

在 /root/grpc/pb.py 中实现 encode_varint(n) 和 decode_varint(data, pos=0),并把 1、150、300、16384 的十六进制,以 값=16진수(占位符依次为值与十六进制)的格式写入 /root/grpc/01-varint.txt。

把值按每 7 位切开,从低位开始依次输出,如果后面还有,就把该字节的最高位(0x80)置 1。150 的二进制是 10010110 → 在低 7 位 0010110 上加上“后面还有”的标记,得到 0x96,剩下的 1 是 0x01——所以是 96 01。

decode_varint 要从 pos 开始读,并返回 (값, 다음 위치)(占位符依次为值与下一个位置)。评分器也会用 pos=3 这样的中间位置来调用它。

验算:python3 -c "import sys; sys.path.insert(0,'/root/grpc'); import pb; print(pb.encode_varint(300).hex())" 的结果应该是 ac02。

让 tag 携带编号和 wire type

在 /root/grpc/pb.py 中加入 encode_tag(field, wire_type) 和 decode_tag(data, pos=0),并把 1:0、2:2、3:2、16:0 的十六进制,以 번호:와이어타입=16진수(占位符依次为编号、wire type 与十六进制)的格式写入 /root/grpc/02-tag.txt。

tag 不过就是一个 varint——把 (field << 3) | wire_type 交给 encode_varint 就完事了。读取时,先读出 varint,低 3 位是 wire type,其余部分右移 3 位就是编号。

编号 1–15 的 tag 是一个字节,从 16 起是两个字节。文档建议给常用字段分配较小的编号,原因就在这里。

(1 << 3) | 0 是 8,所以是 08;(2 << 3) | 2 是 18,所以是 12。

负的 int32 是 10 个字节,sint32 是一两个字节

加入 zigzag_encode(n) 和 zigzag_decode(u),实际编码 int32 -1、sint32 -1、int32 -150、sint32 -150,并以 타입 값 = 16진수 (N bytes)(占位符依次为类型、值与十六进制)的格式写入 /root/grpc/03-signed.txt。

int32 的负数是把 64 位补码视为 unsigned,再作为 varint 发送——在 Python 中,把 n & 0xFFFFFFFFFFFFFFFF 交给 encode_varint 即可。最高位是置位的,所以十个字节都要用上。

sint32 要先经过 ZigZag。文档中的公式是 (n << 1) ^ (n >> 31),而 Python 的整数是算术移位,所以直接用这个公式,就会得到 -1 → 1、1 → 2、-2 → 3。还原的公式是 (u >> 1) ^ -(u & 1)。

行的格式例如 sint32 -1 = 01 (1 bytes)。

把一个字段做成记录

加入 encode_field(field, kind, value)(kind:int32 int64 uint32 uint64 sint32 sint64 bool string bytes)。在 /root/grpc/04-make.py 中用这个函数,把 id(1)=4242, qty(2)=17, note(3)="한글 메모"(韩文,意为“韩文备注”)接在一起,保存为 /root/grpc/04-order.bin。

记录就是 tag + 值。varint 类用 encode_tag(f, 0) + encode_varint(값)(占位符为值),string 用 encode_tag(f, 2) + encode_varint(len(raw)) + raw,其中 raw 是以 UTF-8 编码的 bytes,长度也是它的字节数。韩文备注虽然只有 5 个字符,却有 13 个字节。

int32/int64 的负数像第 3 步那样用 64 位掩码,sint 类用 ZigZag(64 位用 n >> 63),bool 是 0 或 1。

消息就是把记录直接接在一起——既没有分隔符,也没有头部。

把字节还原成记录列表

加入 decode_message(data),使其返回 [(번호, 와이어타입, 원시값), ...](占位符依次为编号、wire type 与原始值)。解码 /opt/app/grpc/order_v1.bin,并以 field=N wire=W value=V 的格式写入 /root/grpc/05-decode.txt(LEN 的值用十六进制)。

循环到结束为止:先读 tag,再按 wire type 读值。0 就是 varint,2 是长度 varint 加上相应数量的 bytes,5 是 4 个字节,1 是 8 个字节。原始值不做解释——因为在这里既不知道名称,也不知道声明类型。

如果这个函数没有正确地移动 pos,就会从错误的位置读下一个 tag。评分器会用 12 条随机消息来测试。

fixture 确认:python3 -c "print(open('/opt/app/grpc/order_v1.bin','rb').read().hex())"。

跳过不认识的字段,但不能丢掉它们

加入 encode_records(records)(decode_message 的逆函数)。解码 /opt/app/grpc/order_v2.bin,对每条记录判断它的编号是否为 v1 schema(/opt/app/grpc/order_v1.proto)所认识的,并以 field=N wire=W known 或 unknown 的格式写入 /root/grpc/06-unknown.txt。

旧解析器之所以能毫无错误地跳过新字段,是因为 wire type 告知了长度。而且 proto3 解析器会把那些不认识的字段保留下来,在重新序列化时包含进去——encode_records 做的就是这件事:对每条记录重新写出 tag,VARINT 写 varint,LEN 写长度加 bytes,I32/I64 则原样写 bytes。

评分器会让随机消息经过你的 decode_message → encode_records 往返,看字节是否与原文相同。

v1 认识的编号只有 1、2、3。

子消息就是 LEN 里面的另一条消息

把 /opt/app/grpc/nested.bin 的字段 6(Customer 子消息)的载荷再用 decode_message 解码,并在 /root/grpc/07-nested.txt 中写三行:customer.bytes=16진수、customer.name=이름、customer.tier=숫자(占位符依次为十六进制、名称与数字)。

子消息与 string 一样,都是 LEN 记录——只不过它的 bytes 本身又是一条消息,所以用同一个函数再解一遍就行。看一下 schema(/opt/app/grpc/nested.proto),Customer 是 string name = 1; int32 tier = 2;。

customer.bytes 是对字段 6 的原始值(去掉 tag 和长度之后的载荷)执行 .hex() 的结果。

解开 packed repeated

加入 decode_packed_varints(raw),解开 /opt/app/grpc/packed.bin 的字段 7(packed repeated int32),并在 /root/grpc/08-packed.txt 中写三行:tags=쉼표목록、packed_record=16진수、expanded_record=16진수(占位符依次为以逗号分隔的列表、十六进制与十六进制)。

packed 就是在只有一个 tag 的 LEN 记录里,varint 一个接一个连在一起。只要重复调用 decode_varint 直到载荷结束,就能得到列表。

packed_record 是 packed.bin 中字段 7 整条记录(tag + 长度 + 载荷)的十六进制,expanded_record 是把同一个列表的每个元素用 encode_field(7, "int32", v) 生成再接在一起的十六进制。比较一下两者的长度——就能看出 tag 的开销减少了多少。