每天写 gRPC 接口,stub.search(request) 一行搞定。gRPC 帮你把对象变成字节、从字节变回对象,全程不用操心。
但总有些时候你得操心。
抓包看到一串 hex,想知道里面是什么。排查 RPC 丢字段,需要确认 wire 上到底传了什么。做网关或协议转换,得在没有生成类的前提下读懂 protobuf 字节。
这些场景都指向同一个问题:gRPC 请求变成字节后,到底长什么样?
先给一个反常识的事实:同一个值 -1,如果字段类型是 int32,序列化后占 10 个字节;换成 sint32,只占 1 个字节。差 10 倍。
这篇文章会带你逐字节拆一段真实的 gRPC 请求,看清 wire format 的全貌。

1. 一个贯穿全文的 gRPC 请求

先看 .proto
9 个字段,覆盖了 protobuf 里几乎所有常见类型。
填上值:query = "protobuf wire format"page_number = 2result_per_page = 50score_delta = -5ratio = 0.85weight = 1.5verbose = truefilters = [10, 20, 30]page = {offset: 40, limit: 20}
用生成类序列化:
出来 55 个字节:
这就是 gRPC 传输时 wire 上真正跑的东西。下面逐字节拆。

2. wire format:字节流里到底存了什么

2.1 Tag:字段号 + wire type

JSON 是自描述的。{"pageNumber": 2} 这一串,光 "pageNumber" 这个 key 就十几个字节,真正的值只占一个字符。字段名跟着数据走,传一万次就重复一万次。
protobuf 不这么干。它把字段名留在 .proto 里,wire 上只放一个编号。
每个字段在字节流里就是两段:
tag 把两件事压进一个 varint:
field_number 是你在 .proto 里写的 = 1= 2wire_type 占低 3 位,告诉解码器"接下来该怎么读这个值"。
常用的 wire type 就 4 种:
wire type
编号
谁用它
VARINT
0
int32 / int64 / uint / bool / enum / sint(ZigZag 后)
I64
1
fixed64 / sfixed64 / double
LEN
2
string / bytes / 嵌套 message / packed repeated
I32
5
fixed32 / sfixed32 / float
术语说明:protobuf 官方文档现在叫 VARINT / I64 / LEN / I32,早几年叫 Varint / 64-bit / Length-delimited / 32-bit。同一回事,新旧叫法。
notion image

2.2 Varint:用多少字节,值说了算

Varint 是 protobuf 最基础的编码方式。每字节低 7 位存数据,最高位(MSB)是延续位:1 表示后面还有字节,0 表示到此为止。
小数字只占 1 字节。
标本里 page_number = 2 只要 2 字节:
10 是 tag:0x10 = 0b0001_0000,低 3 位 000 是 wire type 0(VARINT),其余 00010 就是字段号 2。算一下:(2 << 3) | 0 = 16 = 0x10
02 是值。varint 最高位是 0,直接就是 2。完事。
再看个大数字。假设值是 150,varint 需要 2 字节:
拆开看:
一个字段名都没出现。这就是 protobuf 省的地方。
notion image

2.3 ZigZag:为什么 sint 存负数更小

回到开头那个反常识问题。int32 存 -1 时,protobuf 先符号扩展到 64 位再做 varint。-1 的 64 位补码全是 1,varint 编出来就是 10 个字节。
sint32 先做 ZigZag 变换,把小的负数映射成小的正数:
标本里 score_delta = -5,字节是:
20 是 tag:(4 << 3) | 0 = 32 = 0x20,字段 4、VARINT。09 就是 ZigZag 后的 9,还原回去是 -5。1 字节搞定。
结论:经常出现负数的字段,用 sint32 / sint64

2.4 Length-delimited:字符串和嵌套

wire type 2 的格式是 tag + 长度(varint) + 内容。看 query = "protobuf wire format"
0a 是 tag:(1 << 3) | 2 = 10 = 0x0a,字段 1、LEN。14 是长度 20 的 varint。后面 20 字节是 UTF-8 的 "protobuf wire format"
stringbytes、嵌套 messagepacked repeated 都用 LEN。解码器靠长度前缀知道该读多少。

2.5 float / double 是位模式

字段 5 ratio = 0.85,字节是:
29 是 tag:(5 << 3) | 1 = 41 = 0x29,字段 5、I64(64 位定长)。后面 8 字节不是把 0.85 当整数存,是把它的 IEEE 754 位模式小端存进去。Java 里就是 Double.doubleToLongBits(0.85)
float 同理,字段 6 weight = 1.5
I32,4 字节。Float.floatToIntBits(1.5f) 的小端表示。
解码时忘了位模式转换,直接把字节当整数读,出来就是天文数字。

2.6 完整字节对照表

55 字节,全貌:
字节
tag
字段
wire type
0a 14 + 20 字节
0a
1
LEN
"protobuf wire format"
10 02
10
2
VARINT
2
18 32
18
3
VARINT
50
20 09
20
4
VARINT
9(ZigZag → -5)
29 + 8 字节
29
5
I64
0.85(double 位模式)
35 + 4 字节
35
6
I32
1.5(float 位模式)
38 01
38
7
VARINT
1(true)
42 03 0a 14 1e
42
8
LEN
packed [10, 20, 30]
4a 04 08 28 10 14
4a
9
LEN
嵌套 {offset:40, limit:20}
拎几个细节出来说。
packed。 字段 8 repeated int32 filters = [10, 20, 30]。proto3 里标量 repeated 默认 packed:不给每个元素单独写 tag,而是把三个值的 varint 拼在一起(0a 14 1e,就是 10、20、30),整体当一个 LEN。三个元素省两个 tag。
嵌套 message。 字段 9 page 是个子消息。wire 上就是一个 LEN:长度 04,后面 08 28 10 14 是子消息完整序列化的结果。08 28 是子消息字段 1 = 40(0x28 = 40),10 14 是字段 2 = 20。嵌套就是「一段 protobuf 塞进另一段 protobuf 的某个字段里」。
notion image

3. proto3 的几条重要规则

3.1 默认值不上线

proto3 最反直觉的一点:字段值等于默认值时,不会出现在字节流里。
数值默认 0、字符串默认 ""、bool 默认 false。解码端 getPageNumber() 返回 0,你分不清是"没设"还是"设了 0"。

3.2 optional 找回存在性

如果确实需要区分"0"和"没设",用 optional
optional 字段即使被显式设成默认值也会被序列化,因为运行时单独跟踪了 presence。代价是生成代码里多一套 hasXxx()。wire 上的编码格式和普通字段一模一样。

3.3 repeated 标量默认 packed

前面表里 filters = [10, 20, 30] 只用了一个 tag + 一段长度前缀的连续 payload。这是 proto3 对标量 repeated 默认开的 packed 编码。
不 packed 的话,每个元素都要写一遍 tag。元素越多省得越多。

3.4 两条补充

  • proto3 没有 required。历史教训:required 是兼容性灾难。
  • enum 收到未知数值时不报错,原样保留为 int,保证前向兼容。

4. Java 引擎:protoc 生成的代码到底干了什么

4.1 不可变 message + Builder

protoc 生成的 SearchRequest 是不可变的。所有字段只读,构造只能通过 SearchRequest.Builder
好处是线程安全,可以放心共享。想改就 request.toBuilder() 拿一个新 Builder。

4.2 编码:CodedOutputStream

toByteArray() 底层走的是 CodedOutputStream,负责把每个字段写成 tag + value:
序列化前框架先算一遍 getSerializedSize() 并缓存,好一次性分配够大的 buffer。

4.3 解码:CodedInputStream

parseFrom(byte[]) 底层是 CodedInputStream,核心是一个按 tag 循环 dispatch 的状态机:
switch 里的数字就是提前算好的 tag 值。字段 1 的 string → (1 << 3) | 2 = 10,字段 2 的 int32 → (2 << 3) | 0 = 16
这段代码虽然是 protoc 生成的,但和你拿着前面那张表手工对字节没有本质区别。

5. gRPC 编解码路径:就是标准 protobuf

用 gRPC 的时候,你可能会想:gRPC 的 marshaller 编出来的字节,和直接调 toByteArray() 一样吗?
我在 protobuf-codec-demo 工程里做了三方对比:
三者完全一致。生成类 toByteArray()、gRPC ProtoUtils.marshaller()、甚至用 UnknownFieldSet 手搓的——编出来的字节一模一样。
又用 DynamicMessage(只有 Descriptor 没有生成类)做了同样的对比,字节也完全一致。
gRPC 在 HTTP/2 帧层有 5 字节的 framing header(1 字节压缩标记 + 4 字节消息长度),但 message body 就是标准 protobuf 二进制。
结论:搞清楚 protobuf 的 wire format,就搞清楚了 gRPC 的 wire。
notion image

6. 兼容性:加了新字段,旧代码不会坏

protobuf 的演进规则靠字段编号,不是字段名。
  • 加字段:用新编号。旧代码不认识,但不会报错。
  • 删字段:别删编号、别复用编号。用 reserved 锁住。
  • 改类型:只在 wire 兼容的类型间安全(如 int32 ↔ int64),否则读出乱码。
旧代码遇到不认识的字段不会丢弃,而是塞进 UnknownFieldSet,再序列化时原样写回:
这个机制在微服务网关和中间代理场景特别重要:中间层不需要知道所有字段的 schema,也能保证数据不丢。

7. 收个尾

几条工程规约留着速查:
  • 高频字段用 1~15 号。这些编号的 tag 只占 1 字节,编号 ≥ 16 起 tag 要 2 字节。
  • 绝不复用已删字段编号,用 reserved 锁住。
  • 常含负数的字段用 sint32 / sint64。差别真的很大。
  • 需要区分"未设置 vs 默认值"时才上 optional
验证工具推荐:protoc --decode_raw < data.bin,无需 .proto 就能反解字节,核对你的理解。
这篇把 protobuf 编解码的原理和 Java 引擎讲清楚了。但如果当你拿到一段 protobuf 字节, .proto 不在你手上,这时候怎么办?
我们下篇见~
 
没有 .proto,我照样把这段 protobuf 解开了一条配置查询,搞瘫了整个服务
Loading...