grpc的数据传输格式protobuf你了解吗?

前言

本文档主要讲解protobuf中基础的编码规则。先整体描述protobuf数据格式的优势,然后讲解protobuf的编码规则,通过了解编码规则,让大家深入浅出的理解protobuf的优势。在讲解规则时,会用到大量的例子帮助大家理解,大家也可以跟着一起计算,如果有计算不符的地方,欢迎评论区留言探讨。感谢大家的关注,后续会持续更新go语言,中间件等相关编程内容。

一、grpc为什么要选择protobuf?

gRPC选择使用Protocol Buffers(protobuf)作为数据格式,因为它具有以下几个优点:

  1. 高效的序列化:protobuf使用二进制格式来序列化数据。相比JSON或XML这种基于文本的格式,protobuf格式的数据更小且更快,这有助于减少网络传输的数据量和处理时间,非常适合高性能的通信场景。
  2. 跨语言支持:protobuf支持多种编程语言,包括Go、Python、Java、C++等。借助protobuf定义的数据结构可以在不同语言的服务间传递数据,而不需要关注各自语言的实现细节。这种跨语言的兼容性使得gRPC能在分布式微服务中被广泛使用。
  3. 强类型和兼容性:protobuf使用强类型来定义数据结构,这样开发者在编写代码时就可以提前发现数据类型的问题。它还提供了良好的向后兼容和向前兼容的支持,即使在服务端或客户端更新了数据结构,也能保证老版本的客户端或服务端仍然能正常通信。
  4. IDL(接口定义语言):protobuf提供了一种接口定义语言(IDL)来定义服务和消息结构。使用.proto文件,我们可以直观地定义接口和数据模型,减少手工编码的工作量。在定义完成后,可以通过protobuf工具自动生成客户端和服务端的代码,这大大提升了开发效率。

二、Varint编码

protobuf使用Varint编码来压缩整型数据(例如 int32, int64, uint32, uint64)的存储,使得它在存储和传输整数时能够更高效、更节省空间。参考链接:https://protobuf.dev/programming-guides/encoding/#varints

2.1 字节序

  1. 大端序:高字节放低地址,低字节放高地址(符合书写习惯:从左到右)
  2. 小端序:高字节放高地址,低字节放低地址

假设我们有一个16位(2字节)数值0x1234(十六进制),它可以分成两个字节:

高字节:0x12

低字节:0x34

在大端序(Big-endian)中

如果该数值从地址0x1000开始存储,那么大端序会将高字节放在高地址,低字节放在低地址:

地址 数据
0x1000 0x12
0x1001 0x34

因此,在大端序中,数值0x1234会按照高位在前的顺序排列。

在小端序(Little-endian)中

为了对比,小端序会将低字节放在低地址,高字节放在高地址。仍然以地址0x1000为起始:

地址 数据
0x1000 0x34
0x1001 0x12

2.2定长编码

16位整型——2个字节

32位整型——4个字节

64位整型——8个字节

2.3变长编码

🔔为什么用变长编码:比如对于一个64位整数来说,需要使用8字节来存储。但是通常情况下我们存储这8个字节的数据通常不会占用8个字节这么多。例如:140(二进制为:10001100),只需要占用两个字节,剩余高位全是0,其实是可以不用存储。

• 所以变长编码的本质就是:根据待编码的数据的大小,动态使用不同大小的空间来存储,从而达成减少占用的效果。

• 如何实现:将一个字节(8位)分成1 + 7:

◦ 最高位位标志位(continuation bit),表示当前字节的负载已经结束。(0表示结束,1表示未结束)

◦ 低7位为负载(payload),存储的就是实际的数字的比特位。

• 对于无符号64bit整型,使用变长编码的话,占用的字节数为1~10字节。

在这里插入图片描述

上述例子中:

  • 首先我们把140转成二进制数1000 1100,我们以每7位数进行划分,分成了1和0001100
  • 因为protobuf中的变长编码使用的小端序,低字节放在地址的前面即 0001100 | 1,因为8位是一个字节,我们在对这两份数进行补齐,又因为0001100后面还有数据1,所以它的第8位补1,即10001100.
  • 第二份数据1后面没有数据,所以它的第8位补0,其它位填充0,即00000001.
  • 最后得到的结果为10001100 00000001,16进制表示为0x8C01.

2.4 有符号数的编码

对于负数来说,protobuf首先将其转换为无符号数,再进行varint的编码。(称作:zigzag编码)

转换的公式为:n<0->2*|n|-1

三.Encoding

字段编码原则:TLV(Tag-Length-Value),也就是每一个字段都是按照TLV的原则来进行编码。

Tag

由field number和wire type组成,field number就是我们定义proto文件的时候,每个字段后面的 = 后面的数字。

由这两个组成Tag的公式为(field_number << 3) | wire_type

例子解析

message Address {
  string addr = 1;
  int32 number = 2;
}

// 创建一个Address对象
addr := &Address{
    Addr: "XiaRoad",
    Number: 523000,
}

对上面的addr的编码过程为(TLV规则):

  1. 计算第一个字段Addr的进行编码:

    a. T:首先计算field-1的tag:$tag1 = (1 << 3) | 2 = 10 = 0x0A$,string的wire_type=2。对tag1进行变长编码,得到的结果依然是0x0A;

    b. L:随后计算字符串的长度,len(“XiaRoad”)=7,对7进行变长编码,得到的结果为0x07;

    c. V:接着拼接上字符串”XiaRoad”的二进制表示(此处为ASCII):

    0x58 0x69 0x61 0x52 0x6F 0x61 0x64

    d. 最终第一个字段的编码结果为(十六进制) 0A 07 58 69 61 52 6F 61 64

  2. 随后对第二个字段Number进行编码:

    a. T:首先计算field-2的tag:tag2 = (2 << 3) | 0 = 16 = 0x10,int32的wire_type=0。对tag2进行变长编码,得到的结果依然是0x10;

    b. L:由于是整型,所以没有不像字符串那样有长度,所以此处就没有Length;

    c. V:随后是对Number的值,也就是523000进行变长编码,得到的结果为0xF8 0xF5 0x1F(小端序)(通过第二节的Varints编码计算)

    d. 最终将上述的结果拼接,得到第二个字段的编码结果为(十六进制):10 F8 F5 1F

  3. 最终得到整个的addr对象的编码结果为:0A 07 58 69 61 52 6F 61 64 10 F8 F5 1F,一共占用13个字节。

字段嵌套的情况

📌protobuf中规定,字段嵌套的wire_type=2,也就是和string的情况类似的处理。T-L-V,L就是被嵌套的字段编码后的长度。

message Address {
  string addr = 1;
  int32 number = 2;
}

message People {
  int32 age = 1;
  string name = 2;
  Address addr = 3;
}

// 对对象p进行序列化
p := &pb.People{
    Age: 20,
    Name: "HelloWps",
    Addr: &pb.Address{
        Addr: "XiaRoad",
        Number: 523000,
    },
}

例如上面的例子:Address被嵌套进People中。

得到的编码结果为:proto.Marshal(proto.Message)

在这里插入图片描述

让我们再来仔细计算一下:

编码 Address 对象

1.首先,我们需要对 Address 对象进行编码。

  • 字段 addr 字段号为 1,类型为 2(string)。 标签为:(1 << 3) | 2 = 0x0A 字符串 “XiaRoad” 的长度为 7,编码为 0x07。 字符串的 UTF-8 编码为 58 69 61 52 6F 61 64。 因此,addr字段的编码为: 0A 07 58 69 61 52 6F 61 64
  • 字段 number 字段号为 2,类型为 0(int32)。 标签为:(2 << 3) | 0 = 0x10 数值 523000 的Varint编码是:F8 F5 1F(我们在前面的计算中已经得到了这个结果)。 因此,number字段的编码为:10 F8 F5 1F
  • 完整的 Address 编码 将Address的两个字段编码组合在一起:0A 07 58 69 61 52 6F 61 64 10 F8 F5 1F

2 编码 People 对象

接下来,对 People 对象进行编码。

  • 2.1 字段 age 字段号为 1,类型为 0(int32)。 标签为: (1 << 3) | 0 = 0x08 数值 20 的Varint编码是:14(00010100)。 因此,age字段的编码为:08 14

  • 2.2 字段 name 字段号为 2,类型为 2(string)。 标签为:(2 << 3) | 2 = 0x12 字符串 “HelloWps” 的长度为 8,编码为 0x08。 字符串的 UTF-8 编码为 48 65 6C 6C 6F 57 70 73。因此,name字段的编码为: 12 08 48 65 6C 6C 6F 57 70 73

  • 2.3 字段 addr 字段号为 3,类型为 2(嵌套的 Address 对象)。 标签为:(3 << 3) | 2 = 0x1A Address的长度为 13,编码为 0x0D(前面加上长度)。 因此,addr字段的编码为:1A 0D 0A 07 58 69 61 52 6F 61 64 10 F8 F5 1F

3 完整的 People 编码

  • 将People的字段编码组合在一起:08 14 12 08 48 65 6C 6C 6F 57 70 73 1A 0D 0A 07 58

69 61 52 6F 61 64 10 F8 F5 1F

repeated字段

  1. 被repeated标识的字段的wire_type=2。(1 << 3 | 2 = 0x0A)
  2. Length表示的是后续每个element编码叠加后的长度。这里的Length是多少,整个编码结果是多少,大家可以放在评论区,我会看大家计算的对不对
// repeat的情况
message IntSlice {
  repeated int32 elems = 1;
}
//
ele := &pb.IntSlice{
    Elems: []int32{100, 200, 300, 400, 500},
}

注意

  1. protobuf中的编码顺序是按照字段的序号来排序的,字段序号小的编码后放在前面。
message Location {
  string loc = 3489;
  int32 number = 189;
}

上面的message中,number首先被编码,然后loc再被编码

  1. 为什么推荐使用1~15的字段序号?为什么序号不能有0?

    a. 因为1~15的字段序号编码后的tag只占用一个字节。(依据:(field_number << 3) | wire_type)

    b. 同样是依据:(field_number << 3) | wire_type,因为field_number要左移3位,对数字0进行左移没有意义。

四:Decoding

这里大家可以先自行计算,看最后的答案是不是上述一样。其实反解析计算服务端收到了grpc客户端的请求之后,然后把上述编码数据转换成对象的过程。说直白一点就是一个逆向的过程。

  • 字节序列解析过程

我们逐字节解析,主要关注每个字段的标签、类型和长度。

字段 1: Age (int32)

  • 字节: 08
  • 字节表示: 00001000
  • 字段号: 8(08 的前 3 位为 000,后 5 位为 01000)
  • 类型: 0(int32,wire type 为 0,表示 Varint 编码)
  • 值: 14
  • 后续字节: 14 → 10 进制为 20

字段 2: Name (string)

  • 字节: 12

  • 字节表示: 00010010

  • 字段号: 2(12 的前 3 位为 000,后 5 位为 010)

  • 类型: 2(string,wire type 为 2,表示长度前缀)

  • 长度: 08

  • 值: 接下来 8 字节是 48 65 6C 6C 6F 57 70 73

  • 转换为字符串: HelloWps

字段 3: Addr (Address)

  • 字节: 1A

  • 字节表示: 00011010

  • 字段号: 3(1A 的前 3 位为 000,后 5 位为 011)

  • 类型: 2(Address,wire type 为 2,表示长度前缀)

  • 长度: 0D

  • 值: 接下来 13 字节是 0A 07 58 69 61 52 6F 61 64

解析内部字段:

字段 1: Addr 字节: 0A 字段号: 1(前 3 位为 000,后 5 位为 001) 类型: 2(string) 长度: 07 值: XiaRoad(接下来的字节)

字段 2: Number 字节: 10 字段号: 2 类型: 0(int32) 值: F8 F5 1F 反解析: 11111000 11110101 00011111 组合后得到 523000

最终反解析的结构如下:

p := &pb.People{
    Age: 20,
    Name: "HelloWps",
    Addr: &pb.Address{
        Addr: "XiaRoad",
        Number: 523000,
    },
}

参考资料

  1. protobuf官方文档

    https://protobuf.dev/programming-guides/encoding/

  2. go标准库中的变长编码实现

    https://pkg.go.dev/encoding/[email protected]

  3. go中的proto序列化实现

    https://pkg.go.dev/google.golang.org/[email protected]/proto

总结

总结一下,gRPC选择protobuf是因为它的高性能、跨语言支持、强类型系统和自动代码生成,这些特点使得它非常适合构建高效、跨平台的微服务系统。

原文地址:https://blog.csdn.net/qq_41705360/article/details/143271168