Google Protobuf数据交换格式的使用方法

  现今,如果问什么格式的数据交互最红火,非Google家的protobuf莫属了。相比XML、Json,其有点就是使用接口简单、序列化和解析速度快、数据小传输效率高,同时其还具有向后兼容、跨平台、以及丰富的语言支持接口(居然js都支持,看来直逼Json了啊)的优势。当然,其缺点是不像XML、Json那种可读性和自解释性强,特别是在网络通信时候调试起来比较麻烦。
  翻墙照着Google的文档,把protobuf走了一遍,总体感觉不愧是大厂的作品,考虑到的是效率、多语言支持、兼容性以及分布式系统中多版本的兼容和演进,同时其内部是使用C++实现的,在proto“语法”设计上也会让C++用户感觉十分亲切。
  老习惯还是顺便做个笔记吧。
protobuf

一、从例子说起

  protobuf的使用,需要事先写好.proto文件,这个文件规定了数据传输和接受方交换数据的字段、类型等信息。然后使用编译器protobuf-compiler编译这个文件,就可以产生指定语言类型所需的辅助性文件(比如C++的.h和.cc,然后对每一个message都会产生一个类进行描述)了,然后方便的集成到项目代码中使用,当然除了命令行模式的编译,也可以在源代码中读取.proto文件进行动态编译。

1.1 .proto文件的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
message Person {
required string name = 1; // Your name
required int32 id = 2; // Your ID
optional string email = 3;

enum PhoneType {
option allow_alias = true;
MOBILE = 0;
CELLPHONE = 0;
HOME = 1;
WORK = 2;
}

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

repeated PhoneNumber phone = 4;

reserved 2, 15, 9 to 11; // reserved保留
reserved "foo", "bar";
}

  (1) 每个字段前面都必须有required、optional、repeated,分别表示后面的字段出现1次、0或1次、0或多次,对于repeated字段,其相同类型字段多个值出现的顺序会被保留。
  (2) 数据类型可以是整形、string类型(支持的类型主要有float、double、[s|u| ]int[32|64]、bool、string),同时还支持自定义类型的嵌套。每一个字段后面都有一个唯一的数字标号(number tag),为了提高效率,1-15是用一个字节编码,16-2047是用的两个字节编码,所以根据霍夫曼编码的愿意应该把常用的字段用小于15的数字标号。
  (3) 同一个.proto文件中可以定义多个message,尤其当他们在业务上逻辑相关时候更应该这样。.proto文件的注释支持用C/C++的//风格注释。
  (4) 后续更新的时候,可能某些字段不想要了。为了兼容性起见,不应当仅仅注释或者删除掉,而应该用reserved字段尤其对数字标号进行保留,防止被别人再次使用,然后让老的程序错误解析这些字段。
  (5) 对于optional的字段,可以使用default提供对应的默认值,这样当message解析发现没有这个字段时候,那么就会:如果设定有默认值,就使用这个默认值;否则其值是类型相关的——0、false、空string、枚举的第一个元素。
  (6) enum枚举类型有一个选项allow_alias,打开它的时候,允许同一个枚举值有多个枚举的名字(比如上文的MOBILE和CELLPHONE)。

1.2 .proto文件其它相关

  (1) 导入定义
  import可以将别的文件的定义导入到当前文件,默认的import行为是只能使用导入文件中的直接定义,如果需要嵌套使用导入文件的内容,达到类似递归的应用效果,可以使用import public语句。

1
2
3
4
5
6
7
// old.proto
import public "new.proto";
import "other.proto";

// client.proto
import "old.proto";
//此时可以使用old.proto和new.proto中的内容,但是看不到other.proto内容

  protobuf-compiler编译器对.proto文件搜索路径默认是执行protoc的当前路径,当然命令行可以使用-I/–proto_path来添加搜索路径。

  (2) 嵌套类型
  可以使用Parent.Type这种语法来实现嵌message的使用,而且不限制嵌套的层次深度

1
2
3
4
5
6
7
8
9
10
11
message SearchResponse {
message Result {
required string url = 1;
optional string title = 2;
}
repeated Result result = 1;
}

message SomeOtherMessage {
optional SearchResponse.Result result = 1;
}

  (3) 更新消息类型
  从整个文档看来,Google对于这种向后兼容性看的很重要,这也是一个大型系统逐渐演进所必需的。如果要修改一个.proto文件,那么需要遵守以下的一些守则和约定:
  a. 对于一个已经存在的字段不要修改其数字标号;
  b. 新增加的字段应该是optional或者repeated的;
  c. 不再使用的字段,可以用OBSOLETE_等前缀命名表示废弃,但是绝对不要重用其数字标号,记得使用上面的reserved对这些数字标号保护起来;
  d. 非required可以转为extension(保留给第三方在他们自己的.proto文件中使用);
  e. int32、uint32、int64、uint64、bool是兼容的,客户端可以进行结果的强制转换;
  f. 修改default默认值是允许的,因为默认值不会真正的传输,只跟程序使用的.proto文件有关;

  (4) extension
  上面的方式可以把特定的数字标号区域保留给扩展,扩展在自己的.proto文件中,先import原有的.proto,然后再次打开message,添加扩充自己的字段,比如

1
2
3
4
5
6
7
8
// foo.proto
message Foo {
// ...
extensions 100 to 199; }

// your.proto
import foo.proto;
extend Foo { optional int32 bar = 126; }

但是跟前面基本字段不同,extension的访问需要使用特殊的接口来操作,比如设置值,需要调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
foo.SetExtension(bar, 15);
其它这类操作接口包括:HasExtension()、ClearExtension()、GetExtension()、MutableExtension()、AddExtension()。

  (5) oneof
  类似于C/C++中的union类型,当oneof包围多个可选字段的时候,至多只能给一个字段赋值,当给某个字段设置的时候,会自动清除已存其它字段的值,因为这些字段都是共享同一内存的,为的就是节省内存。
```cpp
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}

SampleMessage message;
message.set_name("name");
CHECK(message.has_name());

  oneof中的字段定义不能有require、optional、repeated,然后具体使用的时候可以当作optional一样来使用了。
还有,如果一个message中有多个同名的oneof,只有最后一个可见的会被实际使用;extensions不支持oneof;oneof不能被repeated;C++中支持swap两个oneof,交换后可访问字段变成互为对方的那个。

  (6) maps
  proto支持相关性容器map,其定义的格式是

1
map<key_type, value_type> map_field = N;

  其中的key_type除了不能是浮点和bytes,其它类型(整形、string)都可以作为key;针对map其wire format的排序和迭代顺序是未定义的,用户不应当依赖其顺序;当将其转成文本类型的时候,是按照整形从小到大或者字符串的升序来排序的;当解析或者合并map的时候,如果有重复的key,那么只有最后看见的那个key被使用,而当从文本中解析的时候,如果有重复的key会做报错处理。

  (7) Packages
  proto的名字查找类似于C++,从最内层的类依次向外查找。有时候为了防止名字冲突,可以在.proto文件的开头声明package,起到类似名字空间的效果(实际上产生C++辅助代码的时候就是放到对应的namespace中的,比如foo.bar生成了foo::bar)。

1
2
3
4
5
6
7
package foo.bar; //namespace foo::bar
message Open { ... }

// 另外一个proto中使用
message Foo {
required foo.bar.Open open = 1;
}

1.3 将上面的例子用起来

  针对上面的.proto文件,使用protoc编译,可以根据语言产生对应的源代码文件(比如C++的.h和.cc)。实测发现,当前很多发行版还是默认打包的protobuf-compiler-2.6甚至更旧的版本,所以建议在GitHub上面下载源代码自己编译安装最新的3.0版本。
  然后,3.0的版本今年才正式发布的,语法跟之前稳定版2.6差异还是挺大的,总体的感觉是让protobuf使用更简洁了。具体的修改日志可以看参考列表中的Release Note,包括:不再区分optional、required,默认都是optional;需要指定syntax版本,默认是proto2;不支持default默认值等。编译的格式,和上面实际用到的编译命令是:

1
2
3
protoc --proto_path=IMPORT_PATH --cpp_out=DST_DIR path/to/file.proto

➜ ~ protoc --cpp_out=./ msg.proto

  如果DST_DIR使用.zip结尾,那么产生的文件会自动用.zip打包,同时输入的.proto文件可以一次指定一个或者多个。
  编译结束后会生成msg.pb.cc和msg.pb.h两个文件,然后就可以轻松应用了!

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 <iostream>
#include <fstream>
using namespace std;

#include "msg.pb.h"

int main(int argc, char* argv[])
{
Person person;
person.set_name("Nicol TAO");
person.set_id(1234);
person.set_email("taozhijiang@126.com");
fstream output("myfile", ios::out | ios::binary);
person.SerializeToOstream(&output);
output.close();

fstream input("myfile", ios::in | ios::binary);
Person person2;
person2.ParseFromIstream(&input);
cout << "Name: " << person2.name() << endl;
cout << "E-mail: " << person2.email() << endl;
cout << "Test Finished!" << endl;

return 0;
}

  然后,在命令行编译执行,就该得到你想要的结果了:

1
2
3
4
5
6
7
➜  ~ g++ -c msg.pb.cc && g++ -c main.cc                                         
➜ ~ g++ msg.pb.o main.o -lprotobuf -o msg
➜ ~ ./msg
Name: Nicol TAO
E-mail: taozhijiang@126.com
Test Finished!
➜ ~

二、CPP使用接口

  protobuf的手册中有一章是说Encoding的,主要是从原理说protobuf的编码效率为什么会这么高。因为这对用户来说是无所谓的,所以就跳过了,感兴趣的可以去瞄。其中128-Variant变长编码的思路还是不错的。

2.1 Messages

  上面使用message Foo生成的消息,最终都会产生一个public继承自google::protobuf::Message的类Foo,默认protobuf会以生成最快速度的版本,如果在.proto中设置了option optimize_for = CODE_SIZE;,就会实现最少必须函数,然后用反射的机制生成其它的;option optimize_for = LITE_RUNTIME;选项将会派生出google::protobuf::MessageLite的类型,只提供比原Message较少的一个操作子集,同时链接的库也是libprotobuf-lite.so。
对于嵌套类型

1
message Foo { message Bar { } }

  在生成的代码中会有Foo和Foo_Bar两个类,同时还会自动生成typedef Foo_Bar Bar;的别名。可以使用Foo::Bar访问,但是C++不允许带作用域的前向声明,所以如果要前向声明,记得有Foo_Bar这个类。

2.2 Fields

  由于protobuf在传输的时候是使用数字的,所以在protobuf解码的时候,自动为这些数子生成了camel-case驼峰模式的常量,比如:

1
2
optional int32 foo_bar = 5;
static const int kFooBarFieldNumber = 5;

  对于Field的访问器accessor,如果得到的是const reference类型的,在下次modify access作用于这个message的时候,访问器可能会失效,尤其是调用了non-const的访问器类型;当accessor返回的是指针,记住:任何两次不同的accessor调用,返回的指针值都可能是不同的。
  (1) Singular单个数字类型(枚举类型也跟此类似)

1
2
3
4
5
6
int32 foo = 1;
===>

int32 foo() const;
void set_foo(int32 value);
oid clear_foo(); //清除,下次调用foo()会返回0

  (2) Singular单个字符串类型

1
2
3
4
5
6
7
8
9
10
11
12
13
string foo = 1;
bytes foo = 1;
===>

const string& foo() const;
void set_foo(const string& value);
void set_foo(const char* value);
void set_foo(const char* value, int size);
string* mutable_foo(); //返回一个可以修改string值的指针
void clear_foo();
void set_allocated_foo(string* value); //吧value设置到foo,如果foo之前有string,则释放掉之前的string
//如果value==NULL,则等同于clear_foo()
string* release_foo(); //调用后foo释放string的控制权

  (3) Singular嵌入message类型

1
2
3
4
5
6
7
8
9
10
message Bar {}
Bar foo = 1;
===>

bool has_foo(); //检查foo是否已经set
const Bar& foo(); //返回值,如果没有set,就返回一个没有设置的Bar,Bar::default_instance()
Bar* mutable_foo(); //返回mutable指针,如果没有set,内部就会newly-allocated Bar并返回
void clear_foo();
void set_allocated_foo(Bar* bar); //如果bar==NULL,等同于clear_foo()
Bar* release_foo();

  (4) Repeated数字类型

1
2
3
4
5
6
7
8
repeated int32 foo = 1;
===>

int foo_size() const; //元素的个数
int32 foo(int index) const; //0-based索引对应的值
void set_foo(int index, int32 value);
void add_foo(int32 value);
void clear_foo(); //清除所有的元素

  对于枚举、字符串、嵌入message,也有对应的repeated版本,可以参阅其手册,很容易理解和想到。

  (5) Oneof数字类型

1
2
3
4
5
6
7
8
9
oneof oneof_name {
int32 foo = 1;
... }
===>

bool has_foo() const; //检查当前oneof类型是kFoo
int32 foo() const; //如果当前是kFoo,返回其值,否则返回0
void set_foo(int32 value); //如果当前不是kFoo,调用clear_oneof_name(),然后设置其值,并且oneof_name_case()会返回kFoo
void clear_foo(); //如果当前不是kFoo,则什么也不做;否则,清除其值,然后has_foo()==false,foo()==0,同时oneof_name_case()返回ONEOF_NAME_NOT_SET。

  对于枚举、字符串、嵌入message,也有对应的Oneof版本,可以参阅其手册,很容易理解和想到。

  (6) Map类型

1
2
3
4
5
map<int32, int32> weight = 1;
===>

const google::protobuf::Map<int32, int32>& weight();
google::protobuf::Map<int32, int32>* mutable_weight();

  上面的weight()和mutable_weight()会得到可修改和不可修改两个map,其可以支持std::map和std::unorderd_map中常用的函数接口,包括:迭代器、元素访问、查找、修改(添加和删除)、拷贝等。

1
2
3
4
5
//这种插入会被insert要好,免除可能的元素深度拷贝
(*my_enclosing_proto->mutable_weight())[my_key] = my_value;

std::map<int32, int32> standard_map(message.weight().begin(),
message.weight().end());

  同时如上面所示,如果不想用protobuf::Map的接口,可以像上面一样创建标准的std::map,不过构造这个map会产生所有元素的深度拷贝。

本文完!

参考