grpc|protobuf编码
特别提醒,本文所涉及的源码是
go1.22.4 darwin/amd64
gRPC
使用protobuf
作为接口定义语言(IDL
),包括定义服务的方法以及通过网络发送的消息。如下代码所示:
1 | service Greeter { |
上面的proto
文件中定义了一个服务叫做Greeter
,其中包含一个方法名是SayHello
,请求的参数为HelloRequest
类型,返回的响应为HelloReply
类型。
protobuf是如何将上面的消息编码为二进制的呢?
请求参数和响应参数中都会包含若干个字段,这些字段被组成下图所示的样子:
如果是json
编码的话,那其中的标签就是对应的字段名,比如上面的name
和message
,不过这种编码方式不高效,因此protobuf
中没有采用这种,而是使用序号+线路类型(wire type)来组合表示标签。其中线路类型有如下6种:
ID | 线路类型 | 字段类型 |
---|---|---|
0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | fixed64, sfixed64, double |
2 | LEN | string, bytes, embedded messages, packed repeated fields |
3 | SGROUP | group start (deprecated) |
4 | EGROUP | group end (deprecated) |
5 | I32 | fixed32, sfixed32, float |
上述线路类型总共有6种,因此需要3个bit
位来唯一标识,因此protobuf
中的标签由如下组合成:
1 | Tag = (filed_num << 3) | wire_type |
1 Varint类型
varint
是一种可变长度整数类型,为每个整数所分配的字节并不是确定的4字节或者8字节,而是依赖于具体的值。下面具体介绍varint
类型的原理:
- 将原先的8
bit
一位拆分为两种,最高位用来标识是否后面还有更多的数据,如果是则该位置为1
,如果不是则该位置为0
,后7位用来存储数据。
例如:对于一个整数228,它的二进制表示为11100100
。首先将其每7位拆分为一组则得到1
和1100100
,用varint
编码则会表示为00000001
和11100100
,如果该整数是用32位来编码的话则节省了两个字节,如果是64位编码的话,则节省了6个字节。
go标准库中的varint编码跟protobuf几乎一样,看看go是如何实现的
1 | func PutUvarint(buf []byte, x uint64) int { |
- 如果
x
$\ge 0x80$,则说明数据超过7位,需要拆分,并且最高位置为1 - 右移继续判断下一个7位
该编码有一个问题,当整数非常大的时候会是一个负优化。例如对于uint64
的最大值即64个1来说,用varint
编码的话,最后需要10个字节来表示。因此这就引出了下一个问题,负数如果用varint
编码的话就会非常浪费,因为在计算机中是用补码来表示的,-5
的二进制形式是11111111111111111111111111111011
,但它的绝对值其实是101
而已。因此针对负数,采用了zigzag
编码。
zigzag
的原理也非常简单:
- 对于正数
n
来说,首先将其映射成2n
,然后再使用varint
编码 - 对于负数
n
来说,首先将其映射成2*|n|-1
,然后再使用varint
编码
这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。
同时,负数被转换成正数之后,二进制编码也精简了许多。
例如: 对 -5
进行 zigzag
编码后,变成了9
,对应于二进制为 00000000000000000000000000001001
,使用 1 个字节即可表示 。
至于具体的映射实现有两种方式,一种是protobuf
给出的,一种是在go
源码中看到的:
1.protobuf中给出的
1 | 32位执行这个 |
2.go源码中给出的
1 | func PutVarint(buf []byte, x int64) int { |
即转换成无符号数向左移一位,如果x
是负数则取反。
按理来说应该是protobuf
更快一些的,因为没有条件判断语句,实际情况就不清楚了。
对于线路类型为varint
的字段类型来说,sint32
和sint64
首先使用了zigzag
进行映射,然后再使用varint
编码,其它的字段类型则直接使用varint
编码。
2 固定长度类型
一个用来表示 64 位的数据类型,如fixed64、sfixed64 和 double;另一个用来表示 32 位的数据类型,如 fixed32、sfixed32 和 float。
3 LEN
对于使用按长度分割的类型,即string, bytes, embedded messages, packed repeated fields。这意味着首先会有一个经过 Varint 编码的长度值,随后才是指定数量的字节数据。字符串值会使用 UTF-8字符编码格式来进行编码。