特别提醒,本文所涉及的源码是go1.22.4 darwin/amd64

gRPC使用protobuf作为接口定义语言(IDL),包括定义服务的方法以及通过网络发送的消息。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

上面的proto文件中定义了一个服务叫做Greeter,其中包含一个方法名是SayHello,请求的参数为HelloRequest类型,返回的响应为HelloReply类型。

protobuf是如何将上面的消息编码为二进制的呢?

请求参数和响应参数中都会包含若干个字段,这些字段被组成下图所示的样子:

image-20240702113655313

如果是json编码的话,那其中的标签就是对应的字段名,比如上面的namemessage,不过这种编码方式不高效,因此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

image-20240702114356365

1 Varint类型

varint是一种可变长度整数类型,为每个整数所分配的字节并不是确定的4字节或者8字节,而是依赖于具体的值。下面具体介绍varint类型的原理:

  • 将原先的8bit一位拆分为两种,最高位用来标识是否后面还有更多的数据,如果是则该位置为1,如果不是则该位置为0,后7位用来存储数据。

例如:对于一个整数228,它的二进制表示为11100100。首先将其每7位拆分为一组则得到11100100,用varint编码则会表示为0000000111100100,如果该整数是用32位来编码的话则节省了两个字节,如果是64位编码的话,则节省了6个字节。

go标准库中的varint编码跟protobuf几乎一样,看看go是如何实现的

1
2
3
4
5
6
7
8
9
10
func PutUvarint(buf []byte, x uint64) int {
i := 0
for x >= 0x80 {
buf[i] = byte(x) | 0x80
x >>= 7
i++
}
buf[i] = byte(x)
return i + 1
}
  1. 如果x$\ge 0x80$,则说明数据超过7位,需要拆分,并且最高位置为1
  2. 右移继续判断下一个7位

该编码有一个问题,当整数非常大的时候会是一个负优化。例如对于uint64的最大值即64个1来说,用varint编码的话,最后需要10个字节来表示。因此这就引出了下一个问题,负数如果用varint编码的话就会非常浪费,因为在计算机中是用补码来表示的,-5的二进制形式是11111111111111111111111111111011,但它的绝对值其实是101而已。因此针对负数,采用了zigzag编码。

zigzag的原理也非常简单:

  1. 对于正数n来说,首先将其映射成2n,然后再使用varint编码
  2. 对于负数n来说,首先将其映射成2*|n|-1,然后再使用varint编码

这样负数和正数在数值上完全不会冲突,正整数和负整数交错排列,这也是为什么叫做 zigzag 编码 (锯齿形编码)的原因。
同时,负数被转换成正数之后,二进制编码也精简了许多。

例如: 对 -5 进行 zigzag 编码后,变成了9,对应于二进制为 00000000000000000000000000001001,使用 1 个字节即可表示 。

至于具体的映射实现有两种方式,一种是protobuf给出的,一种是在go源码中看到的:

1.protobuf中给出的

1
2
3
4
32位执行这个
(n << 1) ^ (n >> 31)
64位执行这个
(n << 1) ^ (n >> 63)

2.go源码中给出的

1
2
3
4
5
6
func PutVarint(buf []byte, x int64) int {
ux := uint64(x) << 1
if x < 0 {
ux = ^ux
}
}

即转换成无符号数向左移一位,如果x是负数则取反。

按理来说应该是protobuf更快一些的,因为没有条件判断语句,实际情况就不清楚了。

对于线路类型为varint的字段类型来说,sint32sint64首先使用了zigzag进行映射,然后再使用varint编码,其它的字段类型则直接使用varint编码。

2 固定长度类型

一个用来表示 64 位的数据类型,如fixed64、sfixed64 和 double;另一个用来表示 32 位的数据类型,如 fixed32、sfixed32 和 float。

3 LEN

对于使用按长度分割的类型,即string, bytes, embedded messages, packed repeated fields。这意味着首先会有一个经过 Varint 编码的长度值,随后才是指定数量的字节数据。字符串值会使用 UTF-8字符编码格式来进行编码。