Protocol Buffers 使用教程

概述

什么是 protocol buffers?

ProtocolBuffer 是用于结构化数据串行化的灵活、高效、自动的方法,类似 XML,不 过它更小、更快、也更简单。你可以定义自己的数据结构,然后使用代码生成器生成的代码 来读写这个数据结构。你甚至可以在无需重新部署程序的情况下更新数据结构。

他们如何工作

你首先需要在一个.proto 文件中定义你需要做串行化的数据结构信息。每个 ProtocolBuffer 信息是一小段逻辑记录,包含一系列的键值对。这里有个非常简单的.proto 文件定义了个人信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
message Person {
required string name=1;
required int32 id=2;
optional string email=3;
enum PhoneType {
MOBILE=0;
HOME=1;
WORK=2;
}

message PhoneNumber {
required string number=1;
optional PhoneType type=2 [default=HOME];
}
repeated PhoneNumber phone=4;
}

如你所见,消息格式很简单,每个消息类型拥有一个或多个特定的数字字段,每个字 段拥有一个名字和一个值类型。值类型可以是数字(整数或浮点)、布尔型、字符串、原始字 节或者其他 ProtocolBuffer 类型,还允许数据结构的分级。你可以指定可选字段,必选字 段和重复字段。你可以在protocolbuffers/docs/proto找到更多关于如何 编写 .proto 文件的信息。

一旦你定义了自己的报文格式(message),你就可以运行ProtocolBuffer编译器,将你 的.proto 文件编译成特定语言的类。这些类提供了简单的方法访问每个字段(像是 query() 和 set_query() ),像是访问类的方法一样将结构串行化或反串行化。例如你可以选择 C++ 语言,运行编译如上的协议文件生成类叫做 Person 。随后你就可以在应用中使用这个类来 串行化的读取报文信息。你可以这么写代码:

1
2
3
4
5
Person person;
person.set_name("John Doe");
person.set_id(1234); person.set_email("[email protected]");
fstream.output("myfile",ios::out | ios::binary);
person.SerializeToOstream(&output);

然后,你可以读取报文中的数据:

1
2
3
4
5
fstream input("myfile",ios::in | ios:binary);
Person person;
person.ParseFromIstream(&input);
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;

你可以在不影响向后兼容的情况下随意给数据结构增加字段,旧有的数据会忽略新的字段。所以如果使用 ProtocolBuffer 作为通信协议,你可以无须担心破坏现有代码的情况下扩展协议。

你可以在 API 参考overview中找 到完整的参考,而关于 ProtocolBuffer 的报文格式编码则可以在encoding中找到。

为什么不使用 xml?

ProtocolBuffer 拥有多项比 XML 更高级的串行化结构数据的特性,ProtocolBuffer: 更简单小3-10倍快20-100倍更少的歧义可以方便的生成数据存取类
例如,让我们看看如何在 XML 中建模 Person 的 name 和 email 字段:

1
2
3
4
<person>
<name>John Doe</name>
<email>[email protected]</email>
</person>

对应的 ProtocolBuffer 报文则如下:

1
2
3
4
person {
name: "John Doe"
email: "[email protected]"
}

当这个报文编码encoding到 ProtocolBuffer 的二进制格式时(上面的文本 仅用于调试和编辑),它只需要28字节和100-200ns 的解析时间。而 XML 的版本需要69字节(除 去空白)和5000-10000ns 的解析时间。

当然,操作 ProtocolBuffer 也很简单:

1
2
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;

而 XML 的你需要:

1
2
3
4
5
6
cout << "Name: "
<< person.getElementsByTagName("name")->item(0)->innerText()
<< endl;
cout << "E-mail: "
<< person.getElementsByTagName("email")->item(0)->innerText()
<< endl;

当然,ProtocolBuffer 并不是在任何时候都比 XML 更合适,例如 ProtocolBuffer 无法 对一个基于标记文本的文档建模(比如 HTML),因为你根本没法方便的在文本中插入结构。 另外,XML 是便于人类阅读和编辑的,而 ProtocolBuffer 则不是。还有 XML 是自解释的, 而 ProtocolBuffer 仅在你拥有报文格式定义的.proto 文件时才有意义。

如何开始?

下载包,包含了 Java、Python、 C++的 ProtocolBuffer 编译器,用于生成你需要的 IO 类。构建和安装你的编译器,跟随 README 的指令就可以做到。

一旦你安装好了,就可以跟着编程指导来选择语言- 随后就是使用 ProtocolBuffer 创建一个简单的应用了。

一点历史

ProtocolBuffers 最初是在 Google 开发的,用以解决索引服务器的请求、响应协议。 在使用 ProtocolBuffers 之前,有一种格式用以处理请求和响应数据的编码和解码,并且支 持多种版本的协议。而这最终导致了丑陋的代码,比如:

1
2
3
4
5
6
7
8
if (version==3) {
...
} else if (version>4) {
if (version==5) {
...
}
...
}

通信协议因此变得越来越复杂,因为开发者必须确保,发出请求的人和接受请求的人必 须同时兼容,并且在一方开始使用新协议时,另外一方也要可以接受。ProtocolBuffers 设计用于解决这一类问题:

  • 很方便引入新字段,而中间服务器可以忽略这些字段,直接传递过去而无需理解所有的 字段。
  • 格式可以自描述,并且可以在多种语言中使用(C++、Java 等)
  • 然而用户仍然需要手写解析代码。

随着系统的演化,他需要一些其他的功能:

  • 自动生成编码和解码代码,而无需自己编写解析器。
  • 除了用于简短的 RPC(Remote Procedure Call)请求,人们使用 ProtocolBuffer来做数据存储格式(例如 BitTable)。
  • RPC服务器接口可以作为 .proto 文件来描述,而通过 ProtocolBuffer的编译器生成存根(stub)类供用户实现服务器接口。

ProtocolBuffers 现在已经是 Google 的混合语言数据标准了,现在已经正在使用的有 超过48,162种报文格式定义和超过12,183个 .proto 文件。他们用于 RPC 系统和持续数据存 储系统。

环境安装

下载

官方下载网站

安装

1
2
3
4
5
tar -zxvf protobuf-2.5.0.tar.gz
cd protobuf-2.5.0
./configure --prefix=$INSTALL_DIR
make
make install

更详细的安装步骤请参考源码目录下的 README.txt。

安装完后在INSTALL_DIR目录下生成三个目录:

1
bin include lib

bin目录下是protoc工具,用于将你的.proto 文件编译成相应目标语言的编解码代码。include和lib目录是protoc工具所依赖的头文件与库环境。

利用protoc工具编译成目标语言

编译前,先准备你的.proto 文件,这里暂时以源码目录下的 examples/addressbook.proto 文件为例。

protoc 用法:

1
Usage: ./protoc [OPTION] PROTO_FILES

详情请使用

1
./protoc --help

开始将你的.protoc 文件编译成目标语言编解码文件:

1
2
mkdir c java python
./protoc --proto_path=./ --cpp_out=c/ --java_out=java/ --python_out=python/ addressbook.proto

官方的 protoc 工具仅支持C++/java/python三种语言,如果你使用其他语言,比如 c#,php,你可以使用其他第三方工具。命令简介:

1
./protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR path/to/file.proto

其中:

1
2
3
--proto_path:.proto 所在的路径
--cpp_out:生成 C++代码的路径
--java_out:生成 java 代码的路径--python_out:生成 python 代码的路径

Python如何使用protocol buffers

安装 python 的 pb 库:在 protobuf 源码目录下可以找到一个目录python,没错,你需要进入此目录安装python的pb库。

1
2
cd protobuf-2.5.0/python
$PYTHONHOME/bin/python setup.py install --prefix=$PYTHONHOME

使用–prefix 选项指定你 python 的安装目录,然后静待安装完成。以下是安装时常见的错误:

  • 安装提示 error: package directory ‘google/protobuf/compiler’ does not exist
    解决办法:
    执行mkdir google/protobuf/compiler
    创建compiler目录即可。

  • TBD

安装完 python 的 pb 库后,你就可以在源码的 examples 目录中,使用 add_person.py 和 list_people.py 来测试如何使用 pb 序列化与反序列化了。序列化与反序列化的相关接口 分别为 SerializeToString()和 ParseFromString()。

更多 python 相关的 api 请看protocol-buffers/docs/reference/python/index

其他语言如何使用protocol buffers

TBD

语言指导

消息定义

在.proto 文件里面用 Protocol Buffers 消息类型进行定义,每一个 Protocol Buffers消息是信息的一条小的逻辑记录,里面包含一系列名称-值对。下面是一个简单的.proto 文件:

1
2
3
4
5
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}

字段类型

可以是基本类型,例如整形、浮点型,值类型可以是其他的 Protocol Buffers 的消息类型,这样你可以用分层的方式定义你的数据结构。

分配字段Tag(标记)

每个字段必须有一个唯一的标记,这个标记在序列化时会作为字段的标识出现在序 列化后的二进制数据中。一旦该消息用于生产,字段的 tag 就不能修改了。标记的值小 于15时序列化编码为一个字节,大于15会用到两个以上的字节。

指定字段的规则

消息的字段可以具有以下类型的属性:

required: 消息中必须包含一个该字段的值

optional: 可选字段,消息中可以有0个或一个该字段的值

repeated: 重复字段,消息中可以有0个或多个该字段的值

选择字段规则的建议

你可以在你的消息格式里面添加新的域,而不用考虑向后兼容性,老的二进制流在 解析的时候可以简单的忽略掉新增的域。因此如果你使用 Protocol Buffers 作为你数据 格式的通信协议时,你可以扩展你的协议,而不用担心破坏现有的代码。对于 required,尽可能的少用,若一个字段开始时指定为 required,则以后就不能 修改为 optional。建议将字段设置都设置为 optional 类型,这样字段的 required 规则可以放在业务代码中进行处理。

增加更多的消息类型

在单个 .proto 文件里可以定义多种消息类型:

1
2
3
4
5
6
7
8
9
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}

message SearchResponse {
...
}

注释

.proto 文件使用 C/C++的注释风格:

1
2
3
4
5
message SearchRequest {
required string query = 1;
optional int32 page_number = 2; // Which page number do we want?
optional int32 result_per_page = 3; // Number of results to return per page.
}

数值类型

下表列举了 pb 协议数据类型与 C++/Java/Python 语言的类型对应关系:

.proto Type Notes C++ Type Java Type Python Type[2]
double double double float
float float float float
int32 Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint32 instead. int32 int int
int64 Uses variable-length encoding. Inefficient for encoding negative numbers – if your field is likely to have negative values, use sint64 instead. int64 long int/long[3]
uint32 Uses variable-length encoding. uint32 int[1] int/long[3]
uint64 Uses variable-length encoding. uint64 long[1] int/long[3]
sint32 Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int32s. int32 int int
sint64 Uses variable-length encoding. Signed int value. These more efficiently encode negative numbers than regular int64s. int64 long int/long[3]
fixed32 Always four bytes. More efficient than uint32 if values are often greater than 228. uint32 int[1] int
fixed64 Always eight bytes. More efficient than uint64 if values are often greater than 256. uint64 long[1] int/long[3]
sfixed32 Always four bytes. int32 int int
sfixed64 Always eight bytes. int64 long int/long[3]
bool bool boolean boolean
string A string must always contain UTF-8 encoded or 7-bit ASCII text. string String str/unicode[4]
bytes May contain any arbitrary sequence of bytes. string ByteString str

你可以在protocol-buffers/docs/encoding找到这些类型在 pb 序列化时是如何被编码的。

[1] 在 Java, unsigned 32-bit and 64-bit integers 都是使用最高位表示符号位,而无符号位部分是一样的。

[2] 所有情况下,赋值操作会触发类型检查以保证可用性。

[3] 64-bit or unsigned 32-bit integers 会被解码为 long 类型,但如果在赋值时使用 int 类型的话,解码后可以是 int 类型。任何情况下,值必须与被赋值时一样。见[2]。

[4] Python strings 类型在解码后是 unicode 类型,但如果原始字符串是 ASCII 编码的话也可能是 str 类型。

可选字段规则与默认值

一个消息字段可以使用 optional 规则来限定,表示该字段是可选类型,即该消息可以 包含该字段也可以不包含该字段。当一个消息被解析时,如果序列化数据没有包含 optional 字段,则该字段会使用默认值来代替。默认值可以显式指定如下:

1
optional int32 result_per_page = 3 [default = 10];

如果没有显式指定默认值,则使用数据类型的默认值来代替:比如,字符串类型的默认值是空字符串,布尔类型的默认值是 false,数字类型的默认值是0,枚举类 型的默认值是第一个被定义的枚举值。

枚举类型

使用 enum 关键字定义枚举类型,比如你想定义一个叫 Corpus 的枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3 [default = 10];
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
optional Corpus corpus = 4 [default = UNIVERSAL];
}

使用其他消息类型

你可以使用其他的消息类型来定义你的消息字段,以构成各种复合类型,比如 SearchResponse 消息里定义了一个 Result 消息类型的字段:

1
2
3
4
5
6
7
8
9
message SearchResponse {
repeated Result result = 1;
}

message Result {
required string url = 1;
optional string title = 2;
repeated string snippets = 3;
}

另外,你还可以使用 import 语句导入其他.proto 文件定义的消息类型。 包含路径的 import:

1
import "myproject/other_protos.proto";

不包含路径的 import:

1
2
import public "new.proto";
import "other.proto";

当使用 public 域 import 时,编译器会去 -I/–proto_path 标志指定的路径去查找,如果没有指定此标志,它会去编译器目录索引。通常情况下,建议你使用 –proto_path 指 定为项目的根路径,并且使用全名(包含命名空间或包名)import。

命名风格

良好的命名风格让你的.proto 文件更加易读。

消息以及字段名称

使用 CamelCase 方式命名消息名称,使用下划线分隔的名字来命名消息的字段,例如:

1
2
3
message SongServerRequest {
required string song_name = 1;
}

枚举类型

使用 CamelCase 方式命名消息名称,例如 PhoneType 使用大写字母+下划线来命名枚举值,例如:

1
2
3
4
5
enum PhoneType {
TYPE_MOBILE = 0;
TYPE_HOME = 1;
TYPE_WORK = 2;
}

服务

如果你的.proto 文件定义了 RPC 服务,你可以使用 CamelCase 的方式命名你的服务名与RPC 方法名:

1
2
3
service FooService {
rpc GetSomething(FooRequest) returns (FooResponse);
}

编码原理

本节主要介绍 protocol buffer 消息转换成二进制格式的原理。如果仅需要了解怎么使 用 protocol buffers,你可以无需理解这些原理,但了解这些能帮助你理解 protocol buffers 对编码后数据大小的影响。

一个简单的消息

下面是一个简单的消息定义:

1
2
3
message Test1 {
required int32 a = 1;
}

若你创建了一个 Test1的消息,然后 a 赋值为150,序列化后,你会发现消息被编码为 下面3个字节:

1
08 96 01

看起来非常小巧与数字化,但它代表什么意义?继续看下去,好戏还在后头……

变长整型(varint)

为了理解 protocol buffer 的编码原理,你首先需要理解 varint 的概念。
Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小 的数字使用越少的字节数。这能减少用来表示数字的字节数。比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。注意,采用 Varint 表示法,大的数字则需要更多个 byte 来表示。从统计的角度来说,一般不会所有消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。Varint 中的每个 byte 的最高位 most significant bit (msb) 有特殊的含义,如果 该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个 bit 都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,比如 300,会用两个字节来表示:1010 1100 0000 0010,以下是它的解码过程:

首先按照字节分组:

1
1010 1100 0000 0010

去掉 msb

1
010 1100 000 0010

将字节反向排列

1
000 0010 010 1100

重新组合成字节

1
000 001 0010 1100 → 100101100=300

消息结构

一个 protocol buffer 消息是一系列的键-值对。序列化后的二进制消息仅使用字段数字为 key。
当消息被编码后,键值对被组织成一个字节流。消息在解码后,解析器能够忽略不认识 的字段。按照这样的方式,旧程序能够忽略不认识的新增字段。最后,”key”实际上是由两个值组成的,其中一个是.proto 文件的字段数字标号,另外一个是 wire types,这样才 能提供足够的信息去找到接下来数据的长度。下面是可用的wire types:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

每个消息流的 key 都是一个 varint 类型,它的值为(field_number << 3) | wire_type 。换句话说,最后三位用于保存 wire type 。

比如 key 是08,去掉 msb 位后如下:

1
000 1000

则field_number 和 wire type 分别为:

1
2
field_number=0001
wire_type=000

更多的值类型

带符号整数

在前面的例子中,所有的 protocol buffer 类型都是 wire type 0的 varints 类型。然 而,带符号整数 (sint32 and sint64)与标准的整型(int32 and int64)在编码时有很大的 区别。如果你使用 int32 或者 int64来表示一个负数,结果需要10个字节,因为它会被认为 是一个非常大的无符号整数。为此,对带符号整数使用 ZigZag 编码会更高效。ZigZag 编码用无符号数来表示有符号数字,正数和负数交错,这就是 zigzag 这个词 的含义了。使用 zigzag 编码时,与0距离越近,编码时使用的值越小,从统计意义层面来看, 这样编码更高效,因为数据通信中绝对值小的数据交互占的比例要高。下面是 ZigZag 的编 码表:

Signed Original Encoded As
0 0
-1 1
1 2
-2 3
2147483647 4294967294
-2147483648 4294967295

换句话说,每个值 n 都使用以下方式编码:

1
2
sint32:(n << 1) ^ (n >> 31)
sint34:(n << 1) ^ (n >> 63)

注意到第二个位移部分(n >> 31)实际上是算术位移,所以若 n 是正数,算术位移后得到的数全部位都是0,若 n 是负数,算术位移后得到的数全部位都是1。

当 sint32或 sint64被解析时,它的值会被解码回原始的带符号数。

非 varint 数

非 varint 的数据类型就非常简单了, double 和 fixed64是 wire type 1,它会告诉解 析器期望的是一个固定的64位数据块;类似的,float 和 fixed32是 wire type 5,它会告 诉解析器期望的是一个固定的32位数据块。无论何种情况,值都是以 little-endian 小端对 齐的字节顺序方式存储。

字符串

wire type 2 (length-delimited) 意思是它的值先使用一个 varint 来表示编码后的 数据大小,而接下来就是相应长度的编码数据了。

1
2
3
message Test2 {
required string b = 2;
}

设置 b 的值为”testing”,你会得到下面编码:

1
12 07 74 65 73 74 69 6e 67

分析 key,首先第一个字节12为:

1
0001 0010

msb 为0,表示 key 仅用一个字节表示,去掉 msb:

1
001 0010

根据 key 的解码办法,得到:

1
2
field_number = 0010 = 2
wire_type = 010 = 2

分析第二个字节07,根据 varint 编码可知数据长度为7,然后紧跟后面的7个字节则为 “testing”。

嵌套消息

下面 Test3是一个嵌套消息:

1
2
3
4
5
6
7
message Test1 {
required int32 a = 1;
}

message Test3 {
required Test1 c = 3;
}

如果将 Test1的 a 字段赋值为150,则得到下面的编码:

1
1a 03 08 96 01

还记得编码原理刚开始提到的“一个简单的消息”吗?后面3个字节是否似曾相识?而Test3是嵌套消息,它的 wire type = 2,请参照该类型的编码办法即可解码。

可选与重复元素

Optional 可选字段

optional 可选元素在消息编码时可以有0或者1个键值对。

Repeated 重复字段

Repeated 字段序列化时,序列化的结果中包含0个或多个 key-value,每个 key-value 都包含字段的 tag。PB2.1.0版本中提供了另外一种 Repeated 字段,即带有[packed=true]属性的 Repeated 字段,这种字段又称为:packedrepeated field。packed repeated field 字段序列化时,有0个或多个元素,并且所有的 元素打包在一个 key-value 中,key-value 的类型采用 wire type 2 (length-delimited),每个元素不需要提供各自的 tag。
下面是一个例子:

1
2
3
message Test4 {
repeated int32 d = 4 [packed=true];
}

序列化字节码:

1
2
3
4
5
22        // tag (field number 4, wire type 2)
06 // payload size (6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)

相关序列化与反序列化技术的性能比较

本章节内容摘自thrift-protobuf-compare。作者提到,数值并非一切,仅供参考,实际上测试结果会受硬件,测试用例等影响。

总耗时(包括对象创建,序列化与反序列化):

total_time

序列化时间(每次序列化的时间,包括对象创建时间):

serialize_time

反序列化时间:

deserialize_time

序列化大小:

serialize_size

对象创建时间:

creat_obj_time

从上面的数据来看,protocol buffers 在序列化与反序列化性能及序列化后的数据大 小方面都不有错的表现。

参考资料

  1. https://developers.google.com/protocol-buffers/Protocol-buffers 的官方介绍,本教程的主要参考资料。
  2. http://www.ibm.com/developerworks/cn/linux/l-cn-gpb/Protocol Buffers 原理的中文介绍。
  3. http://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking#Object_Creation_Time Protocol Buffers 与其他相关技术的性能对比。
  4. http://www.cppblog.com/liquidx/archive/2009/06/23/88366.html
  5. http://blog.csdn.net/hguisu/article/details/20721109
感谢你的阅读,如果文章对你有帮助,可以请作者喝杯茶!