Protobuf介绍

Posted by Hsz on June 2, 2021

Protobuf介绍

Protobuf是由google开源的一套序列化工具.它需要预先定义数据的schema,将其编译为需要的编程语言对应的类或者结构体,然后针对定义好格式的数据实例进行序列化和反序列化.

对于大多数编程语言涉及3个实体:

  1. .proto文件,用于定义数据的结构
  2. 对应编程语言的类/结构体定义文件,用于在对应编程语言中表现和使用数据,由protoc编译.proto文件得到.
  3. 由对应编程语言的类/结构体定义文件序列化后的字节串,用于实际的传输和存储

这3者之间的关系是:

  • 1 -> 2
  • 2 <-> 3

可以看到Protobuf之所以可以性能优异主要取决于1 -> 2这个步骤,这个步骤固定好了数据的schema,这样序列化出来的数据就不再需要包含schema中的数据比如字段名这些信息了,这就可以省很多空间.

本文的例子会具体介绍我常用的编程语言如何使用protobuf,代码在分支数据管理-Protobuf上,可以下载下来试试.

Protobuf基本格式

Protobuf的设计很大程度上是参考cpp的, 主要结构可以描述为:

  • 使用关键字声明功能
  • 使用{}分块
  • 使用;表示行结束
  • 使用=区分左值和右值,左值一般是描述信息,右值一般是序列化操作相关的东西
  • 使用//做注释

Protobuf声明语法

语法版本声明

目前Protobuf语法有两版既v2和v3.本文使用v3版本.这个版本功能更加全面.我们在.proto文件中在第一行使用

syntax = "proto3";

模块申明

Protobuf支持使用关键字package来声明模块做功能拆分和模块化.这部分语法参考了java的形式:

package foo.bar;

由于不同的编程语言模块化的实现方式是不同的,因此编译后这个声明在不同编程语言中的结果是不一致的.

编程语言 结果描述 额外的申明项 额外的编译项  
python 无效  
golang 变成golang的模块 option go_package = "{go语言的模块名}"; --go_opt=paths=source_relative用于将-I指定的目录下的目录结构映射到编译的输出中  
js 无效 --js_out=import_style={模块形式}:{输出位置}用于规定使用的模块形式,一般使用commonjs,binary配置

模块导入声明

Protobuf也支持模块导入,其语法与java类似.我们可以使用import语句声明导入模块.

import[ public] "data/bar.proto";

注意

  1. 导入的模块的依赖性会通过任意导入包含import public声明的proto文件传递.
  2. import后面跟的是.proto文件相对于编译时-I参数指定文件夹的位置.

基本类型

Protobuf支持如下基本类型:

类型 说明 默认值
double 双精度浮点数 0.0
float 单精度浮点数 0.0
int32 32位变长整型数,针对正数 0
int64 64位变长整型数,针对正数 0
uint32 32位无符号整型数 0
uint64 64位无符号整型数 0
sint32 32位变长整型数,针对负数 0
sint64 64位变长整型数,针对负数 0
fixed32 32位定长整形数,针对正数 0
fixed64 64位定长整形数,针对正数 0
sfixed32 32位定长整形数,针对负数 0
sfixed64 64位定长整形数,针对负数 0
bool 布尔型 false
string 最长2的32次方长度的字符串 空字节串
bytes 最长2的32次方长度的字节串 空字节串

容器类型

Protobuf中支持两种容器结构:

  • repeated {value_type}关键字用于声明同构不定长序列
  • map<{key_type}, {value_type}>关键字用于声明映射

这两种容器的使用方式和基本类型一样,这边就不再重复介绍.不过补充一句:

尽量不要使用map

protobuf的一大优势在于固定schema.固定schema牺牲了灵活性但带来了proto即文档的巨大便利,而map由于key是不固定的,所以开发人员会因为”灵活”对其滥用,造成消费者无法明确知道key的范围.

如果一定要使用map,那应该限制map的key类型必须为string,value类型必须是基础类型,将它当成只有一层的json使用.

自定义类型

同时Protobuffer支持两种自定义类型:

  • enum枚举类型
  • message类型

这两种类型都可以嵌套定义,也就是在一个message中定义enum或者message.通常我们的自定义类型习惯使用大写英文字母作为首字母.

枚举类型

枚举类型用于定义固定范围内的取值,比如星期几这种.通常枚举类型只有在枚举值不会有修改的情况下才会用,所以一般用的不多,更多的时候还是使用sting确保后续可扩展.下面是一个枚举定义的例子:

enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
}

枚举类型的定义从0位开始,且0位的值就是这个类型的默认值.每一位的左值是枚举中的一个值,通常用全大写的形式;右值则是对应枚举值的真实值.

通常情况下右值是不可以重复的.但我们可以在enum的定义中声明option allow_alias = true;来开启右值的别名,使用这个声明后右值就可以重复了,重复的含义是相同右值的枚举可以有多个不同的左值.

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}

message

message是Protobuf中最基本的结构,每条信息的schema实际上就是由message来定义的.一个典型的message定义如下:

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

在消息定义中,我们使用一行语句声明一个字段的信息.其中左值部分需要声明字段的类型和字段名;每个字段都有唯一的右值作为数字标识符.这些标识符是用来在消息的二进制格式中识别各个字段的,一旦开始使用就不能够再改变.1到15之内的标识号在编码的时候会占用一个字节;16到2047之内的标识号则占用2个字节.所以应该为那些频繁出现的消息元素保留1到15之内的标识号.最小的标识号可以从1开始,最大到2的29次方-1,但不可以使用其中的19000到19999之间的标识号,Protobuf协议实现中对这些进行了预留.

特殊类型Any

Any类型消息允许你在没有指定他们的.proto定义的情况下使用message作为一个嵌套类型.一个Any类型包括一个可以被序列化bytes类型的任意消息以及一个URL作为一个全局标识符和解析消息类型.为了使用Any类型需要导入google/protobuf/any.proto模块

import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Any的特殊在于它在获得到字段后还需要为其做针对protobuf中定义的类型转化.下面是常用编程语言中转化any的方法:

golang

import "github.com/golang/protobuf/ptypes"

...
sd := SomeProtoType{}
err = ptypes.UnmarshalAny(instance_of_any, &sd)
...

python

any_message.Pack(message)
any_message.Unpack(message)

js

// Storing an arbitrary message type in Any.
const status = new proto.foo.ErrorStatus();
const any = new Any();
const binarySerialized = ...;
any.pack(binarySerialized, 'foo.Bar');
console.log(any.getTypeName());  // foo.Bar

// Reading an arbitrary message from Any.
const bar = any.unpack(proto.foo.Bar.deserializeBinary, 'foo.Bar');

C++

在cpp中我们通过调用下面的模板使用any(google::protobuf::Any)

class Any {
 public:
  // Packs the given message into this Any using the default type URL
  // prefix “type.googleapis.com”. Returns false if serializing the message failed.
  bool PackFrom(const google::protobuf::Message& message);

  // Packs the given message into this Any using the given type URL
  // prefix. Returns false if serializing the message failed.
  bool PackFrom(const google::protobuf::Message& message,
                const string& type_url_prefix);

  // Unpacks this Any to a Message. Returns false if this Any
  // represents a different protobuf type or parsing fails.
  bool UnpackTo(google::protobuf::Message* message) const;

  // Returns true if this Any represents the given protobuf type.
  template<typename T> bool Is() const;
}

和map一样,Any也应该不要随便使用.应该尽量使用Oneof来代替Any

Oneof限制

如果你的消息中有很多可选字段,并且同时至多一个字段会被设置,你可以加强这个行为使用oneof特性节省内存.

Oneof字段就像可选字段,除了它们会共享内存,至多一个字段会被设置.这一特性类似C语言中的union共用体.你可以查看不同语言的接口使用类似case()或者WhichOneof()方法检查哪个oneof字段被设置.

message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

需要注意在oneof字段中能使用repeated关键字

编译Protobuf

通常我们使用protoc来编译.proto文件到指定的编程语言,其基本形式是

protoc -I={.proto文件所在文件夹}[ -I={.proto文件所在文件夹}...] \
--{对应语言}_out={输出的对应语言模块源文件所在文件夹}[ --{对应语言}_out={输出的对应语言模块源文件所在文件夹}...] \
{目标.proto文件}[ {目标.proto文件}...]

目前protobuf已经原生支持或者通过插件形式支持了几乎所有主流编程语言.这边只介绍我常用的.

编程语言 编译依赖插件 编译命令 编程语言导入需要的依赖包
python protoc -I={.proto文件所在文件夹} --python_out={输出位置} {.proto文件} protobuf
javascript protoc -I={.proto文件所在文件夹} --js_out=import_style=commonjs,binary:{输出位置} {.proto文件} google-protobuf
golang google.golang.org/protobuf/cmd/protoc-gen-go protoc -I={.proto文件所在文件夹} --go_out={输出位置} --go_opt=paths=source_relative {.proto文件} github.com/golang/protobuf
C++ protoc -I={.proto文件所在文件夹} --cpp_out={输出位置} {.proto文件} libprotobuf.so,libprotobuf-lite.so  

需要注意golang必须在定义文件中的头部指定option go_package = "./"来声明编译出来的文件放在什么位置

Protobuf的性能特点和适用范围

Protobuf首先是性能优异,有测试显示其序列化和反序列化速度是msgpack的2倍至3倍,序列化后的结果大小也约是msgpack的70%至80%.由于固定schema且编译为二进制数据,所以存在一个问题–一段二进制数据无法直接知道它应该由哪个schema来解析.因此使用Protobuf会略微麻烦些,需要在Protobuf序列化后的信息外额外声明或者事先约定它使用的哪个schema.很多时候这不是问题,但也有时候确实是问题.

Protobuf适用于稳定的追求性能的业务.修改.proto文件会是一件非常麻烦的事情.但由于其代码既文档的特性其非常适合作为不同团队定义接口的工具.