最近学习protobuf相关的知识,找到一篇好文,翻译分享下,英文好的同学可以看原文:Protobuf — What & Why?


        Protobuf 是由 Google 开发的序列化协议,消息格式为二进制,独立于平台和语言,与 JSON 和 XML 等其他消息格式相比效率更高。


        但是为什么 Protobuf 更高效呢?从 JSON/XML 消息格式迁移到 Protobuf 是更好的选择吗?要回答这些问题,让我们深入了解 Protobuf 并了解其工作原理。

        我们都知道 JSON,这是用于在 Web 应用程序中传输消息的最流行的消息传递格式。与 XML 相比,JSON 是人类可读的、高效的并且易于使用。NodeJS 等后端框架和 Chrome 等客户端应用程序也原生支持 JSON。这使得 JSON 序列化和反序列化在 Javascript 环境中快速而快速。

        那么,既然 JSON 已经可以很好地完成这项工作,我们为什么还要学习一种新的消息传递格式呢?看看以下几点是否让你信服。

  1. Protobuf 因为它已经是二进制格式,所以序列化和反序列化非常快,而且与 JSON 和 XML 相比消息的大小更小(我们将在本文的最后一节中了解更多信息)。此外,开启服务器压缩后,Protobuf 负载大小变得更小。因此,使用 Protobuf,我们的 Web 应用程序可以更快地请求和响应!
  2. JSON 不允许对消息进行严格的模式定义。这可能不是什么大问题,因为消息验证可以在客户端或服务器中进行。但是比方说,例如,多个微服务通过像 Kafka 这样的消息代理使用 JSON 消息相互交互。当生产者想要更新特定的 JSON 属性时会发生什么?这可能会迫使我们更改大型应用程序中数千个消费者的验证逻辑,对吗?我们可以使用 Protobuf 和 Kafka 中的 Schema Registry 来解决这个问题。
  3. 使用 Protobuf,开发工作量要少得多,因为protobuf编译器会自动为许多流行语言的序列化和反序列化消息生成代码。

如果以上几点不能说服你,那么也许你可以考虑学习 Protobuf 作为一个额外的工具。我在某处读到,学习新事物可以让你的大脑肌肉保持活跃!:)

为什么选择 Protobuf?

        如下图所示,假设一旦客户下订单,订单服务就会通过消息队列将订单详细信息发送到库存服务。此订单详细信息将存储在消息队列中,库存服务异步轮询订单详情并对其进行处理。



Protobuf 是啥以及为啥要用它_字段

订单和库存系统之间的通信

对于像亚马逊这样一天处理超过一百万个订单的网站,服务之间的数据传输需要快速并且应该消耗更少的带宽。这里的目标是通过减少通过网络传输的数据大小来提高吞吐量并减少延迟。在我们的示例中,我们的目标是使用更少的网络带宽和更小的消息存储尽快将消息从订单服务发送到库存服务。

        让我们试着找到解决这个问题的方法。

尝试 #1
当我们使用 XML 作为通信的消息类型时,通过网络传输消息所需的带宽将很大,因为XML冗余的数据结构占用了很大一部分,如下所示。我们可能还需要大量空间来将此 XML 数据存储在消息队列中。

<root>
  <orderId>1</orderId>
  <customerId>123</customerId>
  <items>987</items>
  <items>988</items>
  <couponCode>ALLFREE</couponCode>
  <paymentMode>CASH</paymentMode>
  <shippingAddress>
    <name>Alice</name>
    <address>xyz street</address>
    <pincode>111111</pincode>
  </shippingAddress>
</root>

对于单条消息来说,这是很大的数据。对于每天接收数百万订单的 Amazon,XML 作为消息类型可能不是最好的选择。

尝试 #2
我们为什么不试试 JSON?JSON 是一种非常流行的消息格式,使用范围很广。尽管与 XML 相比,JSON 占用的空间和网络带宽更少,但作为一种基于文本的消息格式,它也占用更多的空间和带宽。我们可以做得更好。

{
    "orderId": 1,
    "customerId": 123,
    "items": [987, 988],
    "couponCode": "ALLFREE",
    "paymentMode": "CASH",
    "shippingAddress": {
        "name": "Alice",
        "address": "xyz street",
        "pincode": "111111"
    } 
}

尝试 #3
Protocol Buffers 以二进制格式通过网络发送消息。因为消息是二进制的,所以它需要更少的空间和带宽来传输。下面显示的是通过网络传输的订单详细信息。

8,1,16,123,26,4,219,7,220,7,34,7,65,76,76,70,82,69,69,40,1,50,27,10,5,65,108,
105,99,101,18,10,120,121,122,32,115,116,114,101,101,116,26,6,49,49,49,49,49,49

        与 JSON 和 XML 相比,Protocol Buffers 体积小,可以很好地通过网络传输数百万条消息。

        所以现在我们明白了我们的问题的解决方案可能是使用 Protocol Buffers,我们可能会有以下问题。

  1. 我们如何将我们的数据结构(即订单消息)转换为二进制格式?
  2. 我们如何从二进制格式重建另一端(库存服务)的订单对象?

让我们在下一节中讨论这些问题的答案。

Protobuf 语义

这一切从定义一个包含消息结构的.proto文件开始,即我们示例中的 Order。

syntax = "proto3";

// 订单
message Order {
    enum PaymentMode {  // 支付方式
        CASH = 0;       // 现金
        CARD = 1;       // 信用卡
    }
    int32 orderId = 1;          // 订单ID
    int32 customerId = 2;       // 客户ID
    repeated int32 items = 3;   // 物品列表
    string couponCode = 4;      // 优惠卷
    PaymentMode paymentMode = 5;// 付款方式
    Address shippingAddress = 6;// 邮寄地址
}

// 地址
message Address {
    string name = 1;        // 姓名
    string address = 2;     // 地址
    string pincode = 3;     // 邮政编码
}

上述order.proto文件包含订单消息的结构,内容如下:

syntax = "proto3" 说明该文件是使用proto3编译器格式编写的(下面有更多关于原型编译器的信息)

Order对象包含以下字段:

  • PaymentMode 枚举定义
  • orderId 整型 integer
  • customerId 整型 integer
  • items 变长整型数组 collection of integers
  • couponCode 字符串
  • paymentModePaymentMode 枚举
  • shippingAddress Address对象

Address对象包含以下字段定义:

  • name 字符串
  • address 字符串
  • pincode 字符串

您可能想知道分配给数据结构对象中每个字段的编号是什么意思。该编号称为字段编号(Field Number),必须是唯一的。Field Number 用于在序列化和反序列化过程中标识字段。我们将在下文讲到序列化、反序列化和字段编号。

定义 proto 文件后,我们需要一些功能将 Order 消息转换为二进制格式,反之亦然。这是最具挑战性的部分,但幸运的是,我们有一个由谷歌创建的proto编译器,它使用必要的算法自动为我们生成代码,完成上述工作。编译器能够生成不同编程语言的代码。下面的例子展示了如何使用编译器生成 Javascript 代码。

protoc --proto_path=. --js_out=import_style=commonjs,binary:. order.proto
  • protoc是protobuf编译器
  • proto_path是包含 proto 文件的目录的路径。.表示当前目录
  • js_out=import_style=commonjs,binary:.输出 commonjs 样式的 Javascript 代码并将 JS 文件放在当前目录中。
  • order.proto原型文件的名称

运行该protoc命令将在我们的当前目录中生成一个order_pb.js文件。该文件包含类似的方法

  • serializeBinary- 将 Order JS 对象序列化为二进制格式
  • deserializeBinary- 将二进制文件反序列化为 Order JS 对象
  • setOrderid- 设置订单ID order.setOrderid()
  • setCustomerid- 像这样设置客户 ID order.setCustomerid()
  • setItemsList- 设置物品集合
  • setShippingaddress- 设置送货地址。请注意,送货地址也是一个对象,Address包含serializeBinarydeserializeBinary,setName等方法。

等等。

下面是一个示例 JS 代码,它创建一个 order order1,序列化为二进制格式并再次反序列化为 order 对象。将这个方式推广到我们的订单示例中,我们可以将序列化的订单消息(即二进制数据)从订单服务通过网络发送到消息队列和库存服务。请注意,只有这段二进制数据通过网络传输并存储在消息队列中。库存服务可以再次将二进制数据反序列化为正确的 JS Order 对象并开始使用它。

// import
const OrderSchema = require("./order_pb");

// create order1 创建订单
const order1 = new OrderSchema.Order();
order1.setOrderid(1);
order1.setCustomerid(123);
order1.setItemsList([987, 988]);
order1.setCouponcode("ALLFREE");
order1.setPaymentmode(1);

// create address for order1 设置订单地址
const address = new OrderSchema.Address();
address.setName("Alice");
address.setAddress("xyz street");
address.setPincode("111111");
order1.setShippingaddress(address);

// serialize order1 to binary 序列化
const bytes = order1.serializeBinary();
console.log("Binary : " + bytes);

// deserialize order1 from binary to JS object 反序列化
const object = OrderSchema.Order.deserializeBinary(bytes).toString();
console.log("Deserialize : " + object);

您可能有一个问题,订单和库存服务怎么知道数据结构定义文件?

好问题。

在像 Apache Kafka 这样的线上环境消息队列/消息代理中,这个数据结构定义文件由模式注册表(Schema Registry)管理,这是一个存储数据结构定义文件的高可用服务。生产者使用模式注册表对消息进行编码。这些经过编码的消息通过网络传输并存储在消息代理中。消费者提取编码的消息,然后在架构注册表的帮助下对其进行解码。下图很好地说明了此工作流程。


Protobuf 是啥以及为啥要用它_字段_02

使用模式注册表的消息通信

这就是 Protobuf 的工作原理。它速度快,重量轻,占用的空间和网络带宽更少。

Protobuf 内部结构

如果您想了解对象→二进制→对象转换是如何发生的,并且想知道编码背后的逻辑,那么请继续阅读。

在我们之前的示例中,订单消息被转换为二进制消息格式,如下所示。

订单消息二进制格式:

8,1,16,123,26,4,219,7,220,7,34,7,65,76,76,70,82,69,69,40,1,50,27,10,5,65,108,
105,99,101,18,10,120,121,122,32,115,116,114,101,101,116,26,6,49,49,49,49,49,49

订单消息原型(供参考):

message Order {
    enum PaymentMode {
        CASH = 0;
        CARD = 1;
    }
    int32 orderId = 1;
    int32 customerId = 2;
    repeated int32 items = 3;
    string couponCode = 4;
    PaymentMode paymentMode = 5;
    Address shippingAddress = 6;
}

message Address {
    string name = 1;
    string address = 2;
    string pincode = 3;
}

以文本形式消息(供参考):

orderId: 1
customerId: 123
items: [987, 988]
couponCode: "ALLFREE"
paymentMode: "CASH"
shippingAddress:
        - name: "Alice"
        - address: "xyz street"
        - pincode: "111111"

要理解编码,让我们从二进制数据的第一个数字开始:8

解码8:
字段的第一个数字是一个key, 解码key将为我们提供数据结构定义文件中的字段。要了解如何解码key,我们可以使用以下逻辑。

key 8的解码逻辑:
1. 将 8 转换为二进制 = 0000 1000 
2. 取最后 3 位数字并转换为十进制以获得数据类型(稍后将详细介绍数据类型)= 000 即 0 
3. 右移 3 并转换为十进制以获得字段值即 0000 1000 >> 3 = 1

另一个例子,

16的解码关键逻辑:
1. 将 16 转换为二进制 = 0001 0000 
2. 取最后 3 位数字并转换为十进制以获得数据类型(稍后将详细介绍数据类型)= 000 即 0 
3. 右移3位 并转换为十进制以获得字段值即 0000 1000 >> 3 = 2(二进制为 10)

回到我们的二进制消息,通过解码 key 8我们已经确定了两件事:

  1. 数据类型为0
  2. 字段编号为1

有了这个我们可以说它8对应于字段orderId(数据结构定义文件中的字段编号 1)。

解码 1:
由于 key 8的数据类型0,我们可以说接下来的数字应该是 变长数据类型。

数据类型告诉我们如何处理即将到来的数字。下表显示了数据类型及其含义之间的映射。


Protobuf 是啥以及为啥要用它_字段_03


什么是 Varint?Varint 是一种使用一个或多个字节序列化整数的方法。让我们以整数为例1,它的 Varint 表示是0000 0001。另一个例子987,它的 Varint 是11011011 00000111。我们怎么知道这是987?以下块解释了逻辑。

Varint 11011011 00000111 的解码逻辑:
1. MSB(最高有效位 most significant bit) 告诉我们是否还有其它字节。如果 MSB 是 1,我们有额外的字节。如果 MSB 为 0,则这是最后一个字节。在当前的例子中,第一个字节的 msb 为 1,这意味着我们还必须考虑下一个字节。但是第二个字节的 MSB 是 0,所以这意味着没有其他字节。
2. 去掉字节1011011 0000111的 msb
3. 反转字节0000111 1011011
4. 连接字节并转换为十进制0000111 + 1011011 -> 1111011011 -> 987

再举一个例子,98765它的 Varint 是11001101 10000011 00000110

Varint 11001101 10000011 00000110 的解码逻辑:
1. 第一个字节的 MSB 是 1 所以有第二个字节,第二个字节的 MSB 是 1 所以有第三个字节,第三个字节的 MSB 是 0 所以没有其他字节。
2. 去掉字节1001101 0000011 0000110的 msb
3.反转字节0000110 0000011 1001101
4. 连接字节并转换为十进制0000110 + 0000011 + 1001101 -> 11000000111001101 -> 98765

回到我们的二进制消息,1是一个 Varint,它的值为 1。

此时我们通过解码8&1,得到了以下信息:

  1. orderId的值为1

接着解码16:
由于第一个数字是数据类型,16应该是key。到目前为止,您可能知道如何解码密钥。

对于 key 16,解码后我们有,

  1. 数据类型为0
  2. 字段编号为2

这意味着16对应于字段 customerId(字段编号 2)。

至此我们通过解码8, 1, 16,提取了以下信息

  1. orderId的值为1
  2. 字段是customerId

解码16后面的数字 123:
由于 key 16的数据类型是0,我们可以说下一个数字应该是 Varint。这里是123,01111011

Varint 01111011 的解码逻辑:
1. 第一个字节的 MSB 为 0,因此没有第二个字节。
2. 去掉字节1111011的 msb
3. 反转字节1111011
4. 连接字节并转换为十进制1111011 -> 123

此时我们已经通过解码8, 1, 16 & 123 提取了以下信息:

  1. orderId的值为1
  2. customerId的值为123

解码123后面的数字26:
由于字段第一个数字表示数据类型,26应该是key。

key 26的解码逻辑:
1. 将 26 转换为二进制 = 0001 1010 
2. 取最后 3 位并转换为十进制以获得数据类型即 010 即 2 
3. 右移 3 并转换为十进制以获得字段值即 0001 1010 >> 3 = 3 (11 二进制)

通过解码数字26我们有:

  1. 数据类型为2
  2. 字段编号为3

这意味着key 26对应于字段items(字段编号 3)。

此时我们通过解码8, 1, 16, 123 & 26 得到了以下信息:

  1. orderId的值为1
  2. customerId的值为123
  3. 接下来是items

解码 4, 219, 7, 220, 7:
key 26是数据类型是2,我们可以说接下来的数字应该是 Length Delimited 数据类型(参见上表)。那么我们如何解析 Length Delimited 线型呢?让我们来看看。

长度分隔的解码逻辑:
1. 第一个数字始终是 Varint 中给出的长度,即 Varint 4 的值为 4,因此长度为 4。
2. 由于长度为 4,请考虑接下来的 4 个数字,即 219、7、220、7
3. 之前为 26 计算的字段编号是 3,对应于 int 的集合(重复),我们可以说 219 (11011011), 7 (00000111), 220 (11011100), 7 (00000111) 是 Varints,可以解析为整数。
4. 11011011 00000111 11011100 00000111 包含 2 个基于 MSB 的整数
5. 11011011 00000111 Varint 解析为 9876. 11011100 00000111 Varint 解析为 988

此时我们通过解码 8, 1, 16, 123, 26, 4, 219, 7, 220, 7 得到了以下信息:

  1. orderId的值为1
  2. customerId的值为123
  3. items是一个包含987&988

继续解码34:
由于第一个数字表示类型的,34应该是key。

通过解码key 34 我们得到,

  1. 数据类型为2(Length Delimited)
  2. 字段编号为4

这意味着34对应于字段couponCode(字段编号 4)。

此时我们通过解码8, 1, 16, 123, 26, 4, 219, 7, 220, 7 & 34 得到了以下信息:

  1. orderId的值为1
  2. customerId的值为123
  3. items是一个包含987&988整型数组
  4. 字段couponCode

接着解码 7、65、76、76、70、82、69、69:
key 34的数据类型是2,我们可以说接下来的数字应该是 Length Delimited 线类型。那么我们如何解析 Length Delimited 线型呢?让我们来看看。

长度分隔的解码逻辑:
1. 第一个数字始终是 Varint 中给出的长度,即 Varint 7 的值为 7,因此长度为 7。
2. 由于长度为 7,考虑接下来的 7 个数字即 65、76、76、70、82、69、693、前面计算出来的key 34的字段号码是4,对应的数据类型是string,可以说65(1000001), 76(1001100), 76(1001100), 70(1000110), 82(1010010), 69(1000101), 69 (1000101) 将解析为 UTF-8 字符串。
4. 1000001 1001100 1001100 1000110 1010010 1000101 1000101 -> ALLFREE

此时我们通过解码得到了以下信息:

  1. orderId的值为1
  2. customerId的值为123
  3. items是一个包含987&988整型数组
  4. couponCode的值为ALLFREE

解码50:
由于第一个数字表示数据类型,50是key。

通过解码50,我们有:

  1. 数据类型为2(Length Delimited)
  2. 字段编号为6

这意味着key 50对应于字段shippingAddress(字段编号 6)。

继续解码 27,10,5,65,108,105,99,101,18,10,120,121,122,32,115,116,114,101,101,116,26,6,49,49,49,49,49,49:
由于key 50的数据类型2(Length Delimited),我们可以说下一个数字应该是长度分隔数据类型。那么我们如何解析 Length Delimited 线型呢?让我们来看看。

长度分隔的解码逻辑:
1. 第一个数字始终是 Varint 中给出的长度,即 Varint 27 的值是 27,所以长度是 27。
2. 由于长度为 27,请考虑接下来的 27 个数字,即 10、5、65、108、105、99、101、18、10、120、121、122、32、115、116、114、101、101、116、26、6、49、49、49、49、49、49
3. 由于前面计算出的key 50的字段编号为6,对应的是消息类型Address,所以后面10到49之间的数字属于Address消息类型,可以像Order消息类型一样解析。你可以把它当作练习。

使用上面提到的二进制格式编码逻辑,我们能够从二进制构建消息。

以上就是这篇文章的全部内容,感谢阅读。下次见,在那之前保重并继续学习 :)

原文链接:

1. Protobuf — What & Why?