技术: Protocol buffer

google protocolbuffer使用心得

最初是在 “序列化” 问题时接触到这个库的(作为XML的替代方案), 之后慢慢熟悉, 发现其在数据存储和RPC数据交换领域有大作为, 好比说客户端Java, 服务器端Cpp; 服务器端通信直接一字节对齐, char来char去, 但是Java就复杂很多(ByteBuffer中根据预定的报文格式, 然后逐字节的解析字段), 并且容易出现拼接错误; 而使用SOAP协议(WebService)作为消息报文的格式载体, 由该方式生成的报文是基于文本格式的, 同时还存在大量的XML描述信息,因此将会大大增加网络IO的负担, 解析速度也不快.

于是, 来了protocol buffer, 简称protobuffer或者pb.

本文详细地介绍一下我的一些实践和心得.

引子

快速说一下它的某个简单使用, 引入话题.

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

假设订单包括如下属性:

1
2
3
4
  时间:time(用整数表示)
  客户id:userid(用整数表示)
  交易金额:price(用浮点数表示)
  交易的描述:desc(用字符串表示)

如果使用protobuf实现,首先要写一个proto文件(不妨叫Order.proto),在该文件中添加一个名为”Order”的message结构,用来描述通讯协议中的结构化数据。该文件的内容大致如下:
(解释一下: .proto文件确定数据协议,数据结构中存在哪些数据,数据类型是怎么样; 该文件就是一个接口规范)

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

关于modifiers的说明:

  • required 不可以增加或删除的字段,必须初始化
  • optional 可选字段,可删除,可以不初始化
  • repeated 可重复字段, 对应到java文件里,生成的是List

(在protoBuf 3中,optional和required都不需要的了,如果配了两个,protoBuf 编译器还会报错,但是repeated的还是保留的,用来说明该字段是一个list)

然后,使用protobuf内置的编译器编译 该proto。由于本例子的模块是C++,你可以通过protobuf编译器的命令行参数,让它生成C++语言的“订单包装类”。(一般来说,一个message结构会生成一个包装类)然后你使用类似下面的代码来序列化/解析该订单包装类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 发送方
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的通讯库把序列化之后的字符串发送出去
// ......

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

注: 生成的类中还有其他的序列化API, 比如 ParseFromIstream(&input) 以及 SerializeToOstream(&output) 等.

有了这种代码生成机制,开发人员再也不用吭哧吭哧地编写那些协议解析的代码了.

万一将来需求发生变更,要求给订单再增加一个“状态”的属性,那只需要在Order.proto文件中增加一行代码。对于发送方(模块A),只要增加一行设置状态的代码;对于接收方(模块B)只要增加一行读取状态的代码。哇塞,简直太轻松了!

不说了, 太激动, 请看详情.

正文

优缺点

优点

  • 性能好, 效率高(XML解析速度慢, XML冗余信息多空间占用多)
  • 代码生成机制, 数据解析类自动生成(这个最赞, 你不需要自己去写解析代码了)
  • 支持向后兼容和向前兼容(得益于约定好的协议, 即使增加字段, 先前的代码也不必改变)
  • 多语言支持(主流语言Cpp,Java,Python; 前后端语言不一样也没有关系, 协议的处理一致)
  • 平台无关
  • 不必学习DOM模型

性能上和 JSON 拼也是不怕的 参考

和 XML,JSON,Thrift相比,Protobuf 有什么不同呢?简单说来 Protobuf 的主要优点就是:简单,快。

你可以定义自己的数据结构,然后使用代码生成器生成的代码来读写这个数据结构。只需使用 Protobuf 对数据结构进行一次描述,即可利用各种不同语言或从各种不同数据流中对你的结构化数据轻松读写。

缺点

  • 应用不够广
  • 为了提高性能, 导致可读性差(二进制格式)

正是由于可读性, Protocol Buffer还取代不了XML.

安装

Linux平台 和 Window(VS2015)
源码安装:

Linux平台

Ubuntu 14, 64bit

1
git clone https://github.com/google/protobuf.git

当然你也可以去release网页下找到相关的包, 在进行编译.

先确保你及其上有相关的工具集合:

  • autoconf
  • automake
  • libtool
  • curl
  • make
  • g++
  • unzip

然后确保有configure文件, 没有话 ./autogen.sh 生成相关脚本(并下载gmock).
然后编译安装(可以自己制定位置, 我就默认了)

1
2
3
4
5
$ ./configure
$ make
$ make check
$ sudo make install
$ sudo ldconfig # refresh shared library cache.

此时 /usr/local/include/google/protobuf 以及 /usr/local/lib 下就有了相应的头文件和库文件, 并且增加了/usr/local/bin/protoc编译器, 此时安装成功.

windows平台

这里采用的是VS2015, 操作系统win10 64位.

在这个网站找到 protoc-$VERSION-win32.zip
It contains the protoc binary as well as public proto files of protobuf library.

这个文档记录了相关过程, 这一篇文档也可以供您参考

使用案例

主要的使用步骤就是:

  1. 定义你自己的数据结构格式(.pro)源文件
  2. 利用 ProtoBuf 提供的编译器编译源文件
  3. 使用 ProtoBuf C++的 API 读写消息

简单Demo

以linux平台为例, 先实践一个简单的:

定义一个person.pro

1
2
3
4
5
6
7
8
9
syntax = "proto2";
package lm;

message helloworld
{
required int32 id = 1; // ID
required string str = 2; // str
optional int32 opt = 3; //optional field
}

编译:(/usr/local/bin/protoc)

1
protoc -I. --cpp_out=. lm.helloworld.proto

生成了一个头文件和一个源文件:

  • lm.helloworld.pb.cc
  • lm.helloworld.pb.h

相当于POJO类或者domain类已经有(并且序列化API也已经有了), 类名 lm::helloworld, 现在可以进行序列化了.

简单的操作代码如下:
(Writer和Reader来对消息进行操作, 序列化和反序列化)
writer.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "lm.helloworld.pb.h"
#include <fstream>
#include <iostream>

using namespace std;


int main(void)
{
//主要逻辑如下
lm::helloworld msg1;

msg1.set_id(101);
msg1.set_str("hello");

// Write the new address book back to disk.
fstream output("./log", ios::out | ios::trunc | ios::binary);

if (!msg1.SerializeToOstream(&output)) {
cerr << "Failed to write msg." << endl;
return -1;
}

return 0;
}

reader.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "lm.helloworld.pb.h"
#include <fstream>
#include <iostream>

using namespace std;

void ListMsg(const lm::helloworld & msg) {
cout << msg.id() << endl;
cout << msg.str() << endl;
}



int main(int argc, char* argv[]) {

lm::helloworld msg1;

{
fstream input("./log", ios::in | ios::binary);
if (!msg1.ParseFromIstream(&input)) {
cerr << "Failed to parse address book." << endl;
return -1;
}
}

ListMsg(msg1);

return 0;
}

(上面的代码主要用到了 SerializeToOstreamParseFromIstream 两个成员方法)
然后编译一下

1
2
3
4
5
6
7
8
9
10
11
12
main:write read

write:writer.cpp lm.helloworld.pb.cc
g++ -g -Wall -O0 writer.cpp lm.helloworld.pb.cc \
-o write -I. -L/usr/local/lib -lprotobuf

read:reader.cpp lm.helloworld.pb.cc
g++ -g -Wall -O0 reader.cpp lm.helloworld.pb.cc \
-o read -I. -L/usr/local/lib -lprotobuf

clean:
rm -f read write

如果你不知道库的具体位置, 那么可以使用pkg-config --cflags --libs protobuf代替后面的 -I等(该工具通过PKG_CONFIG_PATH环境变量指定的地址去找.pc文件, 该文件记录了ProtoBuf安装时头文件和库文件所在的目录, 但是注意, 把PKG_CONFIG_PATH环境变量设置正确, 例如$export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/)

运行结果:

1
2
3
4
merlin@remote~/test/pb_test$ ./write
merlin@remote~/test/pb_test$ ./read
101
hello

总结下来, 其实protocol buffer这个库, 就是解决了你序列化, 或者消息传递时, 需要去写实体类, 以及序列化API的工作, 并且由于消息是语言无关的, 所以又可以跨语言了(当然跨语言时数据类型对接, 还是要在这些API中得到解决的, 这也是该库工作的一部分).

如果你还没有意识到这个库的重要性, 那么下面涉及到RPC, 序列化的部分, 看看吧:
pb

老实说, 初级部分没有太多讲的, 下面看看生成的代码(具体的工程实现细节就不展开了, 只看生成代码的结构, 下面的代码我已经添加了注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//根据option optimize_for优化类型不同, 可能继承自Message或者MessageLite
//MessageLite是Message的父类, Message扩展了反射相关的内容
//当 option optimize_for = LITE_RUNTIME;时, 才继承MessageLite
class helloworld : public ::google::protobuf::Message {
/* @@protoc_insertion_point(class_definition:lm.helloworld) */ {
public:
helloworld();
virtual ~helloworld();

helloworld(const helloworld& from);

inline helloworld& operator=(const helloworld& from) {
CopyFrom(from);
return *this;
}

inline const ::google::protobuf::UnknownFieldSet& unknown_fields() const {
return _internal_metadata_.unknown_fields();
}

inline ::google::protobuf::UnknownFieldSet* mutable_unknown_fields() {
return _internal_metadata_.mutable_unknown_fields();
}

static const ::google::protobuf::Descriptor* descriptor();
static const helloworld& default_instance();

static inline const helloworld* internal_default_instance() {
return reinterpret_cast<const helloworld*>(
&_helloworld_default_instance_);
}
static PROTOBUF_CONSTEXPR int const kIndexInFileMessages =
0;

void Swap(helloworld* other);

// implements Message ----------------------------------------------
//下面实现的是Message中的虚函数
//New等同于clone, 创建一个该类的新对象
inline helloworld* New() const PROTOBUF_FINAL { return New(NULL); }
helloworld* New(::google::protobuf::Arena* arena) const PROTOBUF_FINAL;

//等同于赋值操作符重载(operator=)
void CopyFrom(const ::google::protobuf::Message& from) PROTOBUF_FINAL;
void MergeFrom(const ::google::protobuf::Message& from) PROTOBUF_FIeNAL;
void CopyFrom(const helloworld& from);
void MergeFrom(const helloworld& from);

//清空当前对象中的所有数据, 即将所有成员变量置为未初始化状态
void Clear() PROTOBUF_FINAL;
//判断当前状态是否已经初始化
bool IsInitialized() const PROTOBUF_FINAL;

//在给当前对象的所有变量赋值之后, 获取该对象序列化后所需要的字节数
size_t ByteSizeLong() const PROTOBUF_FINAL;
bool MergePartialFromCodedStream(
::google::protobuf::io::CodedInputStream* input) PROTOBUF_FINAL;
void SerializeWithCachedSizes(
::google::protobuf::io::CodedOutputStream* output) const PROTOBUF_FINAL;
::google::protobuf::uint8* InternalSerializeWithCachedSizesToArray(
bool deterministic, ::google::protobuf::uint8* target) const PROTOBUF_FINAL;
int GetCachedSize() const PROTOBUF_FINAL { return _cached_size_; }
private:
void SharedCtor();
void SharedDtor();
void SetCachedSize(int size) const PROTOBUF_FINAL;
void InternalSwap(helloworld* other);
private:
inline ::google::protobuf::Arena* GetArenaNoVirtual() const {
return NULL;
}
inline void* MaybeArenaPtr() const {
return NULL;
}
public:

::google::protobuf::Metadata GetMetadata() const PROTOBUF_FINAL;

// nested types ----------------------------------------------------
//没有嵌套, 所以这里为空

// accessors -------------------------------------------------------

//下面的是根据字段生成的代码
// required string str = 2;

//字段已经赋值, 返回true
bool has_str() const;
//清除该字段值
void clear_str();
//这个静态成员表示字段 str 的编码值
static const int kStrFieldNumber = 2;

//拿到字段值
const ::std::string& str() const;

//设置字段值
void set_str(const ::std::string& value);
#if LANG_CXX11
void set_str(::std::string&& value);
#endif
void set_str(const char* value);
void set_str(const char* value, size_t size);
::std::string* mutable_str();
::std::string* release_str();
void set_allocated_str(::std::string* str);

// required int32 id = 1;
bool has_id() const;
void clear_id();
static const int kIdFieldNumber = 1;

//getter和setter
::google::protobuf::int32 id() const;
void set_id(::google::protobuf::int32 value);

// optional int32 opt = 3;
bool has_opt() const;
void clear_opt();
static const int kOptFieldNumber = 3;
::google::protobuf::int32 opt() const;
void set_opt(::google::protobuf::int32 value);

// @@protoc_insertion_point(class_scope:lm.helloworld)
private:
void set_has_id();
void clear_has_id();
void set_has_str();
void clear_has_str();
void set_has_opt();
void clear_has_opt();

// helper for ByteSizeLong()
size_t RequiredFieldsByteSizeFallback() const;

::google::protobuf::internal::InternalMetadataWithArena _internal_metadata_;
::google::protobuf::internal::HasBits<1> _has_bits_;
mutable int _cached_size_;
::google::protobuf::internal::ArenaStringPtr str_;
::google::protobuf::int32 id_;
::google::protobuf::int32 opt_;
friend struct protobuf_lm_2ehelloworld_2eproto::TableStruct;
};
// ===================================================================

上面的代码Message没有嵌套, 还是蛮简单的, 主要使用的是和字段相关的方法(其他序列化方法, 如 SerializeToArray 等, 不在此类中实现). 上目前的 ByteSizeLong() 主要用于序列化到buffer或者array时, 例如:

1
2
3
4
5
6
7
8
9
10
11
helloworld message;
message.set_str("xxx");
message.set_id(1);
message.set_opt(2);

size_t length = message.ByteSizeLong();
char *buf = new char[length];
message.SerializeToArray(buf, length);
//读的时候 ParseFromArray
delete []buf;
buf = NULL;


复杂案例

复杂的案例, 也就是带有嵌套message. 和简单message不同的是, 不能简单的使用set方法设置被嵌套在message内部的内容了, 而是用另外的方法. 下面举一个案例, 如下.
.proto文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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消息
}

生成的关键代码如下:

1
2
3
4
5
6
7
8
9
10
// required .UserInfo userInfo = 2;
//下面的成员函数都是因message中定义的UserInfo字段而生成。
//这里只是列出和非消息类型字段差异的部分。
static const int kUserInfoFieldNumber = 2;
inline bool has_userinfo() const;
inline void clear_userinfo();

inline const ::UserInfo& userinfo() const; //重要
inline ::UserInfo* mutable_userinfo(); //重要
inline ::UserInfo* release_userinfo(); //重要

可以看到该类并没有生成用于设置和修改userInfo字段set_userinfo函数,而是将该工作交给了下面的mutable_userinfo函数. 因此每当调用函数之后, Protocol Buffer都会认为该字段的值已经被设置了, 同时 has_userinfo 函数亦将返回true. 在实际编码中, 我们可以通过该函数返回 userInfo 字段的内部指针, 并基于该指针完成userInfo成员变量的初始化工作.
而对该字段的操作, 大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
LogonRespMessage logonResp;
logonResp.set_logonresult(LOGON_RESULT_SUCCESS);//设置枚举

//通过mutable_userinfo方法拿到指针
UserInfo* userInfo = logonResp.mutable_userinfo();
userInfo->set_acctid(200);
userInfo->set_name("Tester");
userInfo->set_status(OFFLINE);

//序列化
int length = logonResp.ByteSize();
char* buf = new char[length];
logonResp.SerializeToArray(buf,length);


//反序列化
LogonRespMessage logonResp2;
logonResp2.ParseFromArray(buf,length);
printf("LogonResult = %d, UserInfo->acctID = %I64d,
UserInfo->name = %s, UserInfo->status = %d\n",
logonResp2.logonresult(),
logonResp2.userinfo().acctid(),
logonResp2.userinfo().name().c_str(),
logonResp2.userinfo().status());

delete [] buf;

如果嵌套中涉及到了 repeated, 例如下面这个案例:

1
2
3
4
5
6
7
8
9
message BuddyInfo {
required UserInfo userInfo = 1;
required int32 groupID = 2;
}

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

那么它生成代码中, 关于 RetrieveBuddiesResp 部分可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class RetrieveBuddiesResp : public ::google::protobuf::MessageLite {
public:
RetrieveBuddiesResp();
virtual ~RetrieveBuddiesResp();


// repeated .BuddyInfo buddiesInfo = 2;
static const int kBuddiesInfoFieldNumber = 2;
//返回数组中成员的数量
inline int buddiesinfo_size() const; //重要
//清空数组中的所有已初始化成员, buddiesinfo_size()==0
inline void clear_buddiesinfo();

//返回数组中指定下标所包含元素的引用
inline const ::BuddyInfo& buddiesinfo(int index) const; //重要
//返回数组中指定下标所包含元素的指针(可修改元素)
inline ::BuddyInfo* mutable_buddiesinfo(int index);//重要

//像数组中添加一个新元素(返回值即使新添加元素的指针)
inline ::BuddyInfo* add_buddiesinfo();

//获取buddiesInfo字段所表示的容器(该函数返回的容器仅用于遍历并读取)
inline const ::google::protobuf::RepeatedPtrField< ::BuddyInfo >&
buddiesinfo() const; //重要

//获取buddiesInfo字段所表示的容器指针(该函数返回的容器指针可用于遍历和直接修改)
inline ::google::protobuf::RepeatedPtrField< ::BuddyInfo >*
mutable_buddiesinfo(); //重要
};

对齐操作, 大致上是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

RetrieveBuddiesResp retrieveResp;
retrieveResp.set_buddiescnt(2);

//拿到新元素指针, 然后初始化
BuddyInfo* buddyInfo = retrieveResp.add_buddiesinfo();
buddyInfo->set_groupid(20);
UserInfo* userInfo = buddyInfo->mutable_userinfo();
userInfo->set_acctid(200);
userInfo->set_name("user1");
userInfo->set_status(OFFLINE);


//下面重复利用了 UserInfo* userInfo
buddyInfo = retrieveResp.add_buddiesinfo();
buddyInfo->set_groupid(21);
userInfo = buddyInfo->mutable_userinfo();
userInfo->set_acctid(201);
userInfo->set_name("user2");
userInfo->set_status(ONLINE);

//序列化
int length = retrieveResp.ByteSize();
char* buf = new char[length];
retrieveResp.SerializeToArray(buf,length);

//反序列化
RetrieveBuddiesResp retrieveResp2;
retrieveResp2.ParseFromArray(buf,length);
printf("BuddiesCount = %d\n",retrieveResp2.buddiescnt());//int32
printf("Repeated Size = %d\n",retrieveResp2.buddiesinfo_size());

//通过容器迭代器的方式遍历数组元素的测试代码
//也可以通过( buddiesinfo_size和 buddiesinfo(index) 方式)
//RepeatedPtrField< ::BuddyInfo > &buddiesinfo() const;
RepeatedPtrField<BuddyInfo> *buddiesInfo = retrieveResp2.mutable_buddiesinfo();
RepeatedPtrField<BuddyInfo>::iterator it = buddiesInfo->begin();

for ( ;it != buddiesInfo->end(); ++it) {
printf("BuddyInfo->groupID = %d\n", it->groupid());
printf("UserInfo->acctID = %I64d, UserInfo->name = %s, UserInfo->status = %d\n",
it->userinfo().acctid(),
it->userinfo().name().c_str(),
it->userinfo().status());
}

delete [] buf;

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


语言规范

.proto文件

要通信,必须有协议,否则双方无法理解对方的码流。在protobuf中,协议是由一系列的消息组成的。因此最重要的就是定义通信时使用到的消息格式。

消息格式

字段格式
字段格式:

限定修饰符 | 数据类型 | 字段名称 | = | 字段编码值| [字段默认值]
  • 限定符
    required\optional\repeated
    特别说明: repeated表示的字段可以包含0个或多个数据(这一点有别于C++/Java中的数组, 因为后两者中的数组必须包含至少一个元素).
    升级建议 : 项目投入运营以后涉及到版本升级时的新增消息字段全部使用optional或者repeated, 尽量不实用required. 如果使用了required,需要全网统一升级,如果使用optional或者repeated可以平滑升级. 不要修改已经存在字段的编码号. 在原有的消息中, 不能移除已经存在的required字段, optional和repeated类型的字段可以被移除, 但是他们之前使用的编码号必须被保留, 不能被新的字段重用.
  • 数据类型
    protobuf定义了一套基本数据类型, 这些数据类型, 可以映射到C++\Java等语言的基础数据类型.
    完整的映射表格如下:
    types
    几乎所有的整型都是, 变长编码; 表示存储的字节数根据数据的大小或者长度而定.
    例如int32,如果数值比较小,在0~127时,使用一个字节打包, 而并不总是4字节; 甚至大的数字可能占用5个字节.
    当然message本身也是一种类型(消息可以嵌套消息), 在C++里就表示 object of class.
    关于fixed32和int32的区别: fixed32的打包效率比int32的效率高, 但是使用的空间一般比int32多. 因此一个属于时间效率高, 一个属于空间效率高. 根据项目的实际情况, 一般选择fixed32, 如果遇到对传输数据量要求比较苛刻的环境, 可以选择int32.
    枚举值必须大于等于0的整数, 并且使用分号(;)分隔枚举变量而不是C++语言中的逗号(,), 例如:

    1
    2
    3
    4
    5
    6
    7
    enum voip_protocol
    {
    H323 = 1;
    SIP = 2;
    MGCP = 3;
    H248 = 4;
    }
  • 字段名称
    protobuf建议字段的命名采用以下划线分割的驼峰式, 例如 first_name. (基本和C语言一样)

  • 字段编码值
    字段的编码值, 表示不同的字段在序列化后的二进制数据中的布局位置(数字越大越靠后); 根据字段的类型, 一般有对应关系:
    传输类型
    消息中的字段的编码值无需连续, 只要是合法的, 并且不能在同一个消息中有字段包含相同的编码值.
    根据其内部实现来看, 一般使用在封包和解包时, 你可以把它简单理解成, 编码位置. 不同的编码值, 传输效率是不一样的. 例如编码值的取值范围为 1~2^32, 其中1~15的编码时间和空间效率都是最高的.编码值越大,其编码的时间和空间效率就越低(相对于1-15), protobuf 还建议把经常要传递的值把其字段编码设置为1-15之间的值.

  • 默认值
    默认值. 当在传递数据时,对于required数据类型,如果用户没有设置值,则使用默认值传递到对端。当接受数据时,对于optional字段,如果没有接收到optional字段,则设置为默认值。(一般是反序列化的时候用的, 默认值一般系统指定; 当然你也可以指定, 见上面的编码值对应表)

关于import

protobuf 接口文件可以像C语言的h文件一个, 分离为多个, 在需要的时候通过 import导入需要对文件. 其行为和C语言的#include或者java的import的行为大致相同, 个人觉得更像Java.

关于package

避免名称冲突.可以给每个文件指定一个package名称. 对于java解析为java中的包, 对于C++则解析为名称空间. 例如:

1
package ourproject.lyphone;

Option选项

在.proto文件中, 还有一些常用选项(示Protocol Buffer编译器帮助我们生成更为匹配的目标语言代码). Protocol Buffer内置的选项被分为以下三个级别:(作用范围不一样)

  • 文件级别: 这样的选项将影响当前文件中定义的所有消息和枚举
  • 消息级别: 这样的选项仅影响某个消息及其包含的所有字段
  • 字段级别: 这样的选项仅仅响应与其相关的字段

常用的Protocol Buffer选项如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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_RUNTIME选项而言,其生成的代码均将继承自MessageLite,而非Message。


4. [packed = 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]。

命令行编译工具

一般语法是:

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

简单解释一下:

  • protoc为Protocol Buffer提供的命令行编译工具 /usr/local/bin/protoc
  • –proto_path等同于-I选项,主要用于指定待编译的.proto消息定义文件所在的目录, 或者包含目录该选项可以被同时指定多个
  • –cpp_out选项表示生成C++代码,–java_out表示生成Java代码, –python_out则表示生成Python代码, 其后的目录为生成后的代码所存放的目录
  • path/to/file.proto表示待编译的消息定义文件路径

对于C++而言,通过Protocol Buffer编译工具,可以将每个.proto文件生成出一对.h和.cc的C++代码文件。生成后的文件可以直接加载到应用程序所在的工程项目中。如:MyMessage.proto生成的文件为MyMessage.pb.h和MyMessage.pb.cc。

动态编译

运行时编译, 动态解析(该部分对应一些特殊的需求)
.proto文件如下:

1
2
3
4
5
6
7
package lm;

message helloworld
{
required int32 id = 1;
required string str = 2;
}

利用compiler 内部的对象:

  • MultiFileErrorCollector
  • DiskSourceTree
  • Importer

即可完成运行时编译, 详细的代码:

1
2
3
4
5
6
7
google::protobuf::compiler::MultiFileErrorCollector errorCollector;
google::protobuf::compiler::DiskSourceTree sourceTree;

google::protobuf::compiler::Importer importer(&sourceTree, &errorCollector);
sourceTree.MapPath("", protosrc);

importer.import(“lm.helloworld.proto”);

首先构造一个 importer 对象.构造函数需要两个入口参数,一个是 source Tree 对象,该对象指定了存放 .proto 文件的源目录。第二个参数是一个 error collector 对象,该对象有一个 AddError 方法,用来处理解析 .proto 文件时遇到的语法错误. 之后,需要动态编译一个 .proto 文件时,只需调用 importer 对象的 import 方法。

描述消息: (Package google::protobuf::compiler )

  • 类 FileDescriptor 表示一个编译后的 .proto 文件;
  • 类 Descriptor 对应该文件中的一个 Message;
  • 类 FieldDescriptor 描述一个 Message 中的一个具体 Field
    三者的组合关系是: FileDescriptor > Descriptor > FieldDescriptor

例如:

1
2
3
4
const protobuf::Descriptor *desc = 
importer_.pool()->FindMessageTypeByName(“lm.helloworld”);
const protobuf::FieldDescriptor* field =
desc->pool()->FindFileByName (“id”);

内部实现

该部分主要了解其两个特征:

  • 序列化后的信息内容紧凑(这得益于 Protobuf 采用的非常巧妙的 Encoding 方法)
  • 封包和解包的过程

编码

关于编码(encode)这一块儿, 怎么样压缩字节存储才最省事儿, 也最容易区分字段field的key-value, 一直是可以改进的领域; 貌似属于科学家研究的问题了. 我这里不展开, 如果展开估计也有一大堆人, 看不懂, 例如:

1
2
3
4
5
6
7
8
9
 31 30 31 3C 2F 69 64 3E 3C 6E 61 6D 65 3E 68 65 
6C 6C 6F 3C 2F 6E 61 6D 65 3E 3C 2F 68 65 6C 6C
6F 77 6F 72 6C 64 3E

//一共 55 个字节,这些奇怪的数字需要稍微解释一下,其含义用 ASCII 表示如下:
<helloworld>
<id>101</id>
<name>hello</name>
</helloworld>

其中还涉及到了big, little-endian
大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;这和我们的阅读习惯一致。(从左到右, 地址从低到高)
小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。

由于encode和decode涉及到了, 太多的工程细节, 对于实际开发帮助不大, 下面给出一个 Go语言的分析参考.

但是我不得不说, 即使你看懂了, 看明白了, 真正让你去设计, 还是非常难的, 请量力而行(作为兴趣了解一下还是可以的, 但是作为解决方案参考意义不大).

封包和解包

对于XML和JSON的封包和解包.
首先一个是二进制存储, 其他的JSON, XML都是文本存储, 原理都不一样; 其次, 我们来了解一下 XML 的封解包过程:

XML 需要从文件中读取出字符串,再转换为 XML 文档对象结构模型。之后,再从 XML 文档对象结构模型中读取指定节点的字符串,最后再将这个字符串转换成指定类型的变量。这个过程非常复杂,其中将 XML 文件转换为文档对象结构模型的过程通常需要完成词法文法分析等大量消耗 CPU 的复杂计算。

好了, XML已经输了.

protobuf呢?
简单的位运算, 速度非常快; 之后存储到c++或者其他语言对应的数据类型中即可. 它只需要简单地将一个二进制序列, 按照指定的格式读取到 C++ 对应的结构类型中就可以了.

整个解析过程需要 Protobuf 本身的框架代码和由 Protobuf 编译器生成的代码共同完成:

  • pb框架:CodedInputStream 类,WireFormatLite 类等提供了对二进制数据的 decode 功能
  • pb编译器生成相关语言的类结构代码, 例如lm::Helloworld类的结构 (生成相关类, 一定是编译器在做这件事儿)
    (之后两者配合, 把读取出来的数据, 赋值给Helloworld类的相应数据成员)

编写新的Proto编译器

使用 Google Protocol Buffer 的 Compiler 包,您可以开发出支持其他语言的新的编译器(原生最开始支持 C++, Java, Python)
类 CommandLineInterface 封装了 protoc 编译器的前端,包括命令行参数的解析,proto 文件的编译等功能。您所需要做的是实现类 CodeGenerator 的派生类,实现诸如代码生成等后端工作.
(该部分属于个高级内容)

对比Thrift

大名鼎鼎的 Thrift, 老早就在听外包公司的大佬在说这个脸书的框架, 它和pb对抗起来如何呢?

protocol buffer 和 thrift对比, 简单来说, 没有可比性.

thrift是全套解决方案, 而protobuffer则仅仅是rpc或者序列化问题的消息解决方案, 两者不在一个级别上. 从框架的复杂性上来说, thrift相对来说比较复杂, 但是功能丰富(全套RPC解决方案,包括序列化机制、传输层、并发处理框架等). pb更加实用, 而thrift更加系统.

我的另外一篇博文有详细的说 thrift .

尾巴

懂了原理以及该库的作用之后, 复杂的案例最多不过帮助你熟悉相关的API; 实际上日常的开发中, 只有在定制化封包, 编码等高级需求中, 才会要求你去了解的实现原理. 好在文本也涉及到了.

花了好大的力气, 不过总结告一段落了.进一步的学习, 只有源码解析部分了, 有时间了再说.
(关于pb2, pb3各自相近的语法, 本文没有说明, 请参考官方文档)

参考资料

官方文档

pb2的语法:
https://developers.google.com/protocol-buffers/docs/proto

pb3的语法
https://developers.google.com/protocol-buffers/docs/proto3

Protocol Buffer Basics(C++) :
https://developers.google.com/protocol-buffers/docs/cpptutorial

其他参考

  1. http://mikewang.blog.51cto.com/3826268/1432136
  2. http://blog.csdn.net/caisini_vc/article/details/5599468
  3. http://cxshun.iteye.com/blog/1974498
  4. https://www.ibm.com/developerworks/cn/linux/l-cn-gpb/
  5. http://blog.csdn.net/sheismylife/article/category/1227779
  6. http://blog.csdn.net/hailong0715/article/details/52057873
  7. https://developers.google.com/protocol-buffers/docs/cpptutorial
  8. http://blog.csdn.net/hguisu/article/details/20721109
  9. http://shanshanpt.github.io/2016/09/10/protobuf-encode.html
  10. http://www.cnblogs.com/stephen-liu74/category/442364.html
文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. 优缺点
      1. 2.1.1. 优点
      2. 2.1.2. 缺点
    2. 2.2. 安装
      1. 2.2.1. Linux平台
      2. 2.2.2. windows平台
    3. 2.3. 使用案例
      1. 2.3.1. 简单Demo
      2. 2.3.2. 复杂案例
    4. 2.4. 语言规范
      1. 2.4.1. .proto文件
      2. 2.4.2. 消息格式
      3. 2.4.3. 关于import
      4. 2.4.4. 关于package
      5. 2.4.5. Option选项
      6. 2.4.6. 命令行编译工具
    5. 2.5. 动态编译
    6. 2.6. 内部实现
      1. 2.6.1. 编码
      2. 2.6.2. 封包和解包
    7. 2.7. 编写新的Proto编译器
    8. 2.8. 对比Thrift
  3. 3. 尾巴
  4. 4. 参考资料
    1. 4.1. 官方文档
    2. 4.2. 其他参考
|