简介

Protocol Buffers

Protocol Buffers 是一种与语言、平台无关,可扩展的序列化结构化数据的方法,常用于通信协议,数据存储等等。与XML、JSON类似,但Protocol Buffers 在一些高性能且对响应速度有要求的数据传输场景非常适用。

Protocol Buffers 在gRPC的框架中主要有三个作用:

  • 定义数据结构
  • 定义服务接口
  • 通过序列化和反序列化,提升传输效率

相较于 JSON、XML,为什么Protocol Buffers 会提高传输效率呢?

我们知道使用XML、JSON进行数据编译时,数据文本格式更容易阅读,但进行数据交换时,设备就需要耗费大量的CPU在I/O动作上,自然会影响整个传输速率。Protocol Buffers不像前者,它会将字符串进行序列化后再进行传输,即二进制数据。

举个例子来看下Protocol Buffers编码与JSON编码的区别,如下:

Protocol Buffers编码 JSON编码
{1:"xjx", 2:"xjx"} {"name1":"xjx", "name2":"xjx"}

可以看到其实两者内容相差不大,并且内容非常直观,但是Protocol Buffers编码的内容只是提供给操作者阅读的,实际上传输的并不会以这种文本形式,而是序列化后的二进制数据。字节数会比JSON、XML的字节数少很多,速率更快。

Protocol Buffers自带一个编译器也是一个优势点。不用语言和平台可以通过.proto文件编译生成一个类似库文件,实现了跨语言优势。

综合来说,Protocol Buffers对比JSON、XML的优点总结如下:

  • 简单,体积小,数据描述文件大小只有1/10至1/3;
  • 传输和解析的速率快,相比XML等,解析速度提升20倍甚至更高;
  • 可编译性强

nanoPB(支持C语言)

Protocol Buffers支持多种语言,但是却不支持纯C语言,而且Protocol Buffers的使用笨重,在一些内存紧张的嵌入式设备上不能使用,nanoPB(使用入门)是谷歌协议缓冲数据格式的一个纯C实现。它的目标是32位微控制器,但也适用于其他嵌入式系统的严格(< 10kB ROM,< 1kB RAM)内存限制。

基础语法与使用

通过Protocol Buffers 可以用来构建数据结构和接口,其文件名后缀为 .proto 。

先来看一个非常简单的例子。假设你想定义一个“搜索请求”的消息格式,每一个请求含有一个查询字符串、你感兴趣的查询结果所在的页数,以及每一页多少条查询结果。可以采用如下的方式来定义消息类型的.proto文件了:

syntax = "proto3"; //指定proto版本号
 
message SearchRequest 
{
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

Protocol Buffers版本

文件的第一行使用 syntax指定了你正在使用proto3语法:如果你没有指定这个,编译器会使用proto2。这个指定语法行必须是文件的非空非注释的第一个行

packages

可以在.proto文件中增加一个可选的包标识来防止Protocol Buffers消息类型之间发生名字冲突,一个.proto文件里只能有一个package定义。

package foo.bar;
message Open { ... }

在定义消息类型的字段时可以这样使用包标识

message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

例如:

在nanoPB中.proto文件为:

//添加packages前
syntax = "proto3";
//package foo.bar;
option java_package = "com.example.protobuf";
message Open {
    string name = 1;
    int32 age = 2;
}

message Foo {
//   foo.bar.Open open = 1;
    Open open = 1;
}
//添加packages后
syntax = "proto3";
package foo.bar;
option java_package = "com.example.protobuf";

message Open {
  string name = 1;
  int32 age = 2;
}

message Foo {
  foo.bar.Open open = 1;
}

.options文件为:

//添加packages前
Open.name    			max_size:12
//添加packages后
foo.bar.Open.name    			max_size:12

对应生成的.pb.h

//添加packages前
typedef struct _Open { 
    char name[12]; 
    int32_t age; 
} Open;

typedef struct _Foo { 
    /* foo.bar.Open open = 1; */
    bool has_open;
    Open open; 
} Foo;
//添加packages后
typedef struct _foo_bar_Open { 
    char name[12]; 
    int32_t age; 
} foo_bar_Open;

typedef struct _foo_bar_Foo { 
    bool has_open;
    foo_bar_Open open; 
} foo_bar_Foo;

对应生成的.pb.c

//添加packages前
PB_BIND(Open, Open, AUTO)
    PB_BIND(Foo, Foo, AUTO)
//添加packages后
PB_BIND(foo_bar_Open, foo_bar_Open, AUTO)
    PB_BIND(foo_bar_Foo, foo_bar_Foo, AUTO)

使用不同的语言,包标识的生成的代码有所差异:

  • 在c++中,生成的类包装在c++ namespace中,如上示例,Open将在namespace foo::bar中。
  • 在java和Kotlin中,包被作为java package使用,除非在.proto文件中显式指定java_package选项。
  • 在Python中,这个包指令将被忽略,因为Python模块是根据他们在文件系统中的位置决定的。
  • 在Go中, 包被作为Go package使用, 除非在.proto文件中显式指定go_package选项.
  • 在Ruby中, 生成的代码被包裹在内嵌的Ruby namespaces, 转换为要求的Ruby capitalization风格(第一个字符大写;如 果第一个字符不是字母则加一个PB_前缀)。
  • 在 C# 中,包在转换为 PascalCase 后用作命名空间,除非在文件中显式指定命名空间。

在protocol buffer语言中, 类型命名解析类似 C++: 首先搜索最内层的范围, 然后最内层的外一层, 以此类推, 每个包都被认为是它父包的"内层". 在最前面的'.'(例如, .foo.bar.Baz)意味着替换为从最外层范围开始.
protocol buffer编译器通过解析导入的.proto文件来解析所有的类型命名. 每个语言的代码生成器知道在这个语言中如何引用每个类型, 即使它有不同的范围规则。

注释

Protocol Buffers 提供以下两种注释方式。

// 单行注释

/* 
多行注释 
*/

导入

Protocol Buffers 中可以导入其它文件消息等,与Python的import类似。

import “myproject/other_protos.proto”;

Options

option 通常是在 .proto 文件中的 syntax = "proto2"; 或 syntax = "proto3"; 行之后指定的。你可以根据你的需求选择适当的 option 来定制生成的代码和行为。

示例:

syntax = "proto3";

// 使用option设置Java包名
option java_package = "com.example.protobuf";

下面列举一些常用的 option以及它们的简要说明:

  1. java_package:用于指定生成的 Java 代码的包名。
  2. java_outer_classname:用于指定生成的 Java 代码的外部类名。
  3. java_multiple_files:如果设置为 true,每个消息类型会生成独立的 Java 文件。
  4. java_generic_services:如果设置为 true,生成的 Java 代码将使用通用服务接口。
  5. optimize_for:用于指定生成的二进制文件的优化方式,可选值为 SPEED、CODE_SIZE 和 LITE_RUNTIME。
  6. deprecated:标记消息、字段、服务或枚举值为已弃用。
  7. cc_enable_arenas:如果设置为 true,生成的 C++ 代码将启用 Arena 内存分配。
  8. objc_class_prefix:为 Objective-C 代码生成的类名添加前缀。
  9. py_generic_services:如果设置为 true,生成的 Python 代码将使用通用服务接口。
  10. go_package:用于指定生成的 Go 代码的包路径。

Message消息结构

在 .proto 文件中Protocol Buffers将一种结构称为一个message,一个message代表一个结构化数据,跟C中的结构体比较像。在一个 .proto文件中可定义多个消息结构。

消息对象的字段 组成主要是:字段 = 字段修饰符 + 字段类型 +字段名 +标识号

字段修饰符

字段修饰符主要作用是用来设置该字段解析时的规则。

proto2

修饰符 作用 备注
required 表示该字段必须赋值 字段如果设置了required规则,如果未赋值,则会抛出错误:RuntimeException
proto3取消了proto2的required
optional 表示该字段可选赋值, 可以设置/不设置(最多一个值) 如果可选字段解析时候没有值,则使用默认值(自定义默认/系统默认); 自定义默认值指定方式,例如: optional int32 Age = 1 [default=10]; 对于内嵌模式,默认值=默认实例或者原型,且无字段集;
在proto3语法中 "optional" 关键字被禁止,因为字段默认就是可选的;必填字段不再被支持
repeated 表示该字段可以被多次赋值 重复值的顺序会被保留,相当于变化的数组; 特别注意:由于历史原因,数值型的repeated字段后面最好加上[packed=true],这样能达到更好的编码效果。例如: repeated int32 samples = 4 [packed=true];

proto3

修饰符 作用 备注
singular 可以有零个或一个此字段(但不超过一个) proto3的singular就是proto2的optional,proto3默认使用singular修饰符
repeated 表示该字段可以被多次赋值 重复值的顺序会被保留,相当于变化的数组; 特别注意:由于历史原因,数值型的repeated字段后面最好加上[packed=true],这样能达到更好的编码效果。例如: repeated int32 samples = 4 [packed=true];

例如:

syntax = "proto3";

option java_package = "com.vgulu.server.Senserproxy.protocol";

message Open {
    string name = 1;
    int32 age = 2;
}
message Foo {
    repeated Open open = 1;
}

表示字段Open.name.bytes与Open.age可以有零个或一个,而Foo.open视定义可以有多个。

字段类型

字段类型主要分为三类:

基础类型

.proto 基本数据类型 对应于 各平台的基本数据类型如下:

.proto C++/C Go java Python 备注
double double float64 double float double 是使用固定 8 个字节来表示浮点数
float float float32 float float float 是使用固定 4 个字节来表示浮点数
int32 int32 int32 int int 使用变长varint编码,对负数编码效率低
int64 int64 int64 long int 使用变长varint编码,对负数编码效率低
uint32 uint32 uint32 int int/long 使用变长varint编码32位无符号整数类型,可以表示范围在0到232之间的整数
uint64 uint64 uint64 long int/long 使用变长varint编码64位无符号整数类型,可以表示范围在0到264之间的整数
sint32 int32 int32 int int 使用变长zigzag 和 varint编码,带符号的int类型,对负数编码比int32高效
sint64 int32 int32 long int/long 使用变长zigzag 和 varint编码,带符号的int类型,对负数编码比int64高效
fixed32 uint32 uint32 int int 固定4字节编码, 如果变量经常>228 位,会比uint32高效
fixed64 uint64 uint64 long int/long 固定8字节编码, 如果变量经常>256 位,会比uint64高效
sfixed32 int32 int32 int int 固定4字节
sfixed64 int64 int64 long int/long 固定8字节
bool bool bool boolean bool 固定一个字节
string string string string str/unicode 必须包含utf-8编码或者7-bit ASCII text
bytes string byte string str 任意的字节序列

注意不同语言下的符号区别!

基础类型使用推荐

画板

enum枚举类型

枚举类型主要作用是为字段指定一个 可能取值的字段集合。举例定义如下:

// 枚举类型 定义运行状态
enum RunStatus{
 UNKNOWN = 0;
 RUNNING = 1;
 STOP = 2;
}

message WorkInfo{
 required string name = 1;
 optional RunStatus type = 2 [default = STOP];  // 使用枚举类型的字段(设置了默认值)
}
说明:如上面例子,工作状态(RunStatus) 可能是 UNKNOWN 、RUNNING、STOP 的其中一个,那么就将工作状态(RunStatus)定义为枚举类型,并将加入工作信息WorkInfo集合。

特别注意:

  1. 枚举常量必须在32位整型值的范围内;
  2. 不推荐在enum中使用负数:因为enum值是使用可变编码方式的,对负数不够高。

默认值

解析消息时,如果编码的消息不包含特定的单数元素,则解析对象中的相应字段将被设置为该字段的默认值:

类型 默认值
strings 空string
bytes 空bytes
bools false
对于数值类型 0
enums 默认是第一个定义的枚举值,必须为0
message fields 根据具体语言定义

标识号

在消息定义中,每个字段都有唯一的一个数字标识符。这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变。

标识号使用范围:[1, 229 - 1], 不可使用 [19000-19999] 标识号, 因为 Protobuf 协议实现中对这些标识号进行了预留。假若使用,则会报错。

编码占有内存规则:

每个字段在进行编码时都会占用内存,而 占用内存大小 取决于 标识号:范围 [1, 15] 标识号的字段 在编码时占用1个字节;范围 [16, 2047] 标识号的字段 在编码时占用2个字节,因此,应该为非常频繁出现的message元素保留字段编号1到15

嵌套(nested)

我们可以在Message中定义Message,这就是嵌套。Protocol Buffers可以有多层的嵌套,并且可以在其他的message(message_other)中使用嵌套在message_a中的Message。

在Message中定义一个Message并使用:

message SearchResponse {
  //在message中定义message
  message Result {
    required string url = 1;
    optional string title = 2;
    repeated string snippets = 3;
  }
  //在message内部使用定义的message
  repeated Result result = 1;
}

//在其他地方使用上面定义的Message
message SomeOtherMessage {
  optional SearchResponse.Result result = 1;
}

扩展(Extensions)

Extensions可以让我们在Message中定义一些域,这些域可以交由第三方进行扩展我们定义的Message而不必去编辑原始文件。

示例如下:

message Foo{
    ......
    extensions 100 to 199;
}

上述代码通过extensions为之后的扩展预留了[100,199]的标识号。其他人可以扩展这个Message,并通过这些预留的标识号定义自己的域。

extend Foo{
    optional int32 bar = 123;
}

这样我们就通过扩展Foo定义了一个字段bar。

要访问 扩展字段 的方法与 访问普通的字段不同:使用专门的扩展访问函数:

pb.Foo.SetExtension(bar, 110);

类似的函数还有: <font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">HasExtensions()</font>, <font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">ClearExtensions()</font>, <font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">GetExtensions()</font>, <font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">MutableExtension()</font>, 以及<font style="color:rgb(25, 27, 31);background-color:rgb(248, 248, 250);">AddExtension()</font>。这些方法的功能和语义一致。其他语言的接口可以在protocol buffer的相关文档中找到。

我们也可以在另一个消息对象里声明扩展,如:

message Baz{
    extend Foo{
        optional int32 bar = 126;
    }
    ...
}

这种情况下访问的方式为:

pb.Foo.SetExtension(Baz.bar, 110);

换句话说,这仅仅作用于Baz的bar域。extensions很容易让人误以为是对原来域的扩展,其实从上例可以看出,这种嵌入式的定义,并不表明二者存在子集的关系,并不表明Baz是Foo的子集。在Foo中使用extend表明,符号bar是在Baz中定义。

Message更新

很多时候,随着项目的扩展,我们都需要在Message中增添一些额外的域,但是我们也希望之前的版本兼容新的proto文件。Protocol Buffers中的更新机制提供的一些兼容性设计,以下是一些更新规则:

操作类型 规则 备注
修改 不能更改已有字段的标识号
拓展 非required字段可以转为拓展 字段类型和标识号不能变化
添加 1. 新添加字段的标识号尽量在[1, 15]内 2. 添加新的字段修饰符必须为optional/singular或者repeated 1. 若标识号超过16,会多占用1个字节内存
删除 1. 只能删除字段修饰符必须为optional/singular或者repeated的字段 2. 不能移除已经存在值的required字段 不能使用已经被删除的标识号
转换 int32, uint32, int64, uint64, 和bool 是互相兼容的 可以将其中的一个类型修改为其他的类型而不会破坏代码的兼容性
转换 sint32和sint64互相兼容 但不兼容其他int类型
转换 string和bytes是兼容的 前提:字节是有效的UTF-8类型
转换 fixed32和sfixed32兼容,fixed64和sfixed64兼容
转换 嵌套Message在编码方式相同的情况下兼容bytes 前提:bytes包含消息的一个编码过的版本
转换 optional/singular兼容repeated 字段不带 pack = true

删除字段

如果操作不当,删除字段可能会导致严重问题。

当不再需要某个字段并且所有引用都已从客户端代码中删除时,可以从消息中删除该字段定义。但是,必须保留已删除的字段编号。如果不保留字段编号,开发人员将来可能会重新使用该编号。

同时还应该保留字段名称,以允许消息的 JSON 和 TextFormat 编码继续解析。

重复使用字段编号

重复使用字段编号会导致解码线路格式的消息变得模糊不清。

Protocol Buffers 连接格式很精简,并且不提供检测使用一个定义编码并使用另一个定义解码的字段的方法。

使用一个定义对一个字段进行编码,然后使用不同的定义对同一字段进行解码可能会导致:

  • 开发人员在调试上浪费的时间
  • 解析/合并错误(最佳情况)
  • 泄露的 PII/SPII
  • 数据损坏

字段编号重用的常见原因:

  • 重新编号字段(有时这样做是为了使字段的数字顺序更美观)。重新编号实际上会删除并重新添加涉及重新编号的所有字段,从而导致不兼容的线路格式更改。
  • 删除一个字段并且不保留该号码以防止将来重复使用。

保留字段编号

为确保不会发生重复使用字段编号情况,需将删除的字段编号添加到reserved列表中。

如果未来的开发人员尝试使用这些保留字段编号,protoc 编译器将生成错误消息。

message Foo {
  reserved 2, 15, 9 to 11;
}

保留字段编号范围包括(9 to 11与 相同9, 10, 11)。

保留字段名称

重用旧字段名称通常是安全的,除非使用 TextProto 或 JSON 编码(其中字段名称已序列化)。为了避免这种风险,可以将已删除的字段名称添加到列表中reserved

保留名称仅影响 protoc 编译器行为,而不影响运行时行为,但有一个例外:TextProto 实现可以在解析时丢弃具有保留名称的未知字段(不会像其他未知字段一样引发错误)(目前只有 C++ 和 Go 实现这样做)。运行时 JSON 解析不受保留名称的影响。

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

请注意,不能在同一个reserved 语句中混合字段名称和字段编号。

Oneof

在 Protocol Buffers 中,oneof 是一种用来定义多个字段中只能选择一个进行赋值的结构。这可以在定义消息类型时非常有用,特别是当你需要表示多个互斥的选项时。oneof 语句允许你在同一消息中定义一组字段,但是只能有一个字段被设置为非默认值。

在oneof字段中,增加任意类型的字段,包括message, 但是不能使用repeated 关键字

下面是一个简单的示例,展示了如何在 proto 文件中使用 oneof:

syntax = "proto3";

message MyMessage {
  oneof data {
    int32 value = 1;
    string text = 2;
    bool flag = 3;
    Foo foo = 4;
  }
}

在上述示例中,MyMessage 消息类型中定义了一个 oneof 包含四个字段:value,text,flag和Foo(message)。这意味着在任何给定的 MyMessage 实例中,只能选择设置其中的一个字段。

Maps

在 Protocol Buffers 中,map 是一种特殊的字段类型,允许你将键值对存储在消息中。这在你需要表示动态集合或关联数据时非常有用。map 字段在 proto3 版本中引入,但在 proto2 版本中并不支持。

如果要在数据定义中创建关联映射,Protocol Buffers提供了一种方便的语法:

map< key_type, value_type> map_field = N ;

其中key_type可以是任何整数或字符串类型。请注意,枚举不是有效的key_type。value_type可以是除map映射类型外的任何类型。

例如,如果要创建项目映射,其中每条Project消息都与字符串键相关联,则可以像下面这样定义它:

protomap<string, Project> projects = 3 ;
  • map的字段可以是repeated。
  • 序列化后的顺序和map迭代器的顺序是不确定的,所以你不要期望以固定顺序处理map
  • 当为.proto文件产生生成文本格式的时候,map会按照key 的顺序排序,数值化的key会按照数值排序。
  • 从序列化中解析或者融合时,如果有重复的key则后一个key不会被使用,当从文本格式中解析map时,如果存在重复的key,则解析可能会失败。
  • 如果为映射字段提供键但没有值,则字段序列化时的行为取决于语言。在Python中,使用类型的默认值

定义Services

在 Protocol Buffers中,service 用于定义 RPC(Remote Procedure Call)服务。RPC 服务定义了一组可以通过网络进行调用的方法,这允许不同的应用程序在不同的计算机上进行通信。

以下是 service 在 Protocol Buffers 中的基本语法:

service ServiceName {
  rpc MethodName(RequestType) returns (ResponseType);
  // 可以定义更多的 rpc 方法...
}

在上述语法中,ServiceName 是你定义的服务名称,MethodName 是你定义的 RPC 方法名称,RequestType 是该方法接收的请求消息类型,ResponseType 是该方法返回的响应消息类型。

以下是一个示例,展示了如何在 .proto 文件中定义一个简单的 RPC 服务:

syntax = "proto3";

package mypackage;

service CalculatorService {
  rpc Add(AddRequest) returns (AddResponse);
}

message AddRequest {
  int32 num1 = 1;
  int32 num2 = 2;
}

message AddResponse {
  int32 result = 1;
}

在上述示例中,我们定义了一个名为 CalculatorService 的服务,其中包含一个名为 Add 的 RPC 方法。Add 方法接收一个 AddRequest 请求消息,并返回一个 AddResponse 响应消息。

生成的代码将根据你的语言生成服务器和客户端代码,以便你可以在不同的应用程序中使用这些定义的服务和方法。

对于不同的编程语言,使用生成的代码调用服务的方法会有所不同,但基本原理是一致的:创建请求消息,发送请求,接收响应消息。请查阅你所使用的编程语言的官方文档以获取更详细的信息。

JSON映射

在 Protocol Buffers 3(proto3)中,JSON Mapping 是一种机制,用于定义如何将消息类型、字段以及其他协议缓冲数据结构映射到 JSON 格式。这样可以方便地在不同系统之间共享数据,因为 JSON 是一种通用的数据交换格式。下面是一些关于 proto3 中的 JSON Mapping 的描述类型:

proto3 JSON JSON example Notes
message object {"fooBar": v, "g": null, ...} 产生JSON对象,消息字段名可以被映射成lowerCamelCase形式,并且成为JSON对象键,null被接受并成为对应字段的默认值
enum string "FOO_BAR" 枚举值的名字在proto文件中被指定
map<K,V> object {"k": v, ...} 所有的键都被转换成string
repeated V array [v, ...] null被视为空列表
bool true, false true, false
string string "Hello World!"
bytes base64 string "YWJjMTIzIT8kKiYoKSctPUB+" JSON 值将是使用带填充的标准 Base64 编码编码为字符串的数据。接受带/不带填充的标准或 URL 安全的 base64 编码
int32, fixed32, uint32 number 1, -10, 0 JSON值会是一个十进制数,数值型或者string类型都会接受
int64, fixed64, uint64 string "1", "-10" JSON值会是一个十进制数,数值型或者string类型都会接受
float, double number 1.1, -10.0, 0, "NaN", "Infinity" JSON值会是一个数字或者一个指定的字符串如”NaN”,”infinity”或者”-Infinity”,数值型或者字符串都是可接受的,指数符号也可以接受
Any object {"@type": "url", "f": v, ... } 如果一个Any保留一个特上述的JSON映射,则它会转换成一个如下形式:{"@type": xxx, "value": yyy}否则,该值会被转换成一个JSON对象,@type字段会被插入所指定的确定的值
Timestamp string "1972-01-01T10:00:20.021Z" 使用RFC 339,其中生成的输出将始终是Z-归一化啊的,并且使用0,3,6或者9位小数
Duration string "1.000340012s", "1s" 生成的输出总是0,3,6或者9位小数,具体依赖于所需要的精度,接受所有可以转换为纳秒级的精度
Struct object { ... } 任意的JSON对象,见struct.proto
Wrapper types various types 2, "2", "foo", true, "true", null, 0, ... 包装器在JSON中的表示方式类似于基本类型,但是允许nulll,并且在转换的过程中保留null
FieldMask string "f.fooBar,h" 见fieldmask.proto
ListValue array [foo, bar, ...]
Value value 任意JSON值
NullValue null JSON null
Empty object {} 空 JSON 对象

注意事项

  • Proto3强制严格 UTF-8 检查。如果字符串字段包含非UTF-8数据则解析将失败
  • nanoPB必须通过.options,使用max_size,max_count限定string和repeat的长度,否则运行将出错。

编码结构

在对 Protocol Buffers 做了一些基本介绍之后,这节深入 Protocol Buffers 的一些原理,看看 Protocol Buffers 是如何尽其所能的压榨编码性能和效率的。

TLV 格式是我们比较熟悉的编码格式。所谓的 TLV 即 Tag - Length - Value。Tag 作为该字段的唯一标识,Length 代表 Value 数据域的长度,最后的 Value 便是数据本身。Protocol Buffers 编码采用类似的结构,但是实际上又有较大区别,其编码结构可见下图:

首先,每一个 message 进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,如下图所示:

特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式,如果变成这样的形式,没有了 Length 我们该如何确定 Value 的边界?答案就是 Varint 编码,在后面将详细介绍。

继续深入 Tag ,Tag 由 field_number 和 wire_type 两个部分组成:

  • field_number: message 定义字段时指定的字段编号
  • wire_type: Protocol Buffers 编码类型,根据这个类型选择不同的 Value 编码方案。

整个 Tag 采用 Varints 编码方案进行编码,Varints 编码会在后面详细介绍。

Tag 结构如下图所示:

3 bit 的 wire_type 最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下图所示:

第一列即是对应的类型编号,第二列为面向最终编码的编码类型,第三列是面向开发者的 message 字段的类型。

注意其中的 Start group 和 End group 两种类型已被遗弃。

另外要特别注意一点,虽然 wire_type 代表编码类型,但是 Varint 这个编码类型里针对 sint32、sint64 又会有一些特别编码(ZigTag 编码)处理,相当于 Varint 这个编码类型里又存在两种不同编码。

重新来看完整的编码结构图:

现在我们可以理解一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:

  • Tag - Length - Value:编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构,
  • Tag - Value:编码类型表中 Varint、64-bit、32-bit 使用这种结构。

可以思考一下为什么这么设计编码方案?可以看完下面各种编码详细的介绍再来细细品味这个问题。

其中 Tag 由字段编号 field_number 和 编码类型 wire_type 组成, Tag 整体采用 Varints 编码。

现在来模拟一下,我们接收到了一串序列化的二进制数据,我们先读一个 Varints 编码块,进行 Varints 解码,读取最后 3 bit 得到 wire_type(由此可知是后面的 Value 采用的哪种编码),随后获取到 field_number (由此可知是哪一个字段)。依据 wire_type 来正确读取后面的 Value。接着继续读取下一个字段 field...

Varints 编码

上一节中多次提到 Varints 编码,现在我们来正式介绍这种编码方案。

总结的讲,Varints 编码的规则主要为以下三点:

  1. 在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节
  2. 存储数字对应的二进制补码
  3. 补码的低位排在前面

为什么低位排在前面?这里主要是为编码实现(移位操作)做的一个小优化。可以尝试写个二进制移位进行编码解码的小例子来体会这一点。

先来看一个最为简单的例子:

int32 val =  1;  // 设置一个 int32 的字段的值 val = 1; 这时编码的结果如下



原码:0000 ... 0000 0001  // 1 的原码表示



补码:0000 ... 0000 0001  // 1 的补码表示



Varints 编码:0#000 0001(0x01)  // 1 的 Varints 编码,其中第一个字节的 msb = 0
  • 编码过程:
    数字 1 对应补码 0000 ... 0000 0001(规则 2),从末端开始取每 7 位一组并且反转排序(规则 3),因为 0000 ... 0000 0001 除了第一个取出的 7 位组(即原数列的后 7 位),剩下的均为 0。所以只需取第一个 7 位组,无需再取下一个 7 bit,那么第一个 7 位组的 msb = 0。最终得到
0 | 000 0001(0x01)
  • 解码过程:
    我们再做一遍解码过程,加深理解。

编码结果为 0#000 0001(0x01)。首先,每个字节的第一个 bit 为 msb 位,msb = 1 表示需要再读一个字节(还未结束),msb = 0 表示无需再读字节(读取到此为止)。

在上面的例子中,数字 1 的 Varints 编码中 msb = 0,所以只需要读完第一个字节无需再读。去掉 msb 之后,剩下的 000 0001 就是补码的逆序,但是这里只有一个字节,所以无需反转,直接解释补码 000 0001,还原即为数字 1。

注意:这里编码数字 1,Varints 只使用了 1 个字节。而正常情况下 int32 将使用 4 个字节存储数字 1。

再看一个需要两个字节的数字 666 的编码:

int32 val = 666; // 设置一个 int32 的字段的值 val = 666; 这时编码的结果如下



原码:000 ... 101 0011010  // 666 的源码



补码:000 ... 101 0011010  // 666 的补码



Varints 编码:1#0011010  0#000 0101 (9a 05)  // 666 的 Varints 编码
  • 编码过程:
    666 的补码为 000 ... 101 0011010,从后依次向前取 7 位组并反转排序,则得到:
0011010 | 0000101

加上 msb,则

1 0011010 | 0 0000101 (0x9a 0x05)
  • 解码过程:
    编码结果为 1#0011010 0#000 0101 (9a 05),与第一个例子类似,但是这里的第一个字节 msb = 1,所以需要再读一个字节,第二个字节的 msb = 0,则读取两个字节后停止。读到两个字节后先去掉两个 msb,剩下:
0011010  000 0101

将这两个 7-bit 组反转得到补码:

000 0101 0011010

然后还原其原码为 666。

注意:这里编码数字 666,Varints 只使用了 2 个字节。而正常情况下 int32 将使用 4 个字节存储数字 666。

仔细品味上述的 Varints 编码,我们可以发现 Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb),来表示是否已经结束(是否还需要读取下一个字节),msb 实际上就起到了 Length 的作用,正因为有了 msb(Length),所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。通过 Varints 我们可以让小的数字用更少的字节表示。从而提高了空间利用和效率。

这里为什么强调牺牲?因为每个字节都拿出一个 bit 做 msb,而原先这个 bit 是可直接用来表示 Value 的,现在每个字节都少了一个 bit 位即只有 7 位能真正用来表达 Value。那就意味这 4 个字节能表达的最大数字为 228-1,而不再是 232-1了。
这意味着什么?意味着当数字> 228-1时,采用 Varints 编码将导致分配 5 个字节,而原先明明只需要 4 个字节,此时 Varints 编码的效率不仅不是提高反而是下降。
但这并不影响 Varints 在实际应用时的高效,因为事实证明,在大多数情况下,< 228-1的数字比> 228-1的数字出现的更为频繁。

到目前为止,好像一切都很完美。但是当前的 Varints 编码却存在着明显缺陷。我们的例子好像只给出了正数,我们来看一下负数的 Varints 编码情况。

int32 val = -1



原码:1000 ... 0001  // 注意这里是 8 个字节



补码:1111 ... 1111  // 注意这里是 8 个字节



再次复习 Varints 编码:对补码取 7 bit 一组,低位放在前面。



上述补码 8 个字节共 64 bit,可分 9 组且这 9 组均为 1,这 9 组的 msb 均为 1(因为还有最后一组)



最后剩下一个 bit 的 1,用 0 补齐作为最后一组放在最后,最后得到 Varints 编码



Varints 编码:1#1111111 ... 0#000 0001 (FF FF FF FF FF FF FF FF FF 01)

注意,因为负数必须在最高位(符号位)置 1,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8 个字节。你没法像正数那样去掉多余的高位(都是 0)。再加上 msb,最终 Varints 编码的结果将固定在 10 个字节。

为什么是十个字节? int32 不应该是 4 个字节吗?这里是 ProtoBuf 基于兼容性的考虑(比如开发者将 int64 的字段改成 int32 后应当不影响旧程序),而将 int32 扩展成 int64 的八个字节。
为什么之前讲正数的时候没有这种扩展?。请仔细品味 Varints 编码,正数的前提下 int32 和 int64 天然兼容!

所以目前的情况是我们定义了一个 int32 类型的变量,如果将变量值设置为负数,那么直接采用 Varints 编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。如何解决?

ZigZag 编码

在上一节中我们提到了 Varints 编码对负数编码效率低的问题。

为解决这个问题,Protocol Buffers 为我们提供了 sint32、sint64 两种类型,当你在使用这两种类型定义字段时,Protocol Buffers 将使用 ZigZag 编码,而 ZigZag 编码将解决负数编码效率低的问题。

ZigZag 的原理和概念比我们想象的简单易懂,一句话就可概括介绍 ZigZag 编码:

ZigZag 编码:有符号整数映射到无符号整数,然后再使用 Varints 编码,如其名,编码后的值在正数与负数整型间摇摆,

如下图所示:

对于 ZigZag 编码的思维不难理解,既然负数的 Varints 编码效率很低,那么就将负数映射到正数,然后对映射后的正数进行 Varints 编码。解码时,解出正数之后再按映射关系映射回原来的负数。

例如我们设置 int32 val = -2。映射得到 3,那么对数字 3 进行 Varints 编码,将结果存储或发送出去。接收方接到数据后进行 Varints 解码,得到数字 3,再将 3 映射回 -2。

这里的“映射”是以移位实现的,并非存储映射表。

Varint 类型

介绍了 Varints 编码和 ZigZag 编码之后,我们就可以继续深入分析每个类型的编码。

在Protocol Buffers中目前已定义 6 种,其中两种已被遗弃(Start group 和 End group),只剩下四种类型: Varint、64-bit、Length-delimited、32-bit。

接下来我们就来一个个详细分析,彻底搞明白 ProtoBuf 针对每种类型的编码策略。

注意,我们在之前已经强调过,与其它三种类型不同,Varint 类型里不止一种编码策略。 除了 int32、int64 等类型的 Varints 编码,还有 sint32、sint64 类型的 ZigZag 编码。

int32、int64、uint32、uint64、bool、enum

当我们使用 int32、int64、uint32、uint64、bool、enum 声明字段类型时,其字段值将使用之前介绍的 Varints 编码。

其中 bool 的本质为 0 和 1,enum 本质为整数常量。

在结合本文开头介绍的编码结构: Tag - [Length] - Value,这里的 Value 采用 Varints 编码,因此不需要 Length,则编码结构为 Tag - Value,其中 Tag 和 Value 均采用 Vartins 编码。

int32、int64、uint32、uint64

来看一个最简单的 int32 的小例子:

syntax = "proto3";







// message 定义



message Example1 {



  int32 int32Val = 1;



}

在程序中设置字段值为 1,其编码结果为:

//  设置字段值 为 1



Example1 example1;



example1.set_int32val(1);



// 编码结果



tag-(Varints)0#0001 000 + value-(Varints)0#000 0001 = 0x08 0x01

在程序中设置字段值为 666,其编码结果为:

//  设置字段值 为 666



Example1 example1;



example1.set_int32val(666);



// 编码结果



tag-(Varints)00001 000 + value-(Varints)1#0011010  0#000 0101 = 0x08 0x9a 0x05

在程序中设置字段值为 -1,其编码结果为:

//  设置字段值 为 1



Example1 example1;



example1.set_int32val(-1);



// 编码结果



tag-(Varints)00001 000 + value-(Varints)1#1111111 ... 0#000 0001 = 0x08 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0x01

int64、uint32、uint64 与 int32 同理

bool、enum

bool 的例子:

syntax = "proto3";







// message 定义



message Example1 {



  bool boolVal = 1;



}

在程序中设置字段值为 true,其编码结果为:

//  设置字段值 为 true



Example1 example1;



example1.set_boolval(true);







// 编码结果



tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

在程序中设置字段值为 false,其编码结果为:

//  设置字段值 为 false



Example1 example1;



example1.set_boolval(false);







// 编码结果



空

这里有个有意思的现象,当 boolVal = false 时,其编码结果为空,为什么?
这里是 ProtoBuf 为了提高效率做的又一个小技巧:规定一个默认值机制,当读出来的字段为空的时候就设置字段的值为默认值。而 bool 类型的默认值为 false。也就是说将 false 编码然后传递(消耗一个字节),不如直接不输出任何编码结果(空),终端解析时发现该字段为空,它会按照规定设置其值为默认值(也就是 false)。如此,可进一步节省空间提高效率。

enum 的例子:

syntax = "proto3";







// message 定义



message Example1 {



  enum COLOR {



    YELLOW = 0;



    RED = 1;



    BLACK = 2;



    WHITE = 3;



    BLUE = 4;



}



  // 枚举常量必须在 32 位整型值的范围



  // 使用 Varints 编码,对负数不够高效,因此不推荐在枚举中使用负数



  COLOR colorVal = 1;



}

在程序中设置字段值为 Example1_COLOR_BLUE,其编码结果为:

//  设置字段值 为 Example1_COLOR_BLUE



Example1 example1;



example1.set_colorval(Example1_COLOR_BLUE);







// 编码结果



tag-(Varints)00001 000 + value-(Varints)0#000 0100 = 08 04

sint32、sint64

sint32、sint64 将采用 ZigZag 编码。编码结构依然为 Tag - Value,只不过在编码和解码的过程中多出一个映射的过程,映射后依然采用 Varints 编码。
来看 sint32 的例子:

syntax = "proto3";







// message 定义



message Example1 {



  sint32 sint32Val = 1;



}

在程序中设置字段值为 -1,其编码结果为:

//  设置字段值 为 -1



Example1 example1;



example1.set_colorval(-1);







// 编码结果,1 映射回 -1 



tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

在程序中设置字段值为 -2,其编码结果为:

//  设置字段值 为 -2



Example1 example1;



example1.set_colorval(-2);







// 编码结果,3 映射回 -2



编码结果:tag-(Varints)00001 000 + value-(Varints)0#000 0011 = 08 03

sint64 与 sint32 同理。

int、uint 和 sint: 之所以同时出现了这三种类型,是因为历史和代码迭代的结果。ProtoBuf 最初只有 int 类型,由于 int 类型不适合负数(负数编码效率低),所以提供了 sint。因为 sint 的一半正数其实是表达的负数,所以其正数范围有所减小(uint32范围:04294967295,sint32范围:-21474836482147483647),所以在一些全是正数场景下需要提供 uint 类型。

64-bit 和 32-bit 类型

64-bit 和 32-bit 比较简单,与 Varints 一样其编码结构为 Tag-Value,不同的是不管数字大小,64-bit 存储 8 字节,32-bit 存储 4 字节。读取时同理,64-bit 直接读取 8 字节,32-bit 直接读取 4 字节。

为什么需要 64-bit 和 32-bit?之前已经分析过了 Varints 编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。如果现在有场景是存在大量的大数字,那么使用 Varints 就不太合适了,此时使用 64-bit 和 32-bit 更为合适。具体的,如果数值比 256-1大的话,64-bit 这个类型比 uint64 高效,如果数值比 228-1大的话,32-bit 这个类型比 uint32 高效。

fixed64、sfixed64、double

来看例子:

// message 定义



syntax = "proto3";







message Example1 {



  fixed64 fixed64Val = 1;



  sfixed64 sfixed64Val = 2;



  double doubleVal = 3;



}

在程序中分别设置字段值 1、-1、1.2,其编码结果为:

//  设置字段值 为 -2



example1.set_fixed64val(1)



example1.set_sfixed64val(-1)



example1.set_doubleval(1.2)







// 编码结果,总是 8 个字节



09 # 01 00 00 00 00 00 00 00



11 # FF FF FF FF FF FF FF FF (没有 ZigZag 编码)



19 # 33 33 33 33 33 33 F3 3F

fixed32、sfixed32、float

与 64-bit 同理。

Length-delimited 类型

string、bytes、EmbeddedMessage、repeated

终于遇到了体现编码结构图中 [Length] 意义的类型了。Length-delimited 类型的编码结构为 Tag - Length - Value

这种编码方式很好理解,来看例子:

syntax = "proto3";







// message 定义



message Example1 {



  string stringVal = 1;



  bytes bytesVal = 2;



  message EmbeddedMessage {



    int32 int32Val = 1;



    string stringVal = 2;



}



  EmbeddedMessage embeddedExample1 = 3;



  repeated int32 repeatedInt32Val = 4;



  repeated string repeatedStringVal = 5;



}

设置相应的值:

Example1 example1;



example1.set_stringval("hello,world");



example1.set_bytesval("are you ok?");







Example1_EmbeddedMessage *embeddedExample2 = new Example1_EmbeddedMessage();







embeddedExample2->set_int32val(1);



embeddedExample2->set_stringval("embeddedInfo");



example1.set_allocated_embeddedexample1(embeddedExample2);







example1.add_repeatedint32val(2);



example1.add_repeatedint32val(3);



example1.add_repeatedstringval("repeated1");



example1.add_repeatedstringval("repeated2");

最终编码的结果为:

0A 0B 68 65 6C 6C 6F 2C 77 6F 72 6C 64 



12 0B 61 72 65 20 79 6F 75 20 6F 6B 3F 



1A 10 08 01 12 0C 65 6D 62 65 64 64 65 64 49 6E 66 6F 



22 02 02 03[ proto3 默认 packed = true](编码结果打包处理,见下一小节的介绍)



2A 09 72 65 70 65 61 74 65 64 31 2A 09 72 65 70 65 61 74 65 64 32(repeated string 为啥不进行默认 packed ?)

读者可对照上面介绍过的编码来理解这段相对复杂的编码结果。(为降低难度,已按字段分行,即第一个字段的编码结果对应第一行,第二个字段对应第二行...)

补充 packed 编码

在 proto2 中为我们提供了可选的设置 [packed = true],而这一可选项在 proto3 中已成默认设置。

packed 目前只能用于 primitive 类型。

packed = true 主要使让 ProtoBuf 为我们把 repeated primitive 的编码结果打包,从而进一步压缩空间,进一步提高效率、速度。这里打包的含义其实就是:原先的 repeated 字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因为这些 Tag 都是相同的(同一字段),因此可以将这些字段的 Value 打包,即将编码结构变为 Tag-Length-Value-Value-Value...

上一节例子中 repeatedInt32Val 字段的编码结果为:

22 | 02 02 03

22 即 00100010 -> wire_type = 2(Length-delimited), field_number = 4(repeatedInt32Val 字段),02 字节长度为 2,则读取两个字节,之后按照 Varints 解码出数字 2 和 3。

参考

https://zhuanlan.zhihu.com/p/650204831

https://www.zhihu.com/question/25020167

https://zhuanlan.zhihu.com/p/458254270

https://www.cnblogs.com/jzyl/p/15027839.html

https://blog.csdn.net/qq_41818123/article/details/120002469

https://blog.csdn.net/ta206/article/details/127602915

https://blog.csdn.net/bingxuesiyang/article/details/119515248

https://zhuanlan.zhihu.com/p/569857795

https://blog.csdn.net/bingxuesiyang/article/details/119515248