Protobuf | Google Protocol Buffer 的使用和原理

Google Protocol Buffer( 简称 Protobuf) 是 Google 公司内部的混合语言数据标准,目前已经正在使用的有超过 48,162 种报文格式定义和超过 12,183 个 .proto 文件。他们用于 RPC 系统和持续数据存储系统。

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。目前提供了 C++、Java、Python 三种语言的 API。

一、为什么使用Protocol Buffer?

      比如:我们的客户端程序是使用Java开发的,可能运行自不同的平台,如:Linux、Windows或者是Android,而我们的服务器程序通常是基于Linux平台并使用C++开发完成的。在这两种程序之间进行数据通讯时存在多种方式用于设计消息格式,如:
      1. 直接传递C/C++语言中一字节对齐的结构体数据,只要结构体的声明为定长格式,那么该方式对于C/C++程序而言就非常方便了,仅需将接收到的数据按照结构体类型强行转换即可。事实上对于变长结构体也不会非常麻烦。在发送数据时,也只需定义一个结构体变量并设置各个成员变量的值之后,再以char*的方式将该二进制数据发送到远端。反之,该方式对于Java开发者而言就会非常繁琐,首先需要将接收到的数据存于ByteBuffer之中,再根据约定的字节序逐个读取每个字段,并将读取后的值再赋值给另外一个值对象中的域变量,以便于程序中其他代码逻辑的编写。对于该类型程序而言,联调的基准是必须客户端和服务器双方均完成了消息报文构建程序的编写后才能展开,而该设计方式将会直接导致Java程序开发的进度过慢。即便是Debug阶段,也会经常遇到Java程序中出现各种域字段拼接的小错误。

     2. 使用SOAP协议(WebService)作为消息报文的格式载体,由该方式生成的报文是基于文本格式的,同时还存在大量的XML描述信息,因此将会大大增加网络IO的负担。又由于XML解析的复杂性,这也会大幅降低报文解析的性能。总之,使用该设计方式将会使系统的整体运行性能明显下降。

     对于以上两种方式所产生的问题,Protocol Buffer均可以很好的解决,不仅如此,Protocol Buffer还有一个非常重要的优点就是可以保证同一消息报文新旧版本之间的兼容性。

 二、定义第一个Protocol Buffer消息

创建扩展名为.proto的文件,如:MyMessage.proto,并将以下内容存入该文件中。      

message LogonReqMessage {
          required int64 acctID = 1;
          required string passwd = 2;
      }

 这里将给出以上消息定义的关键性说明。

      1. message是消息定义的关键字,等同于C++中的struct/class,或是Java中的class
      2. LogonReqMessage为消息的名字,等同于结构体名或类名
      3. required前缀表示该字段为必要字段,既在序列化和反序列化之前该字段必须已经被赋值。与此同时,在Protocol Buffer中还存在另外两个类似的关键字,optionalrepeated,带有这两种限定符的消息字段则没有required字段这样的限制。相比于optional,repeated主要用于表示数组字段。
      4. int64string分别表示长整型和字符串型的消息字段,在Protocol Buffer中存在一张类型对照表,既Protocol Buffer中的数据类型与其他编程语言(C++/Java)中所用类型的对照。该对照表中还将给出在不同的数据场景下,哪种类型更为高效。该对照表将在后面给出。
      5. acctIDpasswd分别表示消息字段名,等同于Java中的域变量名,或是C++中的成员变量名。
      6. 标签数字12则表示不同的字段在序列化后的二进制数据中的布局位置。在该例中,passwd字段编码后的数据一定位于acctID之后。需要注意的是该值在同一message中不能重复。另外,对于Protocol Buffer而言,标签值为1到15的字段在编码时可以得到优化,既标签值和类型信息仅占有一个byte,标签范围是16到2047的将占有两个bytes,而Protocol Buffer可以支持的字段数量则为2的29次方减一。有鉴于此,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省编码后的字节数量。

    三、定义第二个(含有枚举字段)Protocol Buffer消息

 //在定义Protocol Buffer的消息时,可以使用和C++/Java代码同样的方式添加注释。
      enum UserStatus {
          OFFLINE = 0; //表示处于离线状态的用户
          ONLINE = 1;  //表示处于在线状态的用户
      }
      message UserInfo {
          required int64 acctID = 1;
          required string name = 2;
          required UserStatus status = 3;
      }

这里将给出以上消息定义的关键性说明(仅包括上一小节中没有描述的。

      1. enum是枚举类型定义的关键字,等同于C++/Java中的enum。
      2. UserStatus为枚举的名字。
      3. 和C++/Java中的枚举不同的是,枚举值之间的分隔符是分号,而不是逗号。
      4. OFFLINE/ONLINE为枚举值。
      5. 0和1表示枚举值所对应的实际整型值,和C/C++一样,可以为枚举值指定任意整型值,而无需总是从0开始定义。如:

 enum OperationCode {
          LOGON_REQ_CODE = 101;
          LOGOUT_REQ_CODE = 102;
          RETRIEVE_BUDDIES_REQ_CODE = 103;
    
          LOGON_RESP_CODE = 1001;
          LOGOUT_RESP_CODE = 1002;
          RETRIEVE_BUDDIES_RESP_CODE = 1003;
      }

 四、定义第三个(含有嵌套消息字段)Protocol Buffer消息

我们可以在同一个.proto文件中定义多个message,这样便可以很容易的实现嵌套消息的定义。如:

enum UserStatus {
          OFFLINE = 0;
          ONLINE = 1;
      }
      message UserInfo {
          required int64 acctID = 1;
          required string name = 2;
          required UserStatus status = 3;
      }
      message LogonRespMessage {
          required LoginResult logonResult = 1;
          required UserInfo userInfo = 2;
      }

这里将给出以上消息定义的关键性说明(仅包括上两小节中没有描述的)。
      1. LogonRespMessage消息的定义中包含另外一个消息类型作为其字段,如UserInfo userInfo。
      2. 上例中的UserInfoLogonRespMessage被定义在同一个.proto文件中,那么我们是否可以包含在其他.proto文件中定义的message呢?Protocol Buffer提供了另外一个关键字import,这样我们便可以将很多通用的message定义在同一个.proto文件中,而其他消息定义文件可以通过import的方式将该文件中定义的消息包含进来,如:
      import "myproject/CommonMessages.proto"

五、限定符(required/optional/repeated)的基本规则

      1. 在每个消息中必须至少留有一个required类型的字段。 
      2. 每个消息中可以包含0个或多个optional类型的字段。
      3. repeated表示的字段可以包含0个或多个数据。需要说明的是,这一点有别于C++/Java中的数组,因为后两者中的数组必须包含至少一个元素。
      4. 如果打算在原有消息协议中添加新的字段,同时还要保证老版本的程序能够正常读取或写入,那么对于新添加的字段必须是optional或repeated。道理非常简单,老版本程序无法读取或写入新增的required限定符的字段。

六、类型对照表

一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:

.proto类型

Java 类型

C++类型

备注

double

double

double

float

float

float

int32

int

int32

使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用sint32。

int64

long

int64

使用可变长编码方式。编码负数时不够高效——如果你的字段可能含有负数,那么请使用sint64。

uint32

int[1]

uint32

Uses variable-length encoding.

uint64

long[1] uint64 Uses variable-length encoding.

sint32

int

int32

使用可变长编码方式。有符号的整型值。编码时比通常的int32高效。

sint64

long

int64

使用可变长编码方式。有符号的整型值。编码时比通常的int64高效。

fixed32

int[1]

uint32

总是4个字节。如果数值总是比总是比228大的话,这个类型会比uint32高效。

fixed64

long[1]

uint64

总是8个字节。如果数值总是比总是比256大的话,这个类型会比uint64高效。

sfixed32

int

int32

总是4个字节。

sfixed64

long

int64

总是8个字节。

bool

boolean

bool

string

String

string

一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。

bytes

ByteString

string

可能包含任意顺序的字节数据。

七、Protocol Buffer消息升级原则

        在实际的开发中会存在这样一种应用场景,既消息格式因为某些需求的变化而不得不进行必要的升级,但是有些使用原有消息格式的应用程序暂时又不能被立刻升级,这便要求我们在升级消息格式时要遵守一定的规则,从而可以保证基于新老消息格式的新老程序同时运行。规则如下:
      1. 不要修改已经存在字段的标签号。
      2. 任何新添加的字段必须是optional和repeated限定符,否则无法保证新老程序在互相传递消息时的消息兼容性。
      3. 在原有的消息中,不能移除已经存在的required字段,optional和repeated类型的字段可以被移除,但是他们之前使用的标签号必须被保留,不能被新的字段重用。
      4. int32、uint32、int64、uint64和bool等类型之间是兼容的,sint32和sint64是兼容的,string和bytes是兼容的,fixed32和sfixed32,以及fixed64和sfixed64之间是兼容的,这意味着如果想修改原有字段的类型时,为了保证兼容性,只能将其修改为与其原有类型兼容的类型,否则就将打破新老消息格式的兼容性。
      5. optional和repeated限定符也是相互兼容的。

八、Packages

      我们可以在.proto文件中定义包名,如:
      package ourproject.lyphone;
      该包名在生成对应的C++文件时,将被替换为名字空间名称,既namespace ourproject { namespace lyphone。而在生成的Java代码文件中将成为包名

九、Options

       Protocol Buffer允许我们在.proto文件中定义一些常用的选项,这样可以指示Protocol Buffer编译器帮助我们生成更为匹配的目标语言代码。Protocol Buffer内置的选项被分为以下三个级别:
      1. 文件级别,这样的选项将影响当前文件中定义的所有消息和枚举。
      2. 消息级别,这样的选项仅影响某个消息及其包含的所有字段。
      3. 字段级别,这样的选项仅仅响应与其相关的字段。
      下面将给出一些常用的Protocol Buffer选项。
      1. option java_package = "com.companyname.projectname";
      java_package是文件级别的选项,通过指定该选项可以让生成Java代码的包名为该选项值,如上例中的Java代码包名为com.companyname.projectname。与此同时,生成的Java文件也将会自动存放到指定输出目录下的com/companyname/projectname子目录中。如果没有指定该选项,Java的包名则为package关键字指定的名称。该选项对于生成C++代码毫无影响。
      2. option java_outer_classname = "LYPhoneMessage";
      java_outer_classname是文件级别的选项,主要功能是显示的指定生成Java代码的外部类名称。如果没有指定该选项,Java代码的外部类名称为当前文件的文件名部分,同时还要将文件名转换为驼峰格式,如:my_project.proto,那么该文件的默认外部类名称将为MyProject。该选项对于生成C++代码毫无影响。
      注:主要是因为Java中要求同一个.java文件中只能包含一个Java外部类或外部接口,而C++则不存在此限制。因此在.proto文件中定义的消息均为指定外部类的内部类,这样才能将这些消息生成到同一个Java文件中。在实际的使用中,为了避免总是输入该外部类限定符,可以将该外部类静态引入到当前Java文件中,如:import static com.company.project.LYPhoneMessage.*
      3. option optimize_for = LITE_RUNTIME;
      optimize_for是文件级别的选项,Protocol Buffer定义三种优化级别SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下是SPEED。
      SPEED: 表示生成的代码运行效率高,但是由此生成的代码编译后会占用更多的空间。
      CODE_SIZE: 和SPEED恰恰相反,代码运行效率较低,但是由此生成的代码编译后会占用更少的空间,通常用于资源有限的平台,如Mobile。
      LITE_RUNTIME: 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲Protocol Buffer提供的反射功能为代价的。因此我们在C++中链接Protocol Buffer库时仅需链接libprotobuf-lite,而非libprotobuf。在Java中仅需包含protobuf-java-2.4.1-lite.jar,而非protobuf-java-2.4.1.jar。
      注:对于LITE_MESSAGE选项而言,其生成的代码均将继承自MessageLite,而非Message。    
      4. [pack = true]: 因为历史原因,对于数值型的repeated字段,如int32、int64等,在编码时并没有得到很好的优化,然而在新近版本的Protocol Buffer中,可通过添加[pack=true]的字段选项,以通知Protocol Buffer在为该类型的消息对象编码时更加高效。如:
      repeated int32 samples = 4 [packed=true]。
      注:该选项仅适用于2.3.0以上的Protocol Buffer。
      5. [default = default_value]: optional类型的字段,如果在序列化时没有被设置,或者是老版本的消息中根本不存在该字段,那么在反序列化该类型的消息是,optional的字段将被赋予类型相关的缺省值,如bool被设置为false,int32被设置为0。Protocol Buffer也支持自定义的缺省值,如:
      optional int32 result_per_page = 3 [default = 10]。

 十、命令行编译工具

      protoc--proto_path=IMPORT_PATH --cpp_out=DST_DIR --java_out=DST_DIR --python_out=DST_DIR path/to/file.proto
      这里将给出上述命令的参数解释。
      1. protoc为Protocol Buffer提供的命令行编译工具。
      2. --proto_path等同于-I选项,主要用于指定待编译的.proto消息定义文件所在的目录,该选项可以被同时指定多个。
      3. --cpp_out选项表示生成C++代码,--java_out表示生成Java代码,--python_out则表示生成Python代码,其后的目录为生成后的代码所存放的目录。
      4. path/to/file.proto表示待编译的消息定义文件。
      注:对于C++而言,通过Protocol Buffer编译工具,可以将每个.proto文件生成出一对.h和.cc的C++代码文件。生成后的文件可以直接加载到应用程序所在的工程项目中。如:MyMessage.proto生成的文件为MyMessage.pb.h和MyMessage.pb.cc。

 十一、message生成的C++代码示例

  1.简单message生成的C++代码

  这里先定义一个最简单的message,其中只是包含原始类型的字段。     

 option optimize_for = LITE_RUNTIME;
      message LogonReqMessage {
          required int64 acctID = 1;
          required string passwd = 2;
      }

 由于我们在MyMessage文件中定义选项optimize_for的值为LITE_RUNTIME,因此由该.proto文件生成的所有C++类的父类均       为::google::protobuf::MessageLite,而非::google::protobuf::Message。MessageLite类是Message的父类,在MessageLite中将缺少Protocol Buffer对反射的支持,而此类功能均在Message类中提供了具体的实现。对于我们的项目而言,整个系统相对比较封闭,不会和更多的外部程序进行交互,与此同时,我们的客户端部分又是运行在Android平台,有鉴于此,我们考虑使用LITE版本的Protocol Buffer。这样不仅可以得到更高编码效率,而且生成代码编译后所占用的资源也会更少,至于反射所能带来的灵活性和极易扩展性,对于该项目而言完全可以忽略。下面我们来看一下由message LogonReqMessage生成的C++类的部分声明,以及常用方法的说明性注释。

 class LogonReqMessage : public ::google::protobuf::MessageLite {
     public:
          LogonReqMessage();
          virtual ~LogonReqMessage();
  
          // implements Message ----------------------------------------------
          //下面的成员函数均实现自MessageLite中的虚函数。
          //创建一个新的LogonReqMessage对象,等同于clone。
          LogonReqMessage* New() const;
         //用另外一个LogonReqMessage对象初始化当前对象,等同于赋值操作符重载(operator=)
         void CopyFrom(const LogonReqMessage& from);
         //清空当前对象中的所有数据,既将所有成员变量置为未初始化状态。
         void Clear();
         //判断当前状态是否已经初始化。
         bool IsInitialized() const;
         //在给当前对象的所有变量赋值之后,获取该对象序列化后所需要的字节数。
         int ByteSize() const;
         //获取当前对象的类型名称。
         ::std::string GetTypeName() const;
 
         // required int64 acctID = 1;
         //下面的成员函数都是因message中定义的acctID字段而生成。
         //这个静态成员表示AcctID的标签值。命名规则是k + FieldName(驼峰规则) + FieldNumber。
         static const int kAcctIDFieldNumber = 1;
         //如果acctID字段已经被设置返回true,否则false。
         inline bool has_acctid() const;
         //执行该函数后has_acctid函数将返回false,而下面的acctid函数则返回acctID的缺省值。
         inline void clear_acctid();
         //返回acctid字段的当前值,如果没有设置则返回int64类型的缺省值。
         inline ::google::protobuf::int64 acctid() const;
         //为acctid字段设置新值,调用该函数后has_acctid函数将返回true。
         inline void set_acctid(::google::protobuf::int64 value);
     
         // required string passwd = 2;
         //下面的成员函数都是因message中定义的passwd字段而生成。这里生成的函数和上面acctid
         //生成的那组函数基本相似。因此这里只是列出差异部分。
         static const int kPasswdFieldNumber = 2;
         inline bool has_passwd() const;
         inline void clear_passwd();
         inline const ::std::string& passwd() const;
         inline void set_passwd(const ::std::string& value);
         //对于字符串类型字段设置const char*类型的变量值。
         inline void set_passwd(const char* value);
         inline void set_passwd(const char* value, size_t size);
         //可以通过返回值直接给passwd对象赋值。在调用该函数之后has_passwd将返回true。
         inline ::std::string* mutable_passwd();
         //释放当前对象对passwd字段的所有权,同时返回passwd字段对象指针。调用此函数之后,passwd字段对象
         //的所有权将移交给调用者。此后再调用has_passwd函数时将返回false。
         inline ::std::string* release_passwd();
     private:
         ... ... 
     };

下面是读写LogonReqMessage对象的C++测试代码和说明性注释。

 1     void testSimpleMessage()
 2     {
 3         printf("==================This is simple message.================\n");
 4         //序列化LogonReqMessage对象到指定的内存区域。
 5         LogonReqMessage logonReq;
 6         logonReq.set_acctid(20);
 7         logonReq.set_passwd("Hello World");
 8         //提前获取对象序列化所占用的空间并进行一次性分配,从而避免多次分配
 9         //而造成的性能开销。通过该种方式,还可以将序列化后的数据进行加密。
10         //之后再进行持久化,或是发送到远端。
11         int length = logonReq.ByteSize();
12         char* buf = new char[length];
13         logonReq.SerializeToArray(buf,length);
14         //从内存中读取并反序列化LogonReqMessage对象,同时将结果打印出来。
15         LogonReqMessage logonReq2;
16         logonReq2.ParseFromArray(buf,length);
17         printf("acctID = %I64d, password = %s\n",logonReq2.acctid(),logonReq2.passwd().c_str());
18         delete [] buf;
19     }

2、嵌套message生成的C++代码

enum UserStatus {
          OFFLINE = 0;
          ONLINE = 1;
      }
      enum LoginResult {
          LOGON_RESULT_SUCCESS = 0;
          LOGON_RESULT_NOTEXIST = 1;
          LOGON_RESULT_ERROR_PASSWD = 2;
          LOGON_RESULT_ALREADY_LOGON = 3;
          LOGON_RESULT_SERVER_ERROR = 4;
      }
      message UserInfo {
          required int64 acctID = 1;
          required string name = 2;
          required UserStatus status = 3;
      }
      message LogonRespMessage {
          required LoginResult logonResult = 1;
          required UserInfo userInfo = 2; //这里嵌套了UserInfo消息。
      }

对于上述消息生成的C++代码,UserInfo因为只是包含了原始类型字段,因此和上例中的LogonReqMessage没有太多的差别,这里也就不在重复列出了。由于LogonRespMessage消息中嵌套了UserInfo类型的字段,在这里我们将仅仅给出该消息生成的C++代码和关键性注释。

1     class LogonRespMessage : public ::google::protobuf::MessageLite {
 2     public:
 3         LogonRespMessage();
 4         virtual ~LogonRespMessage();
 5     
 6         // implements Message ----------------------------------------------
 7         ... ... //这部分函数和之前的例子一样。
 8         
 9         // required .LoginResult logonResult = 1;
10         //下面的成员函数都是因message中定义的logonResult字段而生成。
11         //这一点和前面的例子基本相同,只是类型换做了枚举类型LoginResult。    
12         static const int kLogonResultFieldNumber = 1;
13         inline bool has_logonresult() const;
14         inline void clear_logonresult();
15         inline LoginResult logonresult() const;
16         inline void set_logonresult(LoginResult value);
17         
18         // required .UserInfo userInfo = 2;
19         //下面的成员函数都是因message中定义的UserInfo字段而生成。
20         //这里只是列出和非消息类型字段差异的部分。
21         static const int kUserInfoFieldNumber = 2;
22         inline bool has_userinfo() const;
23         inline void clear_userinfo();
24         inline const ::UserInfo& userinfo() const;
25         //可以看到该类并没有生成用于设置和修改userInfo字段set_userinfo函数,而是将该工作
26         //交给了下面的mutable_userinfo函数。因此每当调用函数之后,Protocol Buffer都会认为
27         //该字段的值已经被设置了,同时has_userinfo函数亦将返回true。在实际编码中,我们可以
28         //通过该函数返回userInfo字段的内部指针,并基于该指针完成userInfo成员变量的初始化工作。
29         inline ::UserInfo* mutable_userinfo();
30         inline ::UserInfo* release_userinfo();
31     private:
32         ... ...
33     };     

下面是读写LogonRespMessage对象的C++测试代码和说明性注释。

 1     void testNestedMessage()
 2     {
 3         printf("==================This is nested message.================\n");
 4         LogonRespMessage logonResp;
 5         logonResp.set_logonresult(LOGON_RESULT_SUCCESS);
 6         //如上所述,通过mutable_userinfo函数返回userInfo字段的指针,之后再初始化该对象指针。
 7         UserInfo* userInfo = logonResp.mutable_userinfo();
 8         userInfo->set_acctid(200);
 9         userInfo->set_name("Tester");
10         userInfo->set_status(OFFLINE);
11         int length = logonResp.ByteSize();
12         char* buf = new char[length];
13         logonResp.SerializeToArray(buf,length);
14     
15         LogonRespMessage logonResp2;
16         logonResp2.ParseFromArray(buf,length);
17         printf("LogonResult = %d, UserInfo->acctID = %I64d, UserInfo->name = %s, UserInfo->status = %d\n"
18             ,logonResp2.logonresult(),logonResp2.userinfo().acctid(),logonResp2.userinfo().name().c_str(),logonResp2.userinfo().status());
19         delete [] buf;
20     }    

3、repeated嵌套message生成的C++代码

message BuddyInfo {
          required UserInfo userInfo = 1;
          required int32 groupID = 2;
      }
      message RetrieveBuddiesResp {
          required int32 buddiesCnt = 1;
          repeated BuddyInfo buddiesInfo = 2;
      }

        对于上述消息生成的代码,我们将只是针对RetrieveBuddiesResp消息所对应的C++代码进行详细说明,其余部分和前面小节的例子基本相同,可直接参照。而对于RetrieveBuddiesResp类中的代码,我们也仅仅是对buddiesInfo字段生成的代码进行更为详细的解释。

1     class RetrieveBuddiesResp : public ::google::protobuf::MessageLite {
 2     public:
 3         RetrieveBuddiesResp();
 4         virtual ~RetrieveBuddiesResp();
 5 
 6         ... ... //其余代码的功能性注释均可参照前面的例子。
 7             
 8         // repeated .BuddyInfo buddiesInfo = 2;
 9         static const int kBuddiesInfoFieldNumber = 2;
10         //返回数组中成员的数量。
11         inline int buddiesinfo_size() const;
12         //清空数组中的所有已初始化成员,调用该函数后,buddiesinfo_size函数将返回0。
13         inline void clear_buddiesinfo();
14         //返回数组中指定下标所包含元素的引用。
15         inline const ::BuddyInfo& buddiesinfo(int index) const;
16         //返回数组中指定下标所包含元素的指针,通过该方式可直接修改元素的值信息。
17         inline ::BuddyInfo* mutable_buddiesinfo(int index);
18         //像数组中添加一个新元素。返回值即为新增的元素,可直接对其进行初始化。
19         inline ::BuddyInfo* add_buddiesinfo();
20         //获取buddiesInfo字段所表示的容器,该函数返回的容器仅用于遍历并读取,不能直接修改。
21         inline const ::google::protobuf::RepeatedPtrField< ::BuddyInfo >&
22           buddiesinfo() const;
23         //获取buddiesInfo字段所表示的容器指针,该函数返回的容器指针可用于遍历和直接修改。
24         inline ::google::protobuf::RepeatedPtrField< ::BuddyInfo >*
25           mutable_buddiesinfo();
26     private:
27         ... ...
28     };

下面是读写RetrieveBuddiesResp对象的C++测试代码和说明性注释。

1     void testRepeatedMessage()
 2     {
 3         printf("==================This is repeated message.================\n");
 4         RetrieveBuddiesResp retrieveResp;
 5         retrieveResp.set_buddiescnt(2);
 6         BuddyInfo* buddyInfo = retrieveResp.add_buddiesinfo();
 7         buddyInfo->set_groupid(20);
 8         UserInfo* userInfo = buddyInfo->mutable_userinfo();
 9         userInfo->set_acctid(200);
10         userInfo->set_name("user1");
11         userInfo->set_status(OFFLINE);
12     
13         buddyInfo = retrieveResp.add_buddiesinfo();
14         buddyInfo->set_groupid(21);
15         userInfo = buddyInfo->mutable_userinfo();
16         userInfo->set_acctid(201);
17         userInfo->set_name("user2");
18         userInfo->set_status(ONLINE);
19     
20         int length = retrieveResp.ByteSize();
21         char* buf = new char[length];
22         retrieveResp.SerializeToArray(buf,length);
23     
24         RetrieveBuddiesResp retrieveResp2;
25         retrieveResp2.ParseFromArray(buf,length);
26         printf("BuddiesCount = %d\n",retrieveResp2.buddiescnt());
27         printf("Repeated Size = %d\n",retrieveResp2.buddiesinfo_size());
28         //这里仅提供了通过容器迭代器的方式遍历数组元素的测试代码。
29         //事实上,通过buddiesinfo_size和buddiesinfo函数亦可循环遍历。
30         RepeatedPtrField<BuddyInfo>* buddiesInfo = retrieveResp2.mutable_buddiesinfo();
31         RepeatedPtrField<BuddyInfo>::iterator it = buddiesInfo->begin();
32         for (; it != buddiesInfo->end(); ++it) {
33             printf("BuddyInfo->groupID = %d\n", it->groupid());
34             printf("UserInfo->acctID = %I64d, UserInfo->name = %s, UserInfo->status = %d\n"
35                 , it->userinfo().acctid(), it->userinfo().name().c_str(),it->userinfo().status());
36         }
37         delete [] buf;
38     }

Protocol Buffer仍然提供了很多其它非常有用的功能,特别是针对序列化的目的地,比如文件流和网络流等。与此同时,也提供了完整的官方文档和规范的命名规则,在很多情况下,可以直接通过函数的名字便可获悉函数所完成的工作。

 十二、简单消息编码布局

让我们先看一下下面的消息定义示例:

message Test1 {
          required int32 a = 1;
      }

假设我们在应用程序中将字段a的值设置为150(十进制),此后再将该对象序列化到Binary文件中,你可以看到文件的数据为:
      08 96 01
      这3个字节的含义又是什么呢?它们又是按照什么样的编码规则生成的呢?

1.Google Protocol Buffer 的 Encoding

Protobuf 序列化后所生成的二进制消息非常紧凑,这得益于 Protobuf 采用的非常巧妙的 Encoding 方法。

考察消息结构之前,让我首先要介绍一个叫做 Varint 的术语。Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

比如对于 int32 类型的数字,一般需要 4 个 byte 来表示。但是采用 Varint,对于很小的 int32 类型的数字,则可以用 1 个 byte 来表示。当然凡事都有好的也有不好的一面,采用 Varint 表示法,大的数字则需要 5 个 byte 来表示。从统计的角度来说,一般不会所有的消息中的数字都是大数,因此大多数情况下,采用 Varint 后,可以用更少的字节数来表示数字信息。下面就详细介绍一下 Varint。

Varint 中的每个 byte 的最高位 bit 有特殊的含义,如果该位为 1,表示后续的 byte 也是该数字的一部分,如果该位为 0,则结束。其他的 7 个 bit 都用来表示数字。因此小于 128 的数字都可以用一个 byte 表示。大于 128 的数字,比如 300,会用两个字节来表示:1010 1100 0000 0010

下图演示了 Google Protocol Buffer 如何解析两个 bytes。注意到最终计算前将两个 byte 的位置相互交换过一次,这是因为 Google Protocol Buffer 字节序采用 little-endian 的方式。

   图 1.  Varint 编码

图 6. Varint 编码

消息经过序列化后会成为一个二进制数据流,该流中的数据为一系列的 Key-Value 对。如下图所示:

图 2. Message Buffer

图 7. Message Buffer

采用这种 Key-Pair 结构无需使用分隔符来分割不同的 Field。对于可选的 Field,如果消息中不存在该 field,那么在最终的 Message Buffer 中就没有该 field,这些特性都有助于节约消息本身的大小。

 2、消息结构:

        Protocol Buffer中的消息都是由一系列的键值对构成的。每个消息的二进制版本都是使用标签号作为Key,而每一个字段的名字和类型均是在解码的过程中根据目标类型(反序列化后的对象类型)进行配对的。在进行消息编码时,Key-Value被连接成字节流。在解码时,解析器可以直接跳过不识别的字段,这样就可以保证新老版本消息定义在新老程序之间的兼容性,从而有效的避免了使用older消息格式的older程序在解析newer程序发来的newer消息时,一旦遇到未知(新添加的)字段时而引发的解析和对象初始化的错误。最后,我们介绍一下字段标号和字段类型是如何进行编码的。下面先列出Protocol Buffer可以支持的字段类型。

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类型,Key的值是由字段标号和字段类型合成编码所得,其公式如下:
      field_number << 3 | field_type
      由此看出,Key的最后3个bits用于存储字段的类型信息。那么在使用该编码时,Protocol Buffer所支持的字段类型将不会超过8种。这里我们可以进一步计算出Protocol Buffer在一个消息中可以支持的字段数量为2的29次方减一。现在我们再来回顾一下之前给出的Test1消息被序列化后的第一个字节08的由来。
      0000 1000
      -> 000 1000                  
//drop掉msb(最高位)
      最低的3位表示字段类型,即0为varint。我们再将结果右移3位,此时得到的结果为1,即字段a在消息Test1中的标签号。通过这样的结果,Protocol Buffer的解码器可以获悉当前字段的标签号是1,其后所跟随数据的类型为varint。现在我们可以继续利用上面讲到的知识分析出后两个字节(96 01)的由来。
      96 01 = 1001 0110 0000 0001
          -> 001 0110 000 0001    
//drop两个字节的msb
          -> 000 0001 001 0110   //翻转高低字节
          -> 10010110                   //去掉最高位中没用的0
          -> 128 + 16 + 4 + 2 = 150

3、更多的值类型:
      (1)有符号整型
      如前所述,类型0表示varint,其中包含int32/int64/uint32/uint64/sint32/sint64/bool/enum。在实际使用中,如果当前字段可以表示为负数,那么对于int32/int64和sint32/sint64而言,它们在进行编码时将存在着较大的差别。如果使用int32/int64表示一个负数,该字段的值无论是-1还是-2147483648,其编码后长度将始终为10个字节,就如同对待一个很大的无符号整型一样。反之,如果使用的是sint32/sint64,Protocol Buffer将会采用ZigZag编码方式,其编码后的结果将会更加高效。
      这里简单讲述一下ZigZag编码,该编码会将有符号整型映射为无符号整型,以便绝对值较小的负数仍然可以有较小的varint编码值,如-1。下面是ZigZag对照表:

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

      其公式为:
      (n << 1) ^ (n >> 31)   //sint32
      (n << 1> ^ (n >> 63)   //sint64

需要补充说明的是,Protocol Buffer在实现上述位移操作时均采用的算术位移,因此对于(n >> 31)和(n >> 63)而言,如果n为负值位移后的结果就是-1,否则就是0。
      注:简单解释一下C语言中的算术位移和逻辑位移。他们的左移操作都是相同的,即低位补0,高位直接移除。不同的是右移操作,逻辑位移比较简单,高位全部补0。而算术位移则需要视当前值的符号位而定,补进的位和符号位相同,即正数全补0,负数全补1。换句话说,算术位移右移时要保证符号位的一致性。在C语言中,如果使用 int变量位移时就是算术位移,uint变量位移时是逻辑位移。
      (2)Non-varint数值型
      double/fixed64始终都占用8个字节,float/fixed32始终占用4个字节。
      (3)Strings
      其类型值为2,key信息之后是字节数组的长度信息,最后在紧随指定长度的实际数据值信息。如:

message Test2 {
          required string b = 2;
      }

现在我们设置b的值为"testing"。其编码后数据如下:
      12 07 74 65 73 74 69 6E 67
      第一个字节0x12表示Key,通过解码可以得到字段类型2和字段标号2。第二个字节07表示testing的长度。后面7个红色高亮的字节则表示testing。

4、嵌入消息:
      这里是一个包含嵌入消息的消息定义。

 message Test3 {
          required Test1 c = 3;
      }

此时我们先将Test1的a字段值设置为150,其编码结果如下:
      1A 03 08 96 01
      从上面的结果可以看出08 96 01和之前直接编码Test1时是完全一致的,只是在前面增加了key(字段类型 + 标号)和长度信息。新增信息的解码方式和含义与前面的Strings完全相同,这里不再重复解释了。

5、Packed Repeated Fields:
      Protocol Buffer从2.1.0版本开始引入了[pack = true]的字段级别选项。如果设置该选项,那么元素数量为0的repeated字段将不会被编码,否则数组中的所有元素会被编码成一个单一的key/value形式。毕竟数组中的每一个元素都具有相同的字段类型和标号。该编码形式,对包含较小值的整型元素而言,优化后的编码结果可以节省更多的空间。如:

 message Test4 {
          repeated int32 d = 4 [pack=true];
      }

这里我们假设d字段包含3个元素,值分别为3,270,86942。编码结果如下:
      22             //key (字段标号4,类型为2)
      06             //数据中所有元素所占用的字节数量
      03             //第一个元素(varint 3)
      8E 02        //第二个元素(varint 270)
      9E A7 05  //第三个元素(varint 86942)

十三、字段顺序

在.proto文件中定义消息的字段标号时,可以是不连续的,但是如果将其定义为连续递增的数值,将获得更好的编码和解码性能。

参考文章:https://www.ibm.com/developerworks/cn/linux/l-cn-gpb/index.html

发布了25 篇原创文章 · 获赞 17 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/Sherlock_Homles/article/details/83662668