Google protocol buffer

0 阅读15分钟

一、基石

写在前面

本文内容按如下方式组织:

  1. 第一部分介绍了序列化的一些基础概念, 以及对pb做了一个简单的介绍。
  2. 第二部分给出了使用pb的步骤,以及使用建议,同时介绍了如何升级pb协议。
  3. 第三部分模块对pb结构化的方式做了探究, 以及一些字段压缩存储的介绍。
  4. 第四部分模块列出了本文的参考资料、一些常见问题。

Ps: 如果只是想在工程中使用pb,请直接查看《使用pb的步骤》

基础概念

序列化的相关概念

来自百度百科的解释:

序列化 (Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程。在序列化期间,对象将其当前状态写入到临时或持久性存储区。以后,可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。

来自维基百科的解释

序列化(serialization)在计算机科学的资料处理中,是指将数据结构对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。

总结

  • 序列化: 将数据结构或对象转换成可以存储或传输的形式的过程;
  • 结果形式一般为二进制流、字符串、xml等

常见的序列化方式

XML

常见的序列化方式, 针对人和机器都有比较好的可读性, 但用来序列化对象的时候会显得冗长而复杂, 占用存储也比较高。

JSON

Json 是最广泛使用的序列化协议之一, 本质上是描述"key-value"的集合。也有很强的可读性,而且因为协议相对xml比较简单, 所以序列化后的大小, 解析速度都有很大提升。 缺点在于额外的开销比较大, 在一些场景下需要反射来进行序列化和反序列化。

Thrift

Thrift是Facebook开源的RPC框架所使用的协议,相对于JSON和XML而言,Thrift在空间开销和解析性能上有了比较大的提升。 相关文档较少, 没具体分析。 输出为二进制数数组。

Protobuf(本文重点)
Avro

Avro属于Apache Hadoop的一个子项目。 Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。

序列化对比

解析性能

3ebc3f3781f6516a3d0ac4bc9e677c76__fallback_source=1&height=1280&mount_node_token=doxcnjZqy3MLn0UW8fnUccMNSLd&mount_point=docx_image&policy=equal&width=1280.png

序列化之空间开销

6121759d62deb76b2752eec3f5edfdc1__fallback_source=1&height=1280&mount_node_token=doxcnKueL02xeHUr0mDLN8Xemzh&mount_point=docx_image&policy=equal&width=1280.png

pb介绍

Protocol buffer 是 Google 用于序列化结构化数据的语言中立、平台中立、可扩展的机制,它很像XML,但更小、更快、更简单。您只需定义一次数据的结构化方式,然后就可以使用自动生成的源代码轻松地将结构化数据写入和读取各种数据流,并可以使用各种语言来操作。

pb的优点:

  1. 序列化需要的额外空间小, 解析性能高。
  2. 提供了标准化的结构描述文件, 对业务参与方有很强的约束。
  3. 自动生成编译器,对开发者十分友好。
  4. 序列化之后的数据量相对小, 特别适合持久化存储。
  5. 以及其他我还没发现的优点

二、最佳实践

使用pb的步骤(以Java为例)

  1.1 安装 pb compiler

  1. 如果你的电脑上安装了 brew 直接执行 brew install protobuf。若未安装brew, 建议去安装一个,很方便
  2. 实在不想安装, github 上 readme 中安装教程写的很详细>github.com/protocolbuf…

  1.2 使用pb

  1. pb的工作流程

  2. 编写协议文件

    1. 创建一个后缀为.proto的文件,编写协议结构
syntax="proto3";  // 指定当前协议使用的语法, 默认proto2
option java_package = "com.test.pb"; // 附加选项, 可指定生成的结果属性
option java_outer_classname = "PBInnerTest"; // 了解更多附加选项,参考资料10

message TestProtocolBuffersInner{
 int32 a = 1;
 int64 b = 2;
 float c = 3;
 string d = 4;
 repeated int32 e = 5;
 sint32 f = 6;
 TestEntry g = 7;

}

message TestEntry {
 int32 a = 1;
}

语法及支持的类型请查看参考资料第8、9行

  1. 生成编译器(生成编译器的更多细节, 请查看参考资料11)

protoc TestProtocolBuffers.proto --java_out=./

也可使用idea 插件, 具体插件为Protobuf Generator 和 Protobuf Support, 前者生成代码,后者提供option支持

  1. 在代码中使用编译器序列化相应数据
    // 构建PB对象
    PBInnerTest.TestProtocolBuffersInner.Builder builder
            = PBInnerTest.TestProtocolBuffersInner.newBuilder();

    builder.setA(300);
    builder.setB(800);
    builder.setC(1.0f);
    builder.setD("hello");
    builder.addE(10);
    builder.addE(127);
    builder.addE(82687);
    builder.setF(-1);

    // 对象赋值
    PBInnerTest.TestEntry.Builder entryBuild
            = PBInnerTest.TestEntry.newBuilder();
    entryBuild.setA(80);
    builder.setG(entryBuild);


    byte[] bytes = builder.build().toByteArray();
    System.out.println(Arrays.toString(bytes));

    writeData(bytes);

最佳使用姿势

2.1 封装sdk

针对协议提供sdk,封装压缩和解压的实现,服务方和调用方使用相同版本sdk, 确保数据解析的正确性。

举例:

用户画像压缩:因画像中特征值类型不同(经纬度是浮点数, url是字符串, 类目是整数),故pb需要定义多种类型的属性。

syntax="proto3";
option java_package = "com.importer.userportrait.proto";
option java_outer_classname = "PBUserPortraitOut";

message UserPortraitInner {

  repeated PortraitIntTagValueInner intTags = 1;
  repeated PortraitLongTagValueInner longTags = 2;
  repeated PortraitFloatTagValueInner FloatTags = 3;
  repeated PortraitStringTagValueInner StringTags = 4;

}


message PortraitIntTagValueInner {
  int32 fk = 1;
  AnalysisData analysisData = 2;
  repeated IntNodeValue intValue = 3;
}

message PortraitLongTagValueInner{
  int32 fk = 1;
  AnalysisData analysisData = 2;
  repeated LongNodeValue longValue = 3;
}

message PortraitFloatTagValueInner{
  int32 fk = 1;
  AnalysisData analysisData = 2;
  repeated FloatNodeValue floatValue = 3;
}

message PortraitStringTagValueInner{
  int32 fk = 1;
  AnalysisData analysisData = 2;
  repeated StringNodeValue strValue = 3;
}

message AnalysisData {
  int64 sum = 1;
  float avg = 2;
}

message IntNodeValue {
  int32 fv = 1;
  int32 value = 2;
}

message LongNodeValue {
  int64 fv = 1;
  int32 value = 2;
}

message StringNodeValue {
  string fv = 1;
  int32 value = 2;
}

message FloatNodeValue {
  float fv = 1;
  int32 value = 2;
}

使用pb的方式构建builder, 代码逻辑比较复杂,调用方与服务方进行重复工作且需保持一致。

只需对外暴露压缩和解压两个方法入口, 使用非常方便, 后续升级协议版本,大家一块升级sdk就行了。

2.2 增加冗余字段, 定期更新

如果业务发生结构变更比较频繁,可以考虑冗余一些字段,等修改的字段多了,再一次性升级pb协议。

如何升级协议版本

建议阅读完原理再来阅读当前段落,体验感更佳。

升级版本一般会更改协议中的字段,具体会有以下几种情况:

  1. 如果是删除字段,老编译器读取新消息将会有默认值,新编译器读取老数据将不会赋值字段

  2. 如果是更改字段, 不建议你更改,类型有些可以兼容, 但编号绝不能改! 建议新加一个字段兼容, 过度时期后删除原字段。

  3. 编译器处理变更数据原则

老数据新数据
老编译器一切正常如果是新字段,老编译器会正常读取新数据,忽略新字段。
新编译器新字段的值具有默认值一切正常

更多关于升级字段的建议,请查阅资料12


三、探究原理

pb的结构化

1.1 基本概念

  1. pb是一系列键值对。消息的二进制版本只是使用字段的编号作为键。
  2. 每个字段的名称和声明的类型只能在解码端通过引用消息类型的定义(即.proto文件)来确定。
  3. 当消息被编码时,键和值被连接成一个字节流。
  4. 当消息被解码时,解析器能够跳过它无法识别的字段。这样,可以将新字段添加到消息中,而不会破坏旧编译器。pb使用编号+存储类型的方式来实现字段的唯一性,我们将此称为tag。

1.2 存储结构

1.2.1 整体数据是采用T-L-V的方式存储的

TLV存储方式的优点:

不需要分隔符分割数据,节省分隔符空间

数据存储紧密,存储空间利用率高

未设置值的数据不会被序列化,不占用空间

1.2.2 tag的生成规则

tag 为 varint类型, 计算规则为 tag = (field_number << 3) | wire_type

ps: 常见的存储类型

以我们最佳实践中的代码为例, 输出的二进制数据如下:

Tag 解析

序号字节序输出数据原始定义计算方式
1000001000Int32 a = 11 << 3 丨 0
2300010000int64 b = 22 << 3 丨 0
3600011101float c = 33 << 3 丨 5
41100100010String d = 44 << 3 丨 2
1.2.3 Length 的定义及使用

在常见的存储类型中,type = 2 代表变长的数据,包括字符串、字节数组、对象类型、还有集合数据;length 字段用来约定其后多少个字节是当前字段的数据。length使用varint存储,而且只有tpye = 2 存在length字段, 别的类型都是TV方式存储的。

1.2.4 Value 的编码方式

pb中采用了很多方式来实现对数据的编码,他们对数据的压缩已经冲破字节到达bit维度,力求每一个bit都是有用的,这种追求完美的精神,精益求精的做事方式,都是值得我们学习的。下面两个段落将介绍value的编码方式

常见类型的存储方式

序号原始类型对应java类型编码方式压缩效率简介
1int32,int64Int/longvarint当数值绝对值较小时,压缩效率极高可变长整数, 下节叙述
2uint32,uint64Int/longvarint当数值绝对值较小时,压缩效率极高
3sint32,sint64Int/longzip-zags当数值绝对值较小时,压缩效率极高
4boolboolvarint当数值绝对值较小时,压缩效率极高
5enumenumvarint当数值绝对值较小时,压缩效率极高
6fixed32,sfixed32Int/long32-bit固定四个字节存储将数据固定字节,不再增加符号位。如果你的数据经常大于2(28),它的效果将好于varint
7floatfloat32-bit固定四个字节存储几乎不压缩数据,推荐使用FITS
8fixed64,sfixed64Int/long64-bit固定八个字节存储同fixed32,如果大于2(56),使用它
9doubledouble64-bit固定八个字节存储几乎不压缩数据
10stringstringTLV不算特别高,本就无额外字符最大限制长度为2(32)
11bytesByteStringTLV不算特别高,本就无额外字符最大限制长度为2(32)
12embedded messagesObjectTLV内嵌对象,他的压缩效率取决于他的对象结构
13repeated fieldsListTLV集合,压缩效率取决于成员类型

Base 128 Varints 与 zip-zags 的实现

3.1 Base 128 Varints

在计算机中, bit是计算机能够理解的最小单位,8 位的集合称为一个字节,其中每个位可以表示为 0 或 1,所以总共有 2 ^ 8 = 256 中组合方式。

bit的组合代表一个十进制数字,比如00001001代表9。但我们要表示的数如果超出256了,一个字节就放不下了,这时候我们要引入更多的字节来表示。

比如 7896 这个数字表示为 0001-1110-1101-1000, 它将存在于两个字节中。而且可以再扩充。但是不知道大家想过没有,计算机是顺序读取字节并写入内存的, 如果找到两个字节0001-1110和1101-1000, 我们怎么确定这是 两个数字30和216,还是一个数字7896?

大体有两种解决方案:

  1. 预先定义写入值的字节数

大多数编程语言都使用这种方式解决,称之为基本类型, 比如java中的int定义为四个字节, 我写入的时候编译器会写四个字节,读取的时候也会读取四个字节。如果数据超出四个字节,使用更大的类型来存储。但这种情况针对绝对值小的数将是特别大的浪费. 比如 int a = 1, 它的存储将为:0000,0000 - 0000,0000 - 0000,0000 - 0000,0001 也许有人会说如果数比较小,我定义short不就行了? 就一个字节。 如果你能确保每个数字都小于127,你当然可以定义。不过现实情况除了内部使用的枚举等静态变量,很难保证数字范围。这么看定义分隔符是个更好的选择。

  1. 定义分隔符

对于一些无法感知长度的类型,一般都会采用分隔符的方式,比如java中的string,一般采用空字节来分隔, 编译器会连续读取字节,直到遇到空字节。这个解决方案好像能解决问题, 但几乎每个数字都需要额外存储分隔符, 太浪费空间了, 而且大多数计算机操作涉及大量数字计算, 每次都要判断分隔符,对性能有很大损耗,接下来就介绍pb中的重点优化!

Base 128 Varints

用一句话概括: 一个字节的8个bit位, 只有7位用来存储数据,最高位为有效字节位。有效字节位为1,代表后面的数字将会和当前字节一起表示,为0代表本字节为当前数据的最后一个字节。

例如:

1 = 0000,0001

128 = 1000,0001 - 0000,0000

显而易见, 它浪费了1个bit位,那怎么带来的存储空间的降低? 它就是融合了上述的方案一和方案二,既不浪费额外存储空间, 又能动态调整存储空间, 比如 int a = 1, 128, 12832。 存储这三个数将会带来很大的空间压缩。

pb中的varint是 采用小端存储的varint, 解析数据需要逆转字节后去除有效位,得到真正的二进制序列。

定义了一个简单的结构:

syntax="proto3";
option java_package = "com.bj58.zhaopin.dmp.userprofile.proto";

message TestVarintsInner{
  optional int32 a = 1;
}

Set a = 300, 得到的二进制序列为

其中第0字节为tag, 1、2字节为value

  1. 原始序列: 1010,1100-0000,0010
  2. 小端存储,逆转序列: 0000,0010-1010,1100
  3. 去除有效位:000,0010-010,1100 -> 100101100
  4. 100101100 : -> 4+8+32+256 = 300

3.2 zip-zags

除了int32、pb中还定义了uint32和sint32, 其中uint32代表无符号数,sint代表绝对值较小数,使用zip-zags存储

存储原理:

对于正整数,我们可以把无意义的0去掉,实现压缩(varint).

0000,0000-0000,0001 --> 1

对于负数,为了计算方便, 计算机内的负数是以补码的形式存在的(按位取反+1,附录有资料),

例如 -1(short) = 1111,1111 这玩意咋压缩?

解决方案:

  1. 首先,负数的首位是符号位,他阻碍了压缩,我们将符号位移到最后,针对1000,0001 就变成了11
  2. 一些绝对值比较小的数字, 会有很多1前缀, 我们符号位不变按位取反,可以将绝对值较小的数字压缩, 1111,1111 -> 0000,0001

如果是正数, 只进行移位操作,带来的成本也不大。1会被编码成2。

0000,0001 -> 0000,0010

如何在代码中实现:

(n << 1) ^ (n >> 31)

实现的很巧妙,留待大家研究


四、后盾

参考资料

感谢前辈的心血,让我们站到巨人的肩膀上

序号描述链接
1百度百科对序列化的定义baike.baidu.com/item/%E5%BA…
2维基百科对序列化的定义zh.wikipedia.org/zh-sg/%E5%B…
3一篇对序列化的优秀文章tech.meituan.com/2015/02/26/…
4Google 对各种序列化的测试code.google.com/archive/p/t…
5pb安装流程github.com/protocolbuf…
6pb官网developers.google.com/protocol-bu…
7Pb 官方使用指南developers.google.com/protocol-bu…
8Pb 语法及支持类型概述developers.google.com/protocol-bu…
9Pb 语法指南大全(proto3)developers.google.com/protocol-bu…
10Pb 支持的developers.google.com/protocol-bu…
11生成编译器的方式developers.google.com/protocol-bu…
12更新消息developers.google.com/protocol-bu…
13类型默认值developers.google.com/protocol-bu…
14对pb原理总结的优秀文章blog.csdn.net/shijinghan1…
15对 varints的优秀总结hackernoon.com/encoding-ba…
16zigzap优秀解读blog.csdn.net/weixin_4370…
17大端存储与小端存储www.geeksforgeeks.org/little-and-…
18大端存储与小端存储www.section.io/engineering…
19对补码的解释www.ruanyifeng.com/blog/2009/0…
20二进制阅读器www.sweetscape.com/010editor/
21fitsen.wikipedia.org/wiki/FITS