Protocol Buffers使用教程
ProtocolBuffer是用于结构化数据串行化的灵活、高效、自动的方法,类似 XML,不过它更小、更快、也更简单。你可以定义自己的数据结构,然后使用代码生成器生成的代码来读写这个数据结构。你甚至可以在无需重新部署程序的情况下更新数据结构。
你首先需要在一个.proto文件中定义你需要做串行化的数据结构信息。每个ProtocolBuffer信息是一小段逻辑记录,包含一系列的键值对。这里有个非常简单的.proto文件定义了个人信息:
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类型,还允许数据结构的分级。你可以指定可选字段,必选字段和重复字段。你可以在( http://code.google.com/apis/protocolbuffers/docs/proto.html )找到更多关于如何编写 .proto 文件的信息。
一旦你定义了自己的报文格式(message),你就可以运行ProtocolBuffer编译器,将你的.proto文件编译成特定语言的类。这些类提供了简单的方法访问每个字段(像是 query() 和 set_query() ),像是访问类的方法一样将结构串行化或反串行化。例如你可以选择C++语言,运行编译如上的协议文件生成类叫做 Person 。随后你就可以在应用中使用这个类来串行化的读取报文信息。你可以这么写代码:
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);
然后,你可以读取报文中的数据:
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参考( http://code.google.com/apis/protocolbuffers/docs/reference/overview.html )中找到完整的参考,而关于ProtocolBuffer的报文格式编码则可以在( http://code.google.com/apis/protocolbuffers/docs/encoding.html )中找到。
ProtocolBuffer拥有多项比XML更高级的串行化结构数据的特性,ProtocolBuffer:
更简单
小3-10倍
快20-100倍
更少的歧义
可以方便的生成数据存取类
例如,让我们看看如何在XML中建模Person的name和email字段:
对应的ProtocolBuffer报文则如下:
#ProtocolBuffer的文本表示
#这不是正常时使用的二进制数据
person {
name: "John Doe"
email: "[email protected]"
}
当这个报文编码到ProtocolBuffer的二进制格式( http://code.google.com/apis/protocolbuffers/docs/encoding.html )时(上面的文本仅用于调试和编辑),它只需要28字节和100-200ns的解析时间。而XML的版本需要69字节(除去空白)和5000-10000ns的解析时间。
当然,操作ProtocolBuffer也很简单:
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;
而XML的你需要:
cout << "Name: "
<< person.getElementsByTagName("name")->item(0)->innerText()
<< endl;
cout << "E-mail: "
<< person.getElementsByTagName("email")->item(0)->innerText()
<< end;
当然,ProtocolBuffer并不是在任何时候都比XML更合适,例如ProtocolBuffer无法对一个基于标记文本的文档建模(比如HTML),因为你根本没法方便的在文本中插入结构。另外,XML是便于人类阅读和编辑的,而ProtocolBuffer则不是。还有XML是自解释的,而 ProtocolBuffer仅在你拥有报文格式定义的.proto 文件时才有意义。
下载包( http://code.google.com/p/protobuf/downloads/ ),包含了Java、Python、C++的ProtocolBuffer编译器,用于生成你需要的IO类。构建和安装你的编译器,跟随README的指令就可以做到。
一旦你安装好了,就可以跟着编程指导( http://code.google.com/apis/protocolbuffers/docs/tutorials.html )来选择语言-随后就是使用ProtocolBuffer创建一个简单的应用了。
ProtocolBuffers最初是在Google开发的,用以解决索引服务器的请求、响应协议。在使用ProtocolBuffers之前,有一种格式用以处理请求和响应数据的编码和解码,并且支持多种版本的协议。而这最终导致了丑陋的代码,有如:
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系统和持续数据存储系统。
官方下载网站http://code.google.com/p/protobuf/downloads/
tar -zxvf protobuf-2.5.0.tar.gz
cd protobuf-2.5.0
./configure --prefix=$INSTALL_DIR
make
make install
更详细的安装步骤请参考源码目录下的README.txt。
安装完后在$INSTALL_DIR目录下生成三个目录:
bin include lib
bin目录下是protoc工具,用于将你的.proto文件编译成相应目标语言的编解码代码。
include和lib目录是protoc工具所依赖的头文件与库环境。
编译前,先准备你的.proto文件,这里暂时以源码目录下的examples/addressbook.proto文件为例。
protoc用法:
Usage: ./protoc [OPTION] PROTO_FILES
详情请使用./protoc --help
开始将你的.protoc文件编译成目标语言编解码文件:
mkdir c java python
./protoc --proto_path=./ --cpp_out=c/ --java_out=java/ --python_out=python/ addressbook.proto
官方的protoc工具仅支持C++/java/python三种语言,如果你使用其他语言,比如c#,php,你可以使用其他第三方工具。
命令简介:
./protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR path/to/file.proto
其中:
--proto_path:.proto所在的路径
--cpp_out:生成C++代码的路径
--java_out:生成java代码的路径
--python_out:生成python代码的路径
安装python的pb库:
在protobuf源码目录下可以找到一个目录python,没错,你需要进入此目录安装python的pb库。
cd protobuf-2.5.0/python
$PYTHONHOME/bin/python setup.py install --prefix=PYTHONHOME
使用--prefix选项指定你python的安装目录,然后静待安装完成。
以下是安装时常见的错误:
1. 安装提示error: package directory 'google/protobuf/compiler' does not exist
解决办法:执行mkdir google/protobuf/compiler创建compiler目录即可。
2. TBD
安装完python的pb库后,你就可以在源码的examples目录中,使用add_person.py和list_people.py来测试如何使用pb序列化与反序列化了。序列化与反序列化的相关接口分别为SerializeToString()和ParseFromString()。更多python相关的api请看https://developers.google.com/protocol-buffers/docs/reference/python/index 。
@waiting ...
在.proto文件里面用Protocol Buffers消息类型进行定义,每一个Protocol Buffers消息是信息的一条小的逻辑记录,里面包含一系列名称-值对。下面是一个简单的.proto文件:
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}
可以是基本类型,例如整形、浮点型,值类型可以是其他的Protocol Buffers的消息类型,这样你可以用分层的方式定义你的数据结构。
每个字段必须有一个唯一的标记,这个标记在序列化时会作为字段的标识出现在序列化后的二进制数据中。一旦该消息用于生产,字段的tag就不能修改了。标记的值小于15时序列化编码为一个字节,大于15会用到两个以上的字节。
消息的字段可以具有以下类型的属性:
required:消息中必须包含一个该字段的值
optional:可选字段,消息中可以有0个或一个该字段的值
repeated:重复字段,消息中可以有0个或多个该字段的值
你可以在你的消息格式里面添加新的域,而不用考虑向后兼容性,老的二进制流在解析的时候可以简单的忽略掉新增的域。因此如果你使用Protocol Buffers作为你数据格式的通信协议时,你可以扩展你的协议,而不用担心破坏现有的代码。
对于required,尽可能的少用,若一个字段开始时指定为required,则以后就不能修改为optional。建议将字段设置都设置为optional类型,这样字段的required规则可以放在业务代码中进行处理。
在单个 .proto 文件里可以定义多种消息类型:
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}
message SearchResponse {
...
}
.proto文件使用C/C++的注释风格:
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 |
你可以在https://developers.google.com/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字段,则该字段会使用默认值来代替。默认值可以显式指定如下:
optional int32 result_per_page = 3 [default = 10];
如果没有显式指定默认值,则使用数据类型的默认值来代替:比如,字符串类型的默认值是空字符串,布尔类型的默认值是false,数字类型的默认值是0,枚举类型的默认值是第一个被定义的枚举值。
使用enum关键字定义枚举类型,比如你想定义一个叫Corpus的枚举类型:
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消息类型的字段:
message SearchResponse {
repeated Result result = 1;
}
message Result {
required string url = 1;
optional string title = 2;
repeated string snippets = 3;
}
另外,你还可以使用import语句导入其他.proto文件定义的消息类型。
包含路径的import:
import "myproject/other_protos.proto";
不包含路径的import:
import public "new.proto";
import "other.proto";
当使用public域import时,编译器会去 -I/--proto_path标志指定的路径去查找,如果没有指定此标志,它会去编译器目录索引。通常情况下,建议你使用 --proto_path指定为项目的根路径,并且使用全名(包含命名空间或包名)import。
良好的命名风格让你的.proto文件更加易读。
使用CamelCase方式命名消息名称,使用下划线分隔的名字来命名消息的字段,例如:
message SongServerRequest {
required string song_name = 1;
}
使用CamelCase方式命名消息名称,例如PhoneType
使用大写字母+下划线来命名枚举值,例如:
enum PhoneType {
TYPE_MOBILE = 0;
TYPE_HOME = 1;
TYPE_WORK = 2;
}
如果你的.proto文件定义了RPC服务,你可以使用CamelCase的方式命名你的服务名与RPC方法名:
service FooService {
rpc GetSomething(FooRequest) returns (FooResponse);
}
本节主要介绍protocol buffer消息转换成二进制格式的原理。如果仅需要了解怎么使用protocol buffers,你可以无需理解这些原理,但了解这些能帮助你理解protocol buffers对编码后数据大小的影响。
下面是一个简单的消息定义:
message Test1 {
required int32 a = 1;
}
若你创建了一个Test1的消息,然后a赋值为150,序列化后,你会发现消息被编码为下面3个字节:
08 96 01
看起来非常小巧与数字化,但它代表什么意义?继续看下去,好戏还在后头……
为了理解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,以下是它的解码过程:
首先按照字节分组:
1010 1100 0000 0010
去掉msb
010 1100 000 0010
将字节反向排列
000 0010 010 1100
重新组合成字节
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位后如下:
000 1000
则field_number和wire type分别为:
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都使用以下方式编码:
sint32:(n << 1) ^ (n >> 31)
sint34:(n << 1) ^ (n >> 63)
注意到第二个位移部分(n >> 31)实际上是算术位移,所以若n是正数,算术位移后得到的数全部位都是0,若n是负数,算术位移后得到的数全部位都是1。
当sint32或sint64被解析时,它的值会被解码回原始的带符号数。
非varint的数据类型就非常简单了, double和fixed64是wire type 1,它会告诉解析器期望的是一个固定的64位数据块;类似的,float和fixed32是wire type 5,它会告诉解析器期望的是一个固定的32位数据块。无论何种情况,值都是以little-endian小端对齐的字节顺序方式存储。
wire type 2 (length-delimited) 意思是它的值先使用一个varint 来表示编码后的数据大小,而接下来就是相应长度的编码数据了。
message Test2 {
required string b = 2;
}
设置b的值为"testing",你会得到下面编码:
12 07 74 65 73 74 69 6e 67
分析key,首先第一个字节12为:
0001 0010
msb为0,表示key仅用一个字节表示,去掉msb:
001 0010
根据key的解码办法,得到:
field_number = 0010 = 2
wire_type = 010 = 2
分析第二个字节07,根据varint编码可知数据长度为7,然后紧跟后面的7个字节则为"testing"。
下面Test3是一个嵌套消息:
message Test1 {
required int32 a = 1;
}
message Test3 {
required Test1 c = 3;
}
如果将Test1的a字段赋值为150,则得到下面的编码:
1a 03 08 96 01
还记得编码原理刚开始提到的“一个简单的消息”吗?后面3个字节是否似曾相识?而Test3是嵌套消息,它的wire type = 2,请参照该类型的编码办法即可解码。
optional可选元素在消息编码时可以有0或者1个键值对。
Repeated 字段序列化时,序列化的结果中包含0个或多个key-value,每个key-value都包含字段的tag。
PB2.1.0版本中提供了另外一种Repeated 字段,即带有[packed=true]属性的 Repeated 字段,这种字段又称为:packed repeated field。packed repeated field字段序列化时,有0个或多个元素,并且所有的元素打包在一个key-value中,key-value的类型采用wire type 2 (length-delimited),每个元素不需要提供各自的tag。
下面是一个例子:
message Test4 {
repeated int32 d = 4 [packed=true];
}
序列化字节码:
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)
本章节内容摘自http://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking#Object_Creation_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
http://blog.csdn.net/hguisu/article/details/20721109