Google数据交换格式:ProtoBuf

Protocol Buffer

 

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

 

一个例子

比如有个电子商务的系统(假设用C++实现),其中的模块A需要发送大量的订单信息给模块B,通讯的方式使用socket。

假设订单包括如下属性:
--------------------------------
  时间:time(用整数表示)
  客户id:userid(用整数表示)
  交易金额:price(用浮点数表示)
  交易的描述:desc(用字符串表示)
--------------------------------

如果使用protobuf实现,首先要写一个proto文件(不妨叫Order.proto),在该文件中添加一个名为"Order"的message结构,用来描述通讯协议中的结构化数据。该文件的内容大致如下:

message Order
{
  required int32 time = 1;
  required int32 userid = 2;
  required float price = 3;
  optional string desc = 4;
}

然后,使用protobuf内置的编译器编译该proto。由于本例子的模块是C++,你可以通过protobuf编译器让它生成 C++语言的“订单包装类”(一般来说,一个message结构会生成一个包装类)。

然后你使用类似下面的代码来序列化/解析该订单包装类:

发送方:

Order order;
order.set_time(XXXX);
order.set_userid(123);
order.set_price(100.0f);
order.set_desc("a test order");
string sOrder;
order.SerailzeToString(&sOrder);

然后调用某种socket的通讯库把序列化之后的字符串sOrder发送出去;

 

接收方:

string sOrder;
// 先通过网络通讯库接收到数据,存放到某字符串sOrder
// ......
Order order;
if(order.ParseFromString(sOrder)){ // 解析该字符串
  cout << "userid:" << order.userid() << endl
          << "desc:" << order.desc() << endl;
} else {
  cerr << "parse error!" << endl;
}

 

有了这种代码生成机制,开发人员再也不用吭哧吭哧地编写那些协议解析的代码了。万一将来需求发生变更,要求给订单再增加一个“状态”的属性,那只需要在Order.proto文件中增加一行代码。对于发送方,只要增加一行设置状态的代码;对于接收方只要增加一行读取状态的代码。另外,如果通讯双方使用不同的编程语言来实现,使用这种机制可以有效确保两边的模块对于协议的处理是一致的。

从某种意义上讲,可以把proto文件看成是描述通讯协议的规格说明书(或者叫接口规范)。这种伎俩其实老早就有了,搞过微软的COM编程或者接触过CORBA的同学,应该都能从中看到IDL的影子。它们的思想是相通滴。

ProtoBuf支持向后兼容(backward compatible)和向前兼容(forward compatible):

  1. 向后兼容,比如说,当接收方升级了之后,它能够正确识别发送方发出的老版本的协议。由于老版本没有“状态”这个属性,在扩充协议时,可以考 虑把“状态”属性设置成非必填 的(optional),或者给“状态”属性设置一个缺省值;
  2. 向前兼容,比如说,当发送方升级了之后,接收方能够正常识别发送方发出的新版本的协议。这时候,新增加的“状态”属性会被忽略;

向后兼容和向前兼容有啥用捏?俺举个例子:当你维护一个很庞大的分布式系统时,由于你无法同时 升级所有 模块,为了保证在升级过程中,整个系统能够尽可能不受影响,就需要尽量保证通讯协议的向后兼容或向前兼容。

 

 

proto文件

如上面的例子,使用protobuf,首先需要在一个 .proto 文件中定义你需要做串行化的数据结构信息。每个ProtocolBuffer信息是一小段逻辑记录,包含一系列的键值对。

例如:

syntax = "proto3";

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; }

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

Person person;
person.set_name("John Doe");
person.set_id(1234);
person.set_email("jdoe@example.com");
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作为通信协议,你可以无须担心破坏现有代码的情况下扩展协议。

 

当用protocol buffer编译器来运行.proto文件时,编译器将生成所选择语言的代码,这些代码可以操作在.proto文件中定义的消息类型,包括获取、设置字段值,将消息序列化到一个输出流中,以及从一个输入流中解析消息。

  • 对C++来说,编译器会为每个.proto文件生成一个.h文件和一个.cc文件,.proto文件中的每一个消息有一个对应的类。
  • 对Java来说,编译器为每一个消息类型生成了一个.java文件,以及一个特殊的Builder类(该类是用来创建消息类接口的)。
  • 对Python来说,有点不太一样——Python编译器为.proto文件中的每个消息类型生成一个含有静态描述符的模块,,该模块与一个元类(metaclass)在运行时(runtime)被用来创建所需的Python数据访问类。
  • 对go来说,编译器会位每个消息类型生成了一个.pd.go文件。

 

 

protobuf3 消息定义

message由至少一个字段组合而成,类似于C语言中的结构。每个字段都有一定的格式:

字段规则 | 数据类型 | 字段名称 | = | 字段编码值 | [字段默认值]

 

字段规则:

  • singular:格式良好的消息可以包含该字段中的零个或一个(但不超过一个);
  • repeated:此字段可以在格式良好的消息中重复任意次数(包括零),将保留重复值的顺序;

 

字段名称:

字段名称的命名与C、C++、Java等语言的变量命名方式几乎是相同的。
protobuf建议字段的命名采用以下划线分割的驼峰式,例如 first_name 而不是firstName。

 

字段编码值:

编码值的取值范围为 1~2^32(4294967296)。

消息中的字段的编码值无需连续,只要是合法的,并且不能在同一个消息中有字段包含相同的编码值。

[1,15]之内的标识号在编码的时候会占用一个字节。[16,2047]之内的标识号则占用2个字节。所以应该为那些频繁出现的消息元素保留 [1,15]之内的标识号。

切记:要为将来有可能添加的、频繁出现的标识号预留一些标识号。

 

字段默认值:

当一个消息被解析的时候,如果被编码的信息不包含一个特定的singular元素,被解析的对象锁对应的域被设置位一个默认值,对于不同类型指定如下:

  • 对于string,默认是一个空string
  • 对于bytes,默认是一个空的bytes
  • 对于bool,默认是false
  • 对于数值类型,默认是0
  • 对于枚举,默认是第一个定义的枚举值,必须为0;
  • 对于消息类型(message),域没有被设置,确切的消息是根据语言确定的;

注:对于标量消息域,一旦消息被解析,就无法判断域释放被设置为默认值(例如,例如boolean值是否被设置为false)还是根本没有被设置。你应该在定义你的消息类型时非常注意。例如,比如你不应该定义boolean的默认值false作为任何行为的触发方式。也应该注意如果一个标量消息域被设置为标志位,这个值不应该被序列化传输。

对于可重复域的默认值是空(通常情况下是对应语言中空列表)。

 

 

另外:

  • message消息支持嵌套定义,消息可以包含另一个消息作为其字段,也可以在消息内定义一个新的消息;
  • proto定义文件支持import导入其它proto定义文件;
  • 每个proto文件指定一个package名称,对于java解析为java中的包。对于C++则解析为名称空间。

 

 

 标量数值类型

.proto TypeNotesC++ Java Python Go
double   double double float float64
float   float float float float32
int32 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代 int32 int int int32
uint32 使用变长编码 uint32 int int/long uint32
uint64 使用变长编码 uint64 long int/long uint64
sint32 使用变长编码,这些编码在负值时比int32高效的多 int32 int int int32
sint64 使用变长编码,有符号的整型值。编码时比通常的int64高效。 int64 long int/long int64
fixed32 总是4个字节,如果数值总是比总是比228大的话,这个类型会比uint32高效。 uint32 int int uint32
fixed64 总是8个字节,如果数值总是比总是比256大的话,这个类型会比uint64高效。 uint64 long int/long uint64
sfixed32 总是4个字节 int32 int int int32
sfixed64 总是8个字节 int64 long int/long int64
bool   bool boolean bool bool
string 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。 string String str/unicode string
bytes 可能包含任意顺序的字节数据。 string ByteString str []byte

 

 其中:

varint(type=0),动态类型

  1. 每个字节第一位表示有无后续字节,有为1,无为0,(双字节,低字节在前,高字节在后);
  2. 剩余7位倒序合并。
举例: 300 的二进制为 10 0101100

第一位:1(有后续) + 0101100

第二位:0(无后续) + 0000010

最终结果: 101011000000010

 

 

枚举类型

在下面的例子中,在消息格式中添加了一个叫做Corpus的枚举类型——它含有所有可能的值 ——以及一个类型为Corpus的字段:

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

如你所见,Corpus枚举的第一个常量映射为0:每个枚举类型必须将其第一个类型映射为0,这是因为:

  • 必须有有一个0值,我们可以用这个0值作为默认值。
  • 这个零值必须为第一个元素,为了兼容proto2语义,枚举类的第一个值总是默认值。

 

 

 

 

 

posted @ 2016-01-25 14:19  如果的事  阅读(8290)  评论(0编辑  收藏  举报