在软件开发过程中,通常会使用 http 方式调用别人的接口获取数据,json 是一种流行且易读的数据交换格式,因此在调接口时,大家都习惯用这种格式。但对于微服务来说,或者对于高性能的 rpc 请求,http 和 json 实际还是有点过重,具体体现在,http 协议规定了在传输数据时,必须要有请求头,请求行,请求体。而 protobuf 格式的数据,可以很好解决这个问题。它对传输的数据做了很好的编码和压缩,虽然不易阅读,但传输的体积和效率,比 json 或 xml 好很多。与 protobuf 类似的,还有 thrift。虽然使用上有差别,但目的都是类似:高性能的序列化框架。本文记录下如何使用 proto 格式处理数据,或者称为消息。

注:本文的 protobuf 示例使用的语言是 C++,protobuf 版本是 proto2。

首先,我们都要写一个 proto 的文件,用来描述接口的字段以及类型,假定文件名叫 user.proto,内容如下:

package user

message User
{
	message Addr 
	{
		optional string province = 1;
		optional string city = 2;
		optional string road = 3;
	}

	enum Gender
	{
		MALE = 0;
		FEMALE = 1;
	}

	message Phone
	{
		enum Type
		{
			HOME = 0;
			COMPANY = 1;
		}
		optional Type type = 1 [default = HOME];
		optional string no = 2;
	}

	optional string name = 1;
	optional Gender gender = 2;
	optional uint32 age = 3;
	repeated Phone phone = 4;
	optional Addr address = 5;

}

以上就是 user.proto 的定义,有几点值得注意:

  • 每个 proto 都要声明 package,以表明本 proto 的作用域,类似于 java 中的 package。
  • 如果使用的 proto 版本是 v3,则文件第一行得写syntax = "proto3",如果是 v2 版本,则可以省略。
  • message 是 proto 中最基本的关键字,写 message 就如同定义一个类一样,有各种字段,以及字段的类型,常用的基本类型有 uint32,uint64,string,message 也可以嵌套定义,因此也有复合类型,如上面的 Addr,就是复合类型。
  • 每个字段都有序号,序号是 protobuf 在序列化和反序列时用来做标识,对于使用者来说不用过多关注,同时序号没有特别要求,可以调换字段位置,序号也可以不连续,但序号不能重复,序号最大 2^29-1,不过一般也不会定义这么大的序号。
  • 字段可以有默认值,如上面的 Phone Message 中的 type 字段。

写好 proto 文件,就相当于写了一份类的说明书,可以使用 protobuf 提供的编译器生成相应的类和 api,如果已经下好了 protobuf,可以执行下面语句。

protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/user.proto

其中,SRC_DIR 是 user.proto 所在的目录,DST_DIR 是生成文件所在的目录,执行完后,DST_DIR 下会多了几个文件:user.ph.huser.ph.cc ,打开文件,会发现是 User 类的定义,以及各种 api 方法。使用起来也很方便,下面是各种字段类型的读写操作。

#include "user.ph.h"

// 写操作
user::User admin;
admin.set_name("jim");
admin.set_gender(user::User::Gender::MALE);
admin.set_age(30);
auto phone = admin.mutable_phone()->Add();
phone->set_type(user::User::Phone::Type::COMPANY);
phone->set_no("13599999");
auto addr = admin.mutable_address();
addr->set_province();
addr->set_city();
addr->set_road();

// 对 admin 填充完毕,可以进行序列化存到文件,或者 rpc 传输,这里省略
// ...


// 读操作
string name = admin.name();
if (admin.has_age()) {
	int32 age = admin.age();
}
auto gender = admin.gender();
if (gender == user::User::Gender::MALE) {
	// ...
}
for (const auto & item : admin.phone()) {
	auto t = item.type();
	auto n = item.no();
}

string province = admin.address().province();
string city = admin.address().city();
string road = admin.address().road();

user::User admin2;
admin2.CopyFrom(admin);

上面就是对 User 类的读写操作,总结以下几点:

  • 对于基本数据类型的字段,如 string,int32,写就是 set_xxx,读就是调用与字段名相同的方法(在这点上我感觉有些别扭,为何写是 set,读不用 get 统一下?是为了保持和 C++ 的风格一致?)。
  • 要检查字段是否赋值,使用 has_xxx 方法。而如果字段是 repeated,就不能简单的使用 has_xxx,而要使用 xxx.size() 是否大于 0 来判断。
  • 对于复合类型的字段读写稍微麻烦点,写操作基本都是调用 mutable_xxx,获取相应的指针,然后对指针进行逐一赋值。读操作稍微简单,链式调用方法即可。
  • 对于 repeated 类型,可以使用 for 循环读取,赋值时,可以使用 for 循环赋值,也可以使用标准库中的 transform 来赋值,如:
std::transform(phone_list.begin(), phone_list.end(), google::protobuf::RepeatedFieldBackInserter(admin.mutable_phone()), [](const auto & phone) { 
	user::User::Phone p;
	p.set_type(phone.type());
	p.set_no(phone.no());
	return p;
});
phone_list  std::vector<user::User::Phone> 
  • 如果使用了 C++11,对于复合类型,尽量使用 auto 来定义接收值,可以简化不少。
  • 可以使用 CopyFrom 对一个对象赋值,避免字段的逐一赋值。

以上就是对 protobuf 的一些使用总结,如果你使用的是其他语言,如 java, python,也可以使用 proto 工具生成对应的类和 api,读写方式也基本类似。

总结:

  • 与 json 类似,protobuf 也是网络传输的一种方式,不同的是,它有自己的格式,这种格式不像 json 那么直观,但却相当紧凑,可以大幅度提高信息传递的效率。
  • 由于 protobuf 有自己的编码方式,但我们不用关心它是怎么编码,只需要使用它提供的编译器,自动生成 get/set 代码,直接对消息进行读写即可,极大提高了生产力。
  • protobuf 有详细友好的官方文档,而且也不难读,基本所有的问题都能在官方文档里找到答案。
  • protobuf 不是自我描述的,不像 json,xml,你拿到相应的内容,就能直观的看到每个字段以及值,protobuf 是以二进制方式存储和传输,无法直接读出任何内容,而只能通过 proto 文件,生成相应的 api,通过 api 才能正确读取。这点既是优点,使数据具有一定的 “加密性”,也是缺点,数据可读性极差。所以 protobuf 非常适合内部服务之间 RPC 调用和传递数据。目前大厂内部各服务间的调用,很多都是构建在 protobuf 上的。

参考

https://developers.google.com/protocol-buffers/docs/tutorials