protobuf及其编码

1,079 阅读8分钟

简介

概况

  • 谷歌出品
  • 语言无关,平台无关,可扩展
  • 序列化/反序列化数据

版本

proto2 vs. proto3 对比:

  • 特性区别
  • 默认值处理:pb2可设置默认值;pb3的默认值仅能根据type确定,无法自行设置
  • 性能差异很小,约1%

支持语言

C++, Java, Python, C#, JavaScript, Ruby, Go, PHP, Dart

使用

安装

  • 方式一:编译安装
  • 方式二:直接安装protoc (推荐)

快速安装步骤:

  1. github.com/protocolbuf… 下载 protoc-x.x.x-osx-x86_64.zip 并解压;
  2. 执行以下命令
cd protoc-x.x.x-osx-x86_64
cp -r include/ /usr/local/include/
cp -r bin/ /usr/local/bin/
  1. 测试命令
protoc

执行后看到帮助信息,代表安装成功

编译

protoc ./xx.proto --js_out=import_style=commonjs:.

--js_out的语法: --js_out=[OPTIONS:]output_dir,多个options间用逗号分隔

依赖

protoc编译生成的代码依赖了google-protobuf

npm install google-protobuf

方法集

在编译出的js代码中,提供了一整套方法,重要的有:

  • Msg.deserializeBinary
  • Msg.prototype.toObject
  • Msg.prototype.serializeBinary // 序列化为Uint8Array
  • 字段操作:
    • 单一标量值
      • getFoo
      • setFoo
      • hasFoo
      • clearFoo
    • repeated
      • getFooList
      • setFooList
      • addFoo
      • clearFooList
    • bytes类型
      • setFoo // 接受base64编码值或Uint8Array
      • getFoo
      • getFoo_asB64()
      • getFoo_asU8()

.proto文件

syntax = "proto2";

message SearchRequest {
  required string query = 1;
  optional int32 page_number = 2;
  optional int32 result_per_page = 3;
}

字段id (Field Number)

用于在二进制消息格式中唯一标识字段,并且当消息类型开始使用后,字段id就不能再更改了。

范围是 1~2^29-1(536870911) 19000~19999是保留数字,用于protobuf的实现,用户不可使用。 被标记为 reserved 的数字也不可使用。

编码时,1-15只占用1个字节,16-2047占用2个字节。设计消息体时,要注意:

  • 1-15号留给最常使用的字段
  • 要留一些位置给将来可能添加的常用字段

字段可选性

  • required: 必须包含一个本字段
  • optional: 可以不含 或者 包含一个本字段
  • repeated: 可包含0~任意个本字段,且值的顺序会被保存 由于历史原因,repeated 的数字类型编码效率不高。当数字类型(varint, 32-bit, or 64-bit)时,可以使用 [packed=true] 来启用更高效的编码:
repeated int32 samples = 4 [packed=true];

关于 required 的争议:一旦将字段设为required后,就不能改成非必选了,因为旧reader会认为它不完整。一种实践是只使用optional和repeated,然后在具体代码中检查对必选字段进行检查。这种实践在谷歌内部也有争议,并未得到统一。

reserved字段id

比如之前用过2,但后来又删掉了。为了防止后来又用2,可以把2设为reserved:

message Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "foo";
}

可以设置字段id,也可以设置字段名。且两者不能在同一个reserved声明中。

默认值

当消息中没包含optional字段值时,可以使用其默认值:

optional int32 result_per_page = 3 [default = 10];

若未声明默认值,则使用类型相关的默认值:

  • string:空字符串
  • bytes: 空字节串
  • bools: false
  • 数字:0
  • enum: 定义中的第一项

enum

message SearchRequest {
  enum Corpus {
    WEB = 1;
    IMAGES = 2;
    VIDEO = 3;
  }
  optional Corpus corpus = 1 [default = WEB];
}

如果其他message内想使用Corpus,可以使用_MessageType_.EnumType, 即 SearchRequest.Corpus

enum可以允许别名,即多个名称指向同一个值:

enum EnumAllowingAlias {
  option allow_alias = true;
  UNKNOWN = 0;
  STARTED = 1;
  RUNNING = 1;
}
enum EnumNotAllowingAlias {
  UNKNOWN = 0;
  STARTED = 1;
  // RUNNING = 1;  // Uncommenting this line will cause a compile error inside Google and a warning message outside.
}

使用其他message

message SearchResponse {
  repeated Result result = 1;
}

message Result {
  required string url = 1;
  optional string title = 2;
  repeated string snippets = 3;
}

导入:

import "myproject/other_protos.proto";

在a.proto中导入b.proto后,可以直接使用b.proto中的所有message。

嵌套message

message SearchResponse {
  message Result {
    required string url = 1;
    optional string title = 2;
    repeated string snippets = 3;
  }
  repeated Result result = 1;
}

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

修改message

注意事项:

  • 不要修改已存在的字段id
  • 新增字段应该是optional或repeated
  • 非required字段可以删除,只要它的字段id不再被使用
  • int32,uint32,int64,uint64,bool 这些类型互相兼容,可直接修改
  • sint32和sint64互相兼容,但这两个和其他数字类型不兼容
  • 当bytes使用utf-8时,string和bytes是兼容的
  • fixed32和sfixed32兼容,fixed64和sfixed64兼容

extensions

a.proto

message Foo {
  // ...
  extensions 100 to 199;
}

b.proto

import "./a.proto";

extend Foo {
  optional int32 bar = 126;
}

package包名

在.proto文件中可以声明本文件的包名: a.proto

package foo.bar;
message Open { ... }

b.proto

import "./a.proto";
message Foo {
  ...
  required foo.bar.Open open = 1;
  ...
}

类型

int64(变长编码) 和 fixed64(定长编码) 对比:

局限性

由于js的Number基于IEEE 754标准,最大安全整数为 2^53 - 1 (Number.MAX_SAFE_INTEGER),无法精确表示到int64中较大的数字(例如:2^54)。实验:

编码

varint编码

varint是用于整型数字的一种编码方式。它用一个或多个字节表示一个数字,较小的数字使用更少字节表示。

编码过程: 300 ↓二进制 100101100 ↓补齐为7的倍数位 00000100101100 ↓7位分组 0000010 0101100 ↓插入msb 00000010 10101100 ↓倒序 10101100 00000010 ↓Uint8Array [172, 2]

解码过程: [172, 2] ↓ 二进制 10101100 00000010 ↓倒序 00000010 10101100 ↓去掉msb 0000010 0101100 ↓连接 00000100101100 ↓转为十进制 300

message结构编码

tag的值为:(field_number << 3 | wire_type) 计算出值后,将值使用varint编码,再塞入message的二进制数据中。

由此可见,tag中包含了 字段id 和 wire_type 两个信息。wire_type有:

举个解码的例子: 例如字节流 0x08 0x96 0x01 的解码过程为: varint tag是0x08, 二进制为 0000 1000, 去掉msb为 000 1000, 分组为 0001 000,由此可知:

  • field_number = 1
  • wire_type = 0 = varint类型 知道后面是varint类型了,就可以解码了: 0x96 0x01 字节流 1001 0110 0000 0001 二进制 0000 0001 1001 0110 倒序 000 0001 001 0110 去掉msb 1001 0110 连接 150 十进制

有符号整数

当编码负数时,有符号整数(sint32/sint64)和“普通”整数(int32/int64)有区别:

  • 使用int32/int64编码负数时,结果的varint编码总是要用10字节来表示,它实际时一个很大的无符号整数,因为要使用最高位表示符号
  • 如果使用sint32/sint64,编码时先使用ZigZag编码将整数映射为一个无符号整数,在使用varint编码。

ZigZag编码

ZigZag编码将有符号整数映射为无符号整数,绝对值较小的数字的编码值也比较小(例如-1被映射为1)。它在负数和正数之间“之字形”跳动来实现这一点:

编码计算公式: sint32: (n << 1) ^ (n >> 31) sint64: (n << 1) ^ (n >> 63) 含义:

  • 左移1位可以消去符号位,低位补0
  • 有符号右移31位将符号位移动到最低位,负数高位补1,正数高位补0
  • 按位异或 对于正数来说,最低位符号位为0,其他位不变 对于负数,最低位符号位为1,其他位按位取反

解码计算公式: (n >>> 1) ^ -(n & 1)

含义:

  • 无符号右移1位
  • 按位与1,然后取负值,这一步非常巧妙,对于正数就是0,负数就是-1
  • 按位异或得到结果 正数是与0按位异或 负数是与-1按位异或

编码公式中向右移位的部分是算术位移,其结果:

  • n是正数时,结果的所有bit全为0
  • n是负数时,结果的所有bit全是1

非varint数字

  • double 和 fixed64: wire type是1,占用64位
  • float 和 fixed32: wire type是5,占用32位 两者都是小端字节序存储的,即高位在后,低位在前。

说protobuf压缩数据没有到极限,原因就在这,因为float/double这些浮点类型没有压缩。

字符串

字符串采用Tag-Length-Value形式编码,wire type为2,编码后,在key值后会有一个数字表示数据值的字节长度。例如:

message Test2 {
  optional string b = 2;
}

将b的值设置为"testing",得到:0x12 0x07 0x74 0x65 0x73 0x74 0x69 0x6e 0x67 0x12: key值,由(2 << 3) | 2 -> 0x12 计算而来 0x07: 数据值字节长度是7 0x74 ... 0x67: “testing”的utf-8编码

嵌套message

编码方式与字符串类似,采用Tag-Length-Value形式。

优缺点

在序列化方面,protobuf相比XML,有如下优点:

  • 数据体积小
  • 更快的反序列化速度
  • 可自动化生成数据访问类

缺点:

  • 以二进制方式存储,可读性几乎为0
  • 必须有.proto定义,才能够解析
  • 不适合大message处理,一般超过1M字节的message就应该考虑其他方案了

一些FAQ

为什么没有proto1

早在2001年,google内部就开始protobuf的开发了,最初版本就是proto1。经过多年演变,很多使用它的开发者想到什么特性就自己向里面添加了,这就造成代码很混乱。随后他们意识到这样开发项目是不可行的。 于是在proto2中进行了完全的重写,代码组织更加合理了,而且也完全不会依赖任何一款google的未开源项目。

为什么叫“Protocol Buffers”?

这个名字起源于最初的版本,当时还没有protoc编译器帮我们生成class代码。当时是真的有个类叫 ProtocolBuffer的类用作缓冲区,用户可以通过类似 AddValue(tag, value)的方法添加键值对数据并保存为字节数据,并且最后可以一次性将所有数据输出。 后来这名字就一直沿用了。

其他实现:protobufjs

一套开源的protobuf的js实现 github.com/protobufjs/…

命令

  • pbjs:根据.proto文件生成json描述符/静态代码
  • pbts:根据静态代码生成.d.ts文件

特性

  • 支持直接加载.proto/.json
  • 库版本 full/light/minimal,分别对应.proto/json/static code
  • 校验数据类型

long.js

js中安全整数最大值是2^53-1 (Number.MAX_SAFE_INTEGER); js的位运算只处理[-2^31, 2^31 - 1] 或 [0, 2^32 - 1]范围内的整数; protobufjs处理大数时使用了long.js,突破了IEEE 754的限制,它能够处理大于2^53-1的整数,且支持64位整数的位运算

相关文档

官方文档:developers.google.com/protocol-bu…

pb介绍:halfrost.com/protobuf_en…

pb序列化:halfrost.com/protobuf_de…

IEEE754: zh.wikipedia.org/wiki/IEEE_7…

浮点数:en.wikipedia.org/wiki/Floati…

ZigZag:wikimore.github.io/2016/09/22/…