You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Why the name "Protocol Buffers"?
The name originates from the early days of the format, before we had the protocol buffer compiler to generate classes for us. At the time, there was a class called ProtocolBuffer which actually acted as a buffer for an individual method. Users would add tag/value pairs to this buffer individually by calling methods like AddValue(tag, value). The raw bytes were stored in a buffer which could then be written out once the message had been constructed.
Since that time, the "buffers" part of the name has lost its meaning, but it is still the name we use. Today, people usually use the term "protocol message" to refer to a message in an abstract sense, "protocol buffer" to refer to a serialized copy of a message, and "protocol message object" to refer to an in-memory object representing the parsed message.
这个名字起源于 format 早期,在我们有 protocol buffer 编译器为我们生成类之前。当时,有一个名为 ProtocolBuffer 的类,它实际上充当了单个方法的缓冲区。用户可以通过调用像 AddValue(tag,value) 这样的方法分别将标签/值对添加到此缓冲区。原始字节存储在一个缓冲区中,一旦构建消息就可以将其写出。
构造一个 Test4 字段,并且设置 repeated 字段 d 3个值:3,270和86942,编码后:
C
22// tag 0010 0010(field number 010 0 = 4, wire type 010 = 2)06// payload size (设置的length = 6 bytes)03// first element (varint 3)8E02// second element (varint 270)9EA705// third element (varint 86942)
# Textual representation of a protocol buffer.
# This is *not* the binary format used on the wire.
person {
name: "John Doe"email: "[email protected]"
}
一. protocol buffers 是什么?
Protocol buffers 是一种语言中立,平台无关,可扩展的序列化数据的格式,可用于通信协议,数据存储等。
Protocol buffers 在序列化数据方面,它是灵活的,高效的。相比于 XML 来说,Protocol buffers 更加小巧,更加快速,更加简单。一旦定义了要处理的数据的数据结构之后,就可以利用 Protocol buffers 的代码生成工具生成相关的代码。甚至可以在无需重新部署程序的情况下更新数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。
Protocol buffers 很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。
二. 为什么要发明 protocol buffers ?
大家可能会觉得 Google 发明 protocol buffers 是为了解决序列化速度的,其实真实的原因并不是这样的。
protocol buffers 最先开始是 google 用来解决索引服务器 request/response 协议的。没有 protocol buffers 之前,google 已经存在了一种 request/response 格式,用于手动处理 request/response 的编组和反编组。它也能支持多版本协议,不过代码比较丑陋:
C
如果非常明确的格式化协议,会使新协议变得非常复杂。因为开发人员必须确保请求发起者与处理请求的实际服务器之间的所有服务器都能理解新协议,然后才能切换开关以开始使用新协议。
这也就是每个服务器开发人员都遇到过的低版本兼容、新旧协议兼容相关的问题。
protocol buffers 为了解决这些问题,于是就诞生了。protocol buffers 被寄予一下 2 个特点:
这个版本的 protocol buffers 仍需要自己手写解析的代码。
不过随着系统慢慢发展,演进,protocol buffers 目前具有了更多的特性:
protocol buffers 现在是 Google 用于数据的通用语言。在撰写本文时,谷歌代码树中定义了 48162 种不同的消息类型,包括 12183 个 .proto 文件。它们既用于 RPC 系统,也用于在各种存储系统中持久存储数据。
小结:
protocol buffers 诞生之初是为了解决服务器端新旧协议(高低版本)兼容性问题,名字也很体贴,“协议缓冲区”。只不过后期慢慢发展成用于传输数据。
三. proto3 定义 message
目前 protocol buffers 最新版本是 proto3,与老的版本 proto2 还是有些区别的。这两个版本的 API 不完全兼容。
在 proto 中,所有结构化的数据都被称为 message。
上面这几行语句,定义了一个消息 helloworld,该消息有三个成员,类型为 int32 的 id,另一个为类型为 string 的成员 str。opt 是一个可选的成员,即消息中可以不包含该成员。
接下来说明一些 proto3 中需要注意的地方。
如果开头第一行不声明
syntax = "proto3";
,则默认使用 proto2 进行解析。1. 分配字段编号
每个消息定义中的每个字段都有唯一的编号。这些字段编号用于标识消息二进制格式中的字段,并且在使用消息类型后不应更改。请注意,范围 1 到 15 中的字段编号需要一个字节进行编码,包括字段编号和字段类型(具体原因见 Protocol Buffer 编码原理 这一章节)。范围 16 至 2047 中的字段编号需要两个字节。所以你应该保留数字 1 到 15 作为非常频繁出现的消息元素。请记住为将来可能添加的频繁出现的元素留出一些空间。
可以指定的最小字段编号为1,最大字段编号为2^29^-1 或 536,870,911。也不能使用数字 19000 到 19999(FieldDescriptor :: kFirstReservedNumber 到 FieldDescriptor :: kLastReservedNumber),因为它们是为 Protocol Buffers实现保留的。
如果在 .proto 中使用这些保留数字中的一个,Protocol Buffers 编译的时候会报错。
同样,您不能使用任何以前 Protocol Buffers 保留的一些字段号码。保留字段是什么,下一节详细说明。
2. 保留字段
如果您通过完全删除某个字段或将其注释掉来更新消息类型,那么未来的用户可以在对该类型进行自己的更新时重新使用该字段号。如果稍后加载到了的旧版本
.proto
文件,则会导致服务器出现严重问题,例如数据混乱,隐私错误等等。确保这种情况不会发生的一种方法是指定删除字段的字段编号(或名称,这也可能会导致 JSON 序列化问题)为reserved
。如果将来的任何用户试图使用这些字段标识符,Protocol Buffers 编译器将会报错。注意,不能在同一个
reserved
语句中混合字段名称和字段编号。如有需要需要像上面这个例子这样写。3. 默认字段规则
在 proto3 中,纯数字类型的 repeated 字段编码时候默认采用 packed 编码(具体原因见 Protocol Buffer 编码原理 这一章节)
4. 各个语言标量类型对应关系
标注:
5. 枚举
在 message 中可以嵌入枚举类型。
枚举类型需要注意的是,一定要有 0 值。
另外在反序列化的过程中,无法被识别的枚举值,将会被保留在 messaage 中。因为消息反序列化时如何表示是依赖于语言的。在支持指定符号范围之外的值的开放枚举类型的语言中,例如 C++ 和 Go,未知的枚举值只是存储为其基础整数表示。在诸如 Java 之类的封闭枚举类型的语言中,枚举值会被用来标识未识别的值,并且特殊的访问器可以访问到底层整数。
在其他情况下,如果消息被序列化,则无法识别的值仍将与消息一起序列化。
5. 枚举中的保留值
如果您通过完全删除枚举条目或将其注释掉来更新枚举类型,未来的用户可以在对该类型进行自己的更新时重新使用数值。如果稍后加载到了的旧版本
.proto
文件,则会导致服务器出现严重问题,例如数据混乱,隐私错误等等。确保这种情况不会发生的一种方法是指定已删除条目的数字值(或名称,这也可能会导致JSON序列化问题)为reserved
。如果将来的任何用户试图使用这些字段标识符,Protocol Buffers 编译器将会报错。您可以使用max
关键字指定您的保留数值范围上升到最大可能值。注意,不能在同一个
reserved
语句中混合字段名称和字段编号。如有需要需要像上面这个例子这样写。6. 允许嵌套
Protocol Buffers 定义 message 允许嵌套组合成更加复杂的消息。
上面的例子中,SearchResponse 中嵌套使用了 Result 。
更多的例子:
7. 枚举不兼容性
可以导入 proto2 消息类型并在 proto3 消息中使用它们,反之亦然。然而,proto2 枚举不能直接用在 proto3 语法中(但是如果导入的proto2消息使用它们,这是可以的)。
8. 更新 message
如果后面发现之前定义 message 需要增加字段了,这个时候就体现出 Protocol Buffer 的优势了,不需要改动之前的代码。不过需要满足以下 10 条规则:
reserved
,以便将来的.proto
用户不会意外重复使用该号码。9. 未知字段
未知数字段是 protocol buffers 序列化的数据,表示解析器无法识别的字段。例如,当一个旧的二进制文件解析由新的二进制文件发送的新数据的数据时,这些新的字段将成为旧的二进制文件中的未知字段。
Proto3 实现可以成功解析未知字段的消息,但是,实现可能会或可能不会支持保留这些未知字段。你不应该依赖保存或删除未知域。对于大多数 Google protocol buffers 实现,未知字段在 proto3 中无法通过相应的 proto 运行时访问,并且在反序列化时被丢弃和遗忘。这是与 proto2 的不同行为,其中未知字段总是与消息一起保存并序列化。
10. Map 类型
repeated 类型可以用来表示数组,Map 类型则可以用来表示字典。
key_type
可以是任何 int 或者 string 类型(任何的标量类型,具体可以见上面标量类型对应表格,但是要除去 float、double 和 bytes)枚举值也不能作为 key。
key_type
可以是除去 map 以外的任何类型。需要特别注意的是 :
.proto
生成文本格式时,map 按 key 排序。数字的 key 按数字排序。Protocol Buffer 虽然不支持 map 类型的数组,但是可以转换一下,用以下思路实现 maps 数组:
上述写法和 map 数组是完全等价的,所以用 repeated 巧妙的实现了 maps 数组的需求。
11. JSON Mapping
Proto3 支持 JSON 中的规范编码,使系统之间共享数据变得更加容易。编码在下表中按类型逐个描述。
如果 JSON 编码数据中缺少值或其值为空,则在解析为 protocol buffer 时,它将被解释为适当的默认值。如果一个字段在协议缓冲区中具有默认值,默认情况下它将在 JSON 编码数据中省略以节省空间。具体 Mapping 的实现可以提供选项决定是否在 JSON 编码的输出中发送具有默认值的字段。
proto3 的 JSON 实现中提供了以下 4 中 options:
四. proto3 定义 Services
如果要使用 RPC(远程过程调用)系统的消息类型,可以在
.proto
文件中定义 RPC 服务接口,protocol buffer 编译器将使用所选语言生成服务接口代码和 stubs。所以,例如,如果你定义一个 RPC 服务,入参是 SearchRequest 返回值是 SearchResponse,你可以在你的.proto
文件中定义它,如下所示:与 protocol buffer 一起使用的最直接的 RPC 系统是 gRPC:在谷歌开发的语言和平台中立的开源 RPC 系统。gRPC 在 protocol buffer 中工作得非常好,并且允许你通过使用特殊的 protocol buffer 编译插件,直接从
.proto
文件中生成 RPC 相关的代码。如果你不想使用 gRPC,也可以在你自己的 RPC 实现中使用 protocol buffers。您可以在 Proto2 语言指南中找到更多关于这些相关的信息。
还有一些正在进行的第三方项目为 Protocol Buffers 开发 RPC 实现。
五. Protocol Buffer 命名规范
message 采用驼峰命名法。message 首字母大写开头。字段名采用下划线分隔法命名。
枚举类型采用驼峰命名法。枚举类型首字母大写开头。每个枚举值全部大写,并且采用下划线分隔法命名。
每个枚举值用分号结束,不是逗号。
服务名和方法名都采用驼峰命名法。并且首字母都大写开头。
六. Protocol Buffer 编码原理
在讨论 Protocol Buffer 编码原理之前,必须先谈谈 Varints 编码。
Base 128 Varints 编码
Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。
Varint 中的每个字节(最后一个字节除外)都设置了最高有效位(msb),这一位表示还会有更多字节出现。每个字节的低 7 位用于以 7 位组的形式存储数字的二进制补码表示,最低有效组首位。
如果用不到 1 个字节,那么最高有效位设为 0 ,如下面这个例子,1 用一个字节就可以表示,所以 msb 为 0.
C
如果需要多个字节表示,msb 就应该设置为 1 。例如 300,如果用 Varint 表示的话:
C
如果按照正常的二进制计算的话,这个表示的是 88068(65536 + 16384 + 4096 + 2048 + 4)。
那 Varint 是怎么编码的呢?
下面代码是 Varint int 32 的编码计算方法。
C
C
由于 300 超过了 7 位(Varint 一个字节只有 7 位能用来表示数字,最高位 msb 用来表示后面是否有更多字节),所以 300 需要用 2 个字节来表示。
Varint 的编码,以 300 举例:
C
Varint 的解码算法应该是这样的:(实际就是编码的逆过程)
解码过程调用 GetVarint32Ptr 函数,如果是大于一个字节的情况,会调用 GetVarint32PtrFallback 来处理。
C
至此,Varint 处理过程读者应该都熟悉了。上面列举出了 Varint 32 的算法,64 位的同理,只不过不再用 10 个分支来写代码了,太丑了。(32位 是 5 个 字节,64位 是 10 个字节)
64 位 Varint 编码实现:
C
原理不变,只不过用循环来解决了。
64 位 Varint 解码实现:
C
读到这里可能有读者会问了,Varint 不是为了紧凑 int 的么?那 300 本来可以用 2 个字节表示,现在还是 2 个字节了,哪里紧凑了,花费的空间没有变啊?!
Varint 确实是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。
300 如果用 int32 表示,需要 4 个字节,现在用 Varint 表示,只需要 2 个字节了。缩小了一半!
1. Message Structure 编码
protocol buffer 中 message 是一系列键值对。message 的二进制版本只是使用字段号(field's number 和 wire_type)作为 key。每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即
.proto
文件)来确定。这一点也是人们常常说的 protocol buffer 比 JSON,XML 安全一点的原因,如果没有数据结构描述.proto
文件,拿到数据以后是无法解释成正常的数据的。由于采用了 tag-value 的形式,所以 option 的 field 如果有,就存在在这个 message buffer 中,如果没有,就不会在这里,这一点也算是压缩了 message 的大小了。
当消息编码时,键和值被连接成一个字节流。当消息被解码时,解析器需要能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏不知道它们的旧程序。这就是所谓的 “向后”兼容性。
为此,线性的格式消息中每对的“key”实际上是两个值,其中一个是来自
.proto
文件的字段编号,加上提供正好足够的信息来查找下一个值的长度。在大多数语言实现中,这个 key 被称为 tag。注意上图中,3 和 4 已经被废弃了,所以 wire_type 取值目前只有 0、1、2、5。
key 的计算方法是
(field_number << 3) | wire_type
,换句话说,key 的最后 3 位表示的就是wire_type
。举例,一般 message 的字段号都是 1 开始的,所以对应的 tag 可能是这样的:
C
末尾 3 位表示的是 value 的类型,这里是 000,即 0 ,代表的是 varint 值。右移 3 位,即 0001,这代表的就是字段号(field number)。tag 的例子就举这么多,接下来举一个 value 的例子,还是用 varint 来举例:
C
可以 96 01 代表的数据就是 150 。
如果存在上面这样的一个 message 的结构,如果存入 150,在 Protocol Buffer 中显示的二进制应该为 08 96 01 。
额外说一句,type 需要注意的是 type = 2 的情况,tag 里面除了包含 field number 和 wire_type ,还需要再包含一个 length,决定 value 从那一段取出来。(具体原因见 Protocol Buffer 字符串 这一章节)
2. Signed Integers 编码
从上面的表格里面可以看到 wire_type = 0 中包含了无符号的 varints,但是如果是一个无符号数呢?
一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 10 个 byte 长度。
为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的 varint 编码值。
Zigzag 映射函数为:
C
按照这种方法,-1 将会被编码成 1,1 将会被编码成 2,-2 会被编码成 3,如下表所示:
需要注意的是,第二个转换
(n >> 31)
部分,是一个算术转换。所以,换句话说,移位的结果要么是一个全为0(如果n是正数),要么是全部1(如果n是负数)。当 sint32 或 sint64 被解析时,它的值被解码回原始的带符号的版本。
3. Non-varint Numbers
Non-varint 数字比较简单,double 、fixed64 的 wire_type 为 1,在解析时告诉解析器,该类型的数据需要一个 64 位大小的数据块即可。同理,float 和 fixed32 的 wire_type 为5,给其 32 位数据块即可。两种情况下,都是高位在后,低位在前。
说 Protocol Buffer 压缩数据没有到极限,原因就在这里,因为并没有压缩 float、double 这些浮点类型。
4. 字符串
wire_type 类型为 2 的数据,是一种指定长度的编码方式:key + length + content,key 的编码方式是统一的,length 采用 varints 编码方式,content 就是由 length 指定长度的 Bytes。
举例,假设定义如下的 message 格式:
设置该值为"testing",二进制格式查看:
C
74 65 73 74 69 6e 67 是“testing”的 UTF8 代码。
此处,key 是16进制表示的,所以展开是:
12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 tag = 2。
length 此处为 7,后边跟着 7 个bytes,即我们的字符串"testing"。
所以 wire_type 类型为 2 的数据,编码的时候会默认转换为 T-L-V (Tag - Length - Value)的形式。
5. 嵌入式 message
假设,定义如下嵌套消息:
设置字段为整数150,编码后的字节为:
C
08 96 01 这三个代表的是 150,上面讲解过,这里就不再赘述了。
1a -> 0001 1010,后三位 010 为 wire type = 2,0001 1010 右移三位为 0000 0011,即 tag = 3。
length 为 3,代表后面有 3 个字节,即 08 96 01 。
需要转变为 T - L - V 形式的还有 string, bytes, embedded messages, packed repeated fields (即 wire_type 为 2 的形式都会转变成 T - L - V 形式)
6. Optional 和 Repeated 的编码
在 proto2 中定义成 repeated 的字段,(没有加上 [packed=true] option ),编码后的 message 有一个或者多个包含相同 tag 数字的 key-value 对。这些重复的 value 不需要连续的出现;他们可能与其他的字段间隔的出现。尽管他们是无序的,但是在解析时,他们是需要有序的。在 proto3 中 repeated 字段默认采用 packed 编码(具体原因见 Packed Repeated Fields 这一章节)
对于 proto3 中的任何非重复字段或 proto2 中的可选字段,编码的 message 可能有也可能没有包含该字段号的键值对。
通常,编码后的 message,其 required 字段和 optional 字段最多只有一个实例。但是解析器却需要处理多对一的情况。对于数字类型和 string 类型,如果同一值出现多次,解析器接受最后一个它收到的值。对于内嵌字段,解析器合并(merge)它接收到的同一字段的多个实例。就如 MergeFrom 方法一样,所有单数的字段,后来的会替换先前的,所有单数的内嵌 message 都会被合并(merge),所有的 repeated 字段,都会串联起来。这样的规则的结果是,解析两个串联的编码后的 message,与分别解析两个 message 然后 merge,结果是一样的。例如:
C
等价于
C
这种方法有时是非常有用的。比如,即使不知道 message 的类型,也能够将其合并。
7. Packed Repeated Fields
在 2.1.0 版本以后,protocol buffers 引入了该种类型,其与 repeated 字段一样,只是在末尾声明了 [packed=true]。类似 repeated 字段却又不同。在 proto3 中 Repeated 字段默认就是以这种方式处理。对于 packed repeated 字段,如果 message 中没有赋值,则不会出现在编码后的数据中。否则的话,该字段所有的元素会被打包到单一一个 key-value 对中,且它的 wire_type=2,长度确定。每个元素正常编码,只不过其前没有标签 tag。例如有如下 message 类型:
构造一个 Test4 字段,并且设置 repeated 字段 d 3个值:3,270和86942,编码后:
C
形成了 Tag - Length - Value - Value - Value …… 对。
只有原始数字类型(使用varint,32位或64位)的重复字段才可以声明为“packed”。
有一点需要注意,对于 packed 的 repeated 字段,尽管通常没有理由将其编码为多个 key-value 对,编码器必须有接收多个 key-pair 对的准备。这种情况下,payload 必须是串联的,每个 pair 必须包含完整的元素。
Protocol Buffer 解析器必须能够解析被重新编译为 packed 的字段,就像它们未被 packed 一样,反之亦然。这允许以正向和反向兼容的方式将[packed = true]添加到现有字段。
8. Field Order
编码/解码与字段顺序无关,这一点由 key-value 机制保证。
如果消息具有未知字段,则当前的 Java 和 C++ 实现在按顺序排序的已知字段之后以任意顺序写入它们。当前的 Python 实现不会跟踪未知字段。
七. protocol buffers 的优缺点
protocol buffers 在序列化方面,与 XML 相比,有诸多优点:
举个例子:
如果要编码一个用户的名字和 email 信息,用 XML 的方式如下:
Xml
相同需求,如果换成 protocol buffers 来实现,定义文件如下:
C
protocol buffers 通过编码以后,以二进制的方式进行数据传输,最多只需要 28 bytes 空间和 100-200 ns 的反序列化时间。但是 XML 则至少需要 69 bytes 空间(经过压缩以后,去掉所有空格)和 5000-10000 的反序列化时间。
上面说的是性能方面的优势。接下来说说编码方面的优势。
protocol buffers 自带代码生成工具,可以生成友好的数据访问存储接口。从而开发人员使用它来编码更加方便。例如上面的例子,如果用 C++ 的方式去读取用户的名字和 email,直接调用对应的 get 方法即可(所有属性的 get 和 set 方法的代码都自动生成好了,只需要调用即可)
C
而 XML 读取数据会麻烦一些:
Xml
Protobuf 语义更清晰,无需类似 XML 解析器的东西(因为 Protobuf 编译器会将 .proto 文件编译生成对应的数据访问类以对 Protobuf 数据进行序列化、反序列化操作)。
使用 Protobuf 无需学习复杂的文档对象模型,Protobuf 的编程模式比较友好,简单易学,同时它拥有良好的文档和示例,对于喜欢简单事物的人们而言,Protobuf 比其他的技术更加有吸引力。
protocol buffers 最后一个非常棒的特性是,即“向后”兼容性好,人们不必破坏已部署的、依靠“老”数据格式的程序就可以对数据结构进行升级。这样您的程序就可以不必担心因为消息结构的改变而造成的大规模的代码重构或者迁移的问题。因为添加新的消息中的 field 并不会引起已经发布的程序的任何改变(因为存储方式本来就是无序的,k-v 形式)。
当然 protocol buffers 也并不是完美的,在使用上存在一些局限性。
由于文本并不适合用来描述数据结构,所以 Protobuf 也不适合用来对基于文本的标记文档(如 HTML)建模。另外,由于 XML 具有某种程度上的自解释性,它可以被人直接读取编辑,在这一点上 Protobuf 不行,它以二进制的方式存储,除非你有
.proto
定义,否则你没法直接读出 Protobuf 的任何内容。八. 最后
读完本篇 Protocol Buffer 编码原理以后,读者应该能明白以下几点:
.proto
文件,就无法理解二进制数据流。这点即是优点,使数据具有一定的“加密性”,也是缺点,数据可读性极差。所以 Protocol Buffer 非常适合内部服务之间 RPC 调用和传递数据。Protocol Buffer 编码原理篇到此结束,下篇来讲讲 Protocol Buffer 反序列化解包性能快的原因。
Other Resource
google 官方文档
thrift-protobuf-compare - Benchmarking.wiki
jvm-serializers
序列化和反序列化
The text was updated successfully, but these errors were encountered: