Protocol Buffers

Protocol Buffers,又名 protobuf,是谷歌内部使用的一种数据交换格式,于 2008 年开源 (proto2), 2016 年发布 proto3。后被业界广泛采用发展至今。

Protobuf timeline

简介

谷歌在官方文档上说 Protocol buffers 是一种语言无关、平台无关的,用于序列化结构数据的可扩展机制。与之相比,我更喜欢其在 Github 上的描述 Protocol Buffers - Google's data interchange format —— 一种数据交换格式,清楚明了。用于序列化结构数据的可扩展机制还自罢了,说的过去但太细节,不了解的人也根本不明白在说什么。这符合谷歌文档一贯的尿性,看了等于没看,没看却不等于看了,你品品。而语言无关、平台无关更是无用的废话,可媲美门是可以开的,饭是可以吃的,空气是可以呼吸的,……。试问那个数据交换格式不是这样的!

Protocol buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.

数据交换格式

为了进程、程序、客户服务器之间通信,人们定义了一批数据交换格式以应对不同的场景。和 Protocol Buffers 同级,近期又比较流行的有 JSONXMLYAML,相信大家都不陌生。Protocol Buffers 也类似,编码方式不同而已。和 JSON 等相比,Protocol Buffers:

  • 以二进制存储,体积小
  • 编码解码速度快
  • 可读性差

序列化与反序列化

所谓序列化与反序列化,是指将数据结构或对象状态转换为可以存储的格式(例如,存储在文件或内存缓冲区中,或通过网络传输),并稍后在相同或另一个计算机环境中重建格式的过程。

一般来说,先由协议方定义数据交换格式,再由编程语言/开源社区予以支持。某些流行格式的序列化与反序列化甚至被包含在语言模块中,例如 Ruby 的 JSON 模块。公共的解析模块只能把 JSON 反序列化成内置的格式,再映射到具体模型上。以 Ruby 为例,先将 JSON 解析成内置结构 Hash,然后再转存到对应的模型上。

require 'json'
json = '{"name":"zddhub","age":18,"blog":"https://zddhub.com","income":18.125}' # Serialized JSON
ruby_hash = JSON.parse(json)
ruby_hash # => {"name"=>"zddhub", "age"=>18, "blog"=>"https://zddhub.com", "income"=>18.125}
ruby.class # => Hash
author = Author.new(ruby_hash["name"], ruby_hash["age"], ruby_hash["blog"], ruby_bash["income"]) # Mapping
author.class # => Author

Protocol Buffers 做的更直接。它允许直接使用 Protocol Buffers 定义的接口描述语言来定义模型,之后通过 Protocol Compiler protoc 来生成对应语言的模型代码,(如上例中的 Author)。该模型负责序列化与反序列化,而且不需要再转存或映射,可直接使用。

protoc 起初只支持谷歌内部使用的六种语言,后发展到十种,其它语言由第三方提供

通常,我们通过编码来实现序列化,通过解码来反序列化。在本文的语境中,序列化反序列化与编码解码是一个意思。

综上,Protocol Buffers 做为一种数据交换格式,主要负责两件事:

  • 定义了一种接口描述语言 (Interface Description Language),用来描述需要交换的数据结构
  • 定义了一种编码解码方式,用来序列化反序列化 Protobuf 数据

Protocol Buffers 接口描述语言

Protocol Buffers 接口描述语言描述用于交换的数据结构,语法和现今大部分编程语言的数据结构一致。一般来说,编程语言的数据结构主要有:

  • 基础类型:Integer,Float,Double,Boolean,Null, Nil, ……
  • 字符串
  • 集合类:Array,Map, List, Hash, Dictionary,……
  • 自定义结构:Class,Enum,Struct,Type,Union, ……

Protobuf 也一样,只不过选择的关键字不同。具体的语法可以参考 Language Guide (proto3)。若没有特别说明,本文内容以 proto3 为准。

我一般会用一个 CheatSheet 来快速了解一门新语言的语法,当然 protobuf 也有,这里是 Github Gist 上一个 proto3 的 CheatSheet,供大家参考。

这个 CheatSheet 有两百多行,远远超出了我的脑容量。我一般只能记住一成,所以又总结了个精简版,如下:

/* proto3_syntax.proto 文件名 */
syntax = "proto3"; // 1. 指定使用 proto3,如不指定则默认为 proto2

message Message { // 2. 定义数据结构 Message,用关键字 message 表示
  int32 id = 1; // 3. 定义 id,类型为 int32. 其它 Integer 的定义类似。并定义了字段号,字段号用于编码,message 的最小字段号为 1。该字段的字段号为 1
  string str = 3; // 4. 定义 str,类型为 string
  repeated int64 arrays = 4; // 5. arrays,长度 >= 0

  enum Answer { // 6. 定义 enum 类
    YES = 0; // 7. enum 第一个元素的字段号必须为 0,做为 enum 属性的默认值
    NO = 1;
  }
  Answer answer = 16; // 8. 定义 answer,类型为 Answer,默认值为 Answer.Yes

  message InnerMessage {  // 9. message 可以嵌套,定义一个内部的消息
    float test_float = 1; // 10. 定义 test_float. 内部 message 属于新的 message。字段号可重新开始
    map<string, string> map = 2; // 11. 定义 map
  }
  InnerMessage inner_message = 300; // 12. 定义 inner_message,类型为 InnerMessage

  oneof test_oneof { // 13. 类似 C 语言 的 union,is_true 和 bytes 共享内存,同时只能设置其中之一
    bool is_true = 5; // 14. is_true, 类型是 bool。它还是 Message 的属性,字段号和 Message 共享,不可重复
    bytes bytes = 6;  // 15. bytes,类似 string。它还是 Message 的属性,字段号和 Message 共享,不可重复
  }

  int32 max_field_number = 536870911; // 16. 该属性使用 protobuf 支持的最大字段号
}

proto3 使用 .proto 文件定义数据结构,所有属性都是可选的。普通属性可包含 0 个或 1 个值,repeated 属性可包含 0 个、1 个或多个值,类似 Array。和其它语言最明显的区别是,Protobuf 的每个属性都需包含一个字段号 (定义中等号后面的值)。该字段号在同一个 message 内不可重复。message 最小的字段号是 1,最大是 2^29-1(536870911),protobuf 预留了 19000 ~ 19999 供内部使用。

在此强烈建议大家通读 Language Guide (proto3) 学习,不要像我一样投机取巧,此乃做学问之大忌。

你真优秀能坚持读到这里!接下来让我们聊一聊本文的核心部分,Protocol Buffers 的编码和解码。

Protocol Buffers 编码

先直观感受一下当代流行格式的编码结果。假如存在数据结构 Author,如何来编码呢?

Author.new(name="zddhub", age=18, blog="https://zddhub.com", income=18.125)

JSON 的做法:

{
  "author": {
    "name": "zddhub",
    "age": 18,
    "blog": "https://zddhub.com",
    "income": 18.125
  }
}

XML 的做法:

<?xml version="1.0" encoding="UTF-8"?>
<author>
  <name>zddhub</name>
  <age>18</age>
  <blog>https://zddhub.com</blog>
  <income>18.125</income>
</author>

YAML 的做法:

author:
  name: zddhub
  age: 18
  blog: https://zddhub.com
  income: 18.125

都使用文本文件存储数据。除去标记符,大家清一色选用键值对 key/value 来编码。而 protobuf 编码结果是这样:

000010100000011001111010011001000110010001101000011101010110001000010000000100100001101000010010011010000111010001110100011100000111001100111010001011110010111101111010011001000110010001101000011101010110001000101110011000110110111101101101001000010000000000000000000000000000000000000000001000000011001001000000

一串二进制。可以使用二进制编辑器查看内容。假如上述二进制文件存放在 output 文件中,可以使用 xxd 命令 xxd -b output 打开:

00000000: 00001010 00000110 01111010 01100100 01100100 01101000  ..zddh
00000006: 01110101 01100010 00010000 00010010 00011010 00010010  ub....
0000000c: 01101000 01110100 01110100 01110000 01110011 00111010  https:
00000012: 00101111 00101111 01111010 01100100 01100100 01101000  //zddh
00000018: 01110101 01100010 00101110 01100011 01101111 01101101  ub.com
0000001e: 00100001 00000000 00000000 00000000 00000000 00000000  !.....
00000024: 00100000 00110010 01000000                              2@

也可以用十六进制打开, xxd output

00000000: 0a06 7a64 6468 7562 1012 1a12 6874 7470  ..zddhub....http
00000010: 733a 2f2f 7a64 6468 7562 2e63 6f6d 2100  s://zddhub.com!.
00000020: 0000 0000 2032 40                        .... 2@

从该文件中可以了解到:Protobuf 用了 39 个字节来表示 Author 的信息。名字和博客的值是直接用 ASCII 码来存储的,不能一眼看出该作者的年龄和收入。所有 key 值不可见。

那么 Protobuf 到底是如何来编码的呢?

Base 128 Varints

Protobuf 使用了一种叫做 Base 128 Varints 的编码方法,直译一下就是 128 进制变长整型编码方法。注意,这里的 128 进制不是逢 128 进 1,而是每个字节逢 128 进 1 个字节。该方法使用一个到多个字节序列化整型数据,整型数值越小,所占用的字节数越少。每个字节的最高位 (most significant bit - msb) 做为标记位,最高位为 0 表示当前字节是编码的最后一个字节,为 1 时表示还存在后续字节。编码从低位字节开始往高位字节编码,直到标记位为 0 后结束。采用小端序(Little Endian)存储,低地址端存放低位字节,所以标记位为 0 的字节总存放在最后 (高地址端)。所以说 Base 128 Varints 是一种自带分隔符的编码方式。除过标记位,剩余 7bit 存放真实数据,每个字节逢 128 (2^7) 进 1。小于 128 的非负数只需一个字节,128 (2^7) 用两个字节表示,16384 (2^14) 用三个字节表示,……。

Base 128 Varints

举个例子,年龄 18,小于 128,只需要一个字节,最高位为 0。编码结果为:

00010010

再比如 320,需要两个字节,低字节放在低地址端:

11000000 00000010

Varints 320

Protocol Buffers 编码

上面例子中说到 Author 的 protobuf 编码中所有 key 值,比如 name, age, blog 和 income 都不可见,是因为 protobuf 根本没有把这些值编码,而是使用了字段号。编码解码时都需要参考 .proto 文件。

这是 Author 的定义:

message Author {
  string name = 1;
  int32 age = 2;
  string blog = 3;
  double income = 4;
}

实际上 protobuf 省去了消息名和属性名,只编码了以下内容,暂时使用三元组表示 (Field Number, Type, Value):

(1, string, zddhub)
(2, int32, 18)
(3, string, https://zddhub.com)
(4, double, 18.125)

如果把 字段号+类型 看做 key 的话,那么它也是一种 key/value 结构。通常说的 key 是指 name, age, blog 或者 income,为了区分,下文把 字段号+类型 称作 tag。

Protobuf 对 Type 也做了分类和编码,用 3bit 表示,进一步压缩了编码空间。如下表所示:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float
6 Reserved  
7 Reserved  

Protobuf 每个字段结构如下:

Protobuf Tag Value

如果用一个字节表示 tag 的话,实际上只有 4bit 存储字段号,所以一个字节可以表示的字段号从 1 到 15 (2^4 -1),字段号一般不包括 0 (enum 除外)。从 16 到 2047 (2^11 -1) 需要两个字节。Protobuf 规定最大字段号为 536870911 (2^29-1) 需要 5 个字节 (最高四位不用)。使用较小的字段号能节省存储空间。

Field Number

如果知道了字段号,对应到 .proto 文件,能很容易的知道字段名和字段类型,为什么还要把 Type 编码呢?其实这里的 Type 并不能区分当前字段是 int32 还是 int64。Type 更重要的作用是标记了 value 该以何种方式编码。

根据 Type,去掉被 proto3 废弃的两个类型。我们可以把 protobuf 的编码方式分为三类:

  • Tag + Varints (Type = 0)
  • Tag + Value (Type = 1 或者 Type = 5)
  • Tag + Length + Value (Type = 2)
Tag + Varints (Type = 0)

让我们从年龄开始编码,根据 Author 的定义,年龄的字段号是 2,类型为 int32 (Type = 0), 采用 Tag + Varints 的编码方式。把 tag 和 年龄 18 都按 Varints 编码后既得编码结果:

00010000 00010010

Age Encoding

Tag + Value (Type = 1 或者 Type = 5)

根据 Author 的定义,收入的字段号是 4,类型是 double (Type = 1),采用 Tag + Value 的编码方式。Value 直接采用 double 双精度浮点数的编码方式 IEEE 754。Tag 和收入值 18.125 编码后的结果为:

2100 0000 0000 2032 40

共 9 个字节,其中 tag 一个字节, tag 编码为 00100001 (0x21),double 8 个字节 (0x0000 0000 0020 3240)。

这里有必要再重温一下浮点数的编码方法:

根据 IEEE 754 标准,浮点数在计算机中用三部分来表示,符号位 (Sign) + 指数部分 (Exponent) + 尾数部分 (Mantissa)。其中的指数部分(E)采用偏置码(biased)的形式来表示正负指数,若 E<127 (单精度) 或者 E<1023 (双精度) 则为负的指数,否则为非负的指数。尾数部分 (M) 存储的是当把一个浮点数规范化表示后的 1.zozooz…(二进制的)形式中小数点后面的部分 zozooz…。

其中,

  • 单精度浮点数 float 采用 4 个字节 32bit 来表示。其中,符号位 1bit,指数部分 8bit,尾数部分 23bit。
  • 双精度浮点数 double 采用 8 个字节 64bit 来表示。其中,符号位 1bit,指数部分 11bit,尾数部分 52bit。

尾数的位数越多,精度越高。

Floating-Point Arithmetic

现在我们来求双精度浮点数 18.125 在计算机中的表示:

  1. 先把整数部分用二进制表示,18 的二进制是 10010
  2. 再把小数部分用二进制表示,0.125 的二进制是 001

    0.125 * 2 = 0.25, 取 0
    0.25 * 2 = 0.5    取 0
    0.5 * 2 = 1       取 1
    结束
    

    注意,表示小数的二进制数高位的 0 不能省略,表示整数的二进制数低位的 0 不能省略。

  3. 转成科学计数法,并规范化 (10010.001) * 2^0 = 1.0010001 * 2^4
  4. 确定 S,E 和 M 的值。非负数的 S = 0,指数为 4,基数为 1023,所以指数部分 E = 1023 + 4 = 1027, 二进制 E = 100 0000 0011, 尾数 M = 0010001
  5. 按顺序写出 18.125 的二进制表示,尾数末尾用 0 补齐。01000000 00110010 00100000 00000000 00000000 00000000 00000000 00000000。 十六进制为 4032 2000 0000 0000

这里有个在线的二进制转换工具,大家无聊了可以玩玩。

Protobuf 存储浮点数是采用小端序(Little Endian),低地址端存放低位字节,所以其存储为 0000 0000 0020 3240。加上 tag 0x21。终得 income 的编码结果:

2100 0000 0000 2032 40
Tag + Length + Value (Type = 2)

根据 Author 的定义,名字的字段号是 1,类型是 string (Type = 2),采用 Tag + Length + Value 的编码方式。Length 为该字符串所占用的字节数。字符串直接采用 ASCII 码存储,每个字符一个字节。字节的长度等于字符串的长度,名字 zddhub 的长度是 6。编码结果为:

0a06 7a64 6468 7562

Name Encoding

值得注意的是,Length 也用 Varints 来编码。同样的做法,我们可以编码 blog,编码结果为:

1a12 6874 7470 733a 2f2f 7a64 6468 7562 2e63 6f6d

注:这种方式在编码时需要提前算出内容所需的字节数(Length),然后才能进行编码。在 proto2 时代,提供了另外两个类型 Type = 3 (Start group) 和 Type = 4 (End group)。在内容前后插入标记位,防止计算 Length。你可能会觉得这样会提高效率,但谷歌的结论正好相反。例如,想要忽略某字段时,只能从头解析直到 End group,而不能简单的跳过 n 个字节。Type = 3 (Start group) 和 Type = 4 (End group) 在 proto3 中被弃用。

至此,我们使用了 protobuf 提供的三种编码方式。编码了 Author 的四个字段,连起来即为本文开头给出的 Author 的编码结果。

编码顺序和默认值

这里的编码顺序是很有意思的。每个 tag/value 对有严格的顺序要求,但 Author 的四个字段之间可以随意组合,共有 4!种组合方式。而且中间可以有任意多个未知字段,只要未知字段同样按照 protobuf 编码。这也是 protobuf 兼容新老版本,具有可扩展性的奥秘。

Author Combination

如上图所示,使用了和文章开头不同的编码顺序,但都是正确的编码。第二排多了一个 Field Name 是 5,Type 也是 5 的字段,有可能是 float,fixed32 或者 sfixed32。但当前版本 Author 的 protobuf 里没有该字段的定义,所以具体是那一个谁也不知道,解码时被当作未知字段扔掉。

值得注意的是第二排的编码中缺少了 blog。proto3 中每个字段都是可选的。所以它仍然可以被 protoc 生成的代码来解析,并且对于缺省的字段,会自动使用默认值。下面是不同字段的默认值:

Fields Default Value
strings empty string
bytes empty bytes
bools false
number zero
enum first enum value, field number must be 0
message not set, Its exact value is language-dependent
repeated empty list

特别的,如果当前字段的值恰好等于默认值,编码时可忽略该字段,解码程序自动使用默认值,当然也可以进行编码。

Protocol Buffers 解码

知道了编码规则,解码就容易了,这里尝试解码一个例子。已知某 protobuf 编码结果如下:

00000000: 00001000 00011011 00011010 00001011 01001000 01100101  ....He
00000006: 01101100 01101100 01101111 00100000 01010111 01101111  llo Wo
0000000c: 01110010 01101100 01100100 00100010 00000010 00000001  rld"..
00000012: 00000010 00101000 00000000 10000000 00000001 00000001  .(....
00000018: 11100010 00010010 00000101 00001101 00001010 11010111  ......
0000001e: 00110011 01000001 11111000 11111111 11111111 11111111  3A....
00000024: 00001111 00010010

首先,Protobuf 编码按照 tag/value 的模式排列组合而成,第一个字节永远是 tag。

  • 第 1 个字节 00001000, 为 tag,最高位是 0,说明该 tag 仅用一个字节表示。字段码是 1,类型是 Varint,说明该字段的值是这里面的一种 int32, int64, uint32, uint64, sint32, sint64, bool, enum。虽然不知道是哪一种,但是我们知道,该字段的值使用 Varint 编码。那么继续解码获得该字段的值。
  • 第 2 个字节 00011011, 最高位是 0,Varint 的编码结束。得到第一个 tag/value 对,使用一个三元组来表示 (1,Varint,0x1B)。这里使用 16 进制来表示内容,而没有使用 27。因为没有任何信息能说明它是整型,布尔还是枚举型。
  • 第 3 个字节 00011010, 上一个 tag/value 结束,那么接下来这个字节就是 tag。字段码是 3,类型是 Length-delimited。该类型使用 Tag + Length + Value (Type = 2) 的方式编码。接下来的字节就表示长度。
  • 第 4 个字节 00001011, 根据第 3 个字节推断,从这个字节开始使用 Varint 编码表示长度。高位为 0,该字节仅用一个字节表示长度,长度为 11。说明后面用 11 个字节表示该字段的值。顺序读取 11 个字节。
  • 第 5 ~ 15 个字节 01001000 01100101 01101100 01101100 01101111 00100000 01010111 01101111 01110010 01101100 01100100, 这是字段码为 3 的字段值。这 11 个字符正好是 Hello World 的 ASCII 码,我们可以大胆猜测该字段是 string (3, Length-delimited, Hello World),但是没有任何证据,所以暂时表示为 (3, Length-delimited, 0x4865 6c6c 6f20 576f 726c 64)
  • 第 16 个字节 00100010,字段码是 4,类型是 Length-delimited。那么
  • 第 17 个字节 00000010, 为长度,长度为 2。
  • 第 18 ~ 19 个字节 00000001 00000010 为该字段的内容,(4, Length-delimited, 0x01 02)
  • 第 20 个字节 00101000, 字节码是 5,类型是 Varint。
  • 第 21 个字节 00000000, 该字段值为 0。(5, Varint, 0x0),下一个字节为 tag。
  • 第 22 个字节 10000000, 该字节表示 tag,但是最高位为 1,说明该 tag 超过一个字节,继续读下一个
  • 第 23 个字节 00000001, 此时最高位为 0,tag 结束,该 tag 用两个字节表示 00000001 10000000,注意这里的顺序。去掉两个字节最高位的标记位 0000001 0000000 得实际的 tag 值, 字段码为 16 (00000010000), 该字段的类型为 Varint。
  • 第 24 个字节 00000001, 为该字段的值。(16, Varint 0x1), 下一个 tag。
  • 第 25 个字节 11100010, 该字节表示 tag,但是最高位也为 1,继续读
  • 第 26 个字节 00010010, 此时最高位为 0,tag 结束。该 tag 用两个字节表示 00010010 11100010,去掉标记位 0010010 1100010 得实际的 tag 值,字段码为 300 (00100101100),该字段的类型是 Length-delimited。
  • 第 27 个字节 00000101, 为长度。长度为 5。
  • 第 28 ~ 32 个字节 00001101 00001010 11010111 00110011 01000001, 读取 5 个字节解码出该字段为 (300, Length-delimited, 0x0d0a d733 41)
  • 第 33 个字节 11111000 为 tag 的开始,
  • 第 34 ~ 37 个字节 11111111 11111111 11111111 00001111 一直读到字节最高位为 0 结束,该 tag 使用 5 个字节表示,逆序写出 00001111 11111111 11111111 11111111 11111000,去掉每个字节的标记位 0 和 末尾的 Type,字段码全为 1,2^29 = 536870911。是被允许的最大字段号。类型是 Varint。
  • 第 38 个字节 00010010, 高位为 0,该字节即为最后一个字段的值。(536870911, Varint, 0x12)

至此,我们读完了所有内容。让我们看看解码后的内容:

(1,Varint,0x1B)
(3, Length-delimited, 0x4865 6c6c 6f20 576f 726c 64) # Hello World
(4, Length-delimited, 0x01 02)
(5, Varint, 0x0)
(16, Varint 0x1)
(300, Length-delimited, 0x0d0a d733 41)
(536,870,911, Varint, 0x12)

我们解码出了 7 个 tag/value 对,知道每个字段的字段码和值。由于不知道其类型,并没能完全解码。这个时候就需要参照 .proto 的定义了。我们参考 .proto 的定义文件,程序解码时使用 .desc 文件,(.desc 是使用 protoc 生成的 protobuf 描述文件,二进制存储,程序解码时使用)。

聪明如你一定猜到了,刚才解码内容的数据结构为本文开头给出的精简版 Message,如下所示。

message Message {
  int32 id = 1;
  string str = 3;
  repeated int64 arrays = 4;
  enum Answer {
    YES = 0;
    NO = 1;
  }
  Answer answer = 16;
  message InnerMessage {
    float test_float = 1;
    map<string, string> map = 2;
  }
  InnerMessage inner_message = 300;
  oneof test_oneof {
    bool is_true = 5;
    bytes bytes = 6;
  }
  int32 max_field_number = 536870911;
}

参考该定义文件,进一步解码:

Message {
  int32 id = 27 // (1,Varint,0x1B)
  string str = "Hello World" // (3, Length-delimited, 0x4865 6c6c 6f20 576f 726c 64)
  int64 arrays = [1, 2] // (4, Length-delimited, 0x01 02)
  bool is_true = false // (5, Varint, 0x0)
  Answer answer = Answer.No // (16, Varint 0x1)
  InnerMessage inner_message = 0x0d0a d733 41 // (300, Length-delimited, 0x0d0a d733 41)
  int32 max_field_number = 18 // (536,870,911, Varint, 0x12)
}

结合定义,我们解码了大部分数据,除了 InnerMessage。其内容为 0x0d0a d733 41,二进制如下:

00001101 00001010 11010111 00110011 01000001
  • 第 1 个字节 00001101 仍然表示 tag,其中字段号为 1,类型为 5,采用 Tag + Value (Type = 5) 方式编码。其内容用四个字节表示。读取四个字节。
  • 第 2 ~ 5 个字节 00001010 11010111 00110011 01000001 表示该字段的值。该字段为 (1, 32-bit, 0x0ad7 3341)

查定义得该字段为 float,单精度浮点类型,只要翻译出其值即可。根据解码部分可知: Protobuf 存储浮点数是采用小端序(Little Endian),低地址端存放低位字节,所以存储值为 00001010 11010111 00110011 01000001 的浮点数实际值为:

01000001 00110011 11010111 00001010

单精度浮点类型首位为符号位 S = 0,紧接着 8bit 为指数部分 E = 130 (10000010),剩余 23bit 为尾数 (M = 0110011 11010111 00001010)。那么该浮点数的值为:

Value = (-1)^0 * (1.01100111101011100001010) * 2^(130-127) = 1011.00111101011100001010

其中整数部分为 1011 = 11,小数部分为:00111101011100001010。小数部分的二进制如何转十进制呢?编码部分把 0.125 转成二进制时每次都乘以 2,这次做除法就好了。小数部分依次除以 2^1, 2^2, 2^3, 2^4, …, 即可。那么,

小数部分 = 1/2^3 + 1/2^4 + 1/2^5 + 1/2^6 +  1/2^8 + 1/2^10 + 1/2^11 + 1/2^12 + 1/2^17 + 1/2^19 = 0.2399997711181640625 ~= 0.24

得该浮点数的值为 11.24。

至此,我们解析出了所有字段:

Message {
  int32 id = 27 // (1,Varint,0x1B)
  string str = "Hello World" // (3, Length-delimited, 0x4865 6c6c 6f20 576f 726c 64)
  int64 arrays = [1, 2] // (4, Length-delimited, 0x01 02)
  bool is_true = false // (5, Varint, 0x0)
  Answer answer = Answer.No // (16, Varint 0x1)
  InnerMessage inner_message { // (300, Length-delimited, 0x0d0a d733 41)
    float test_float = 11.24 // (1, 32-bit, 0x0ad7 3341)
  }
  int32 max_field_number = 18 // (536,870,911, Varint, 0x12)
}

由此可知,想要正确的理解数据,需要 protobuf 定义。如果使用错误的定义,可能会导致解码错误,或者不能解码。如果碰巧能解码,但是不知道某个字段的 key,也很难理解该字段值所表示的含义。所以这种编码方式有一定的加密作用,可用在密级不高的加密场景。如果我们把 key 值也编码在二进制文件里会有什么影响呢?其实也不是不可以,BJSON 就是一种二进制的 JSON 实现。Protobuf 这种依赖字段码的编码方式,加上编译生成不同语言版本的做法,使得存储 key 值没有必要。

省略 key 值,存储字段码,加上紧凑的编码方式,除了压缩存储空间,还有一大优点就是编码解码速度快。C++ 版的 protobuf 编码解码速度可高达 7.96091GB/s, 参见 Protobuf Performance。实际解码并不像人工这样,在生成的解码文件里,预先根据 tag 定好了类型,例如 Java 版本的一个解析文件代码片段如下,大家可以感受一下:

boolean done = false;
while (!done) {
  int tag = input.readTag();
  switch (tag) {
    case 0:
      done = true;
      break;
    case 8: {
      id_ = input.readInt32();
      break;
    }
    case 26: {
      String s = input.readStringRequireUtf8();
      str_ = s;
      break;
    }
    case 32: {
      if (!((mutable_bitField0_ & 0x00000001) != 0)) {
        arrays_ = newLongList();
        mutable_bitField0_ |= 0x00000001;
      }
      arrays_.addLong(input.readInt64());
      break;
    }
    case 2402: {
      InnerMessage.Builder subBuilder = null;
      if (innerMessage_ != null) {
        subBuilder = innerMessage_.toBuilder();
      }
      innerMessage_ = input.readMessage(InnerMessage.parser(), extensionRegistry);
      if (subBuilder != null) {
        subBuilder.mergeFrom(innerMessage_);
        innerMessage_ = subBuilder.buildPartial();
      }

      break;
    }
    ...
  }
}

根据 tag 值,结合 protobuf 的定义,预先选择好了需要解析的数据类型。例如 tag = 8 时,直接调用 readInt32 读取一个 int32。在 tag 信息里面,字段号是唯一的。程序会根据 tag 值,使用不同的方法来读取内容,这就是为什么 protobuf 不允许字段号重复。看个例子,假设做了如下改动,将原来的 string 改成 Message,其它不变:

- string msg = 1;
+ Message msg = 1;

string 和 Message 的 Type 都等于 2,字段号复用后,tag 不变。假设原来 string 的编码为 (tag = 0x0a, length = 2, value = 0x6464),修改之后的程序在解码之前的编码时,tag 和长度都能正确解析。但是解析内容时,会把原本 ASCII 码 0x6464 当成 Message 来解码。那么第一个字节 0x64 会被当作 tag 而不是 ASCII 码。这样就打乱了原来的编码规则,导致解码无法进行下去。因此 Protobuf 提供了 reserved 关键字,当删除某字段后,使用该关键字保留原来的字段码和 key 值,防止后来人重复使用,造成程序崩溃。

写在最后

Protobuf 由于有 Google 背书,在出现后随着 Google 的使用(如在 gRPC中)快速流行开来。在移动端崛起的时代,尤其备受青睐。而谷歌也在默默地为移动端开发新的 protobuf 版本,proto4,一如既往,高傲的没有 roadmap,没有文档,让外人无法参与。

每种技术都有它适用的场景,请大家理解后根据使用场景理性选用,切勿盲目跟风。

练习

网上得来终觉浅,绝知此事要躬行。我给大家精心准备了一段 proto3 编码,请拿起纸和笔练习一下,解码后有惊喜哦。

二进制版:

00000000: 00001000 10110111 01001010 00100010 00011110 01100001  ..J".a
00000006: 01100010 01100011 01100100 01100101 01100110 01100111  bcdefg
0000000c: 01101000 01101001 01101010 01101011 01101100 01101101  hijklm
00000012: 01101110 01101111 01110000 01110001 01110010 01110011  nopqrs
00000018: 01110100 01110101 01110110 01110111 01111000 01111001  tuvwxy
0000001e: 01111010 00101100 00100001 00111111 00100000 10000010  z,!? .
00000024: 00001000 00101011 01010010 00000100 00000101 00000000  .+R...
0000002a: 00001010 00000100 11000010 00000001 00100010 00011000  ....".
00000030: 00001110 00010100 00011101 00000000 00010001 00000100  ......
00000036: 00011101 00000000 00010110 00000100 00010010 00001110  ......
0000003c: 00001100 00000100 00011011 00011101 00010110 00000100  ......
00000042: 00000010 00000111 00000000 00010011 00011101 00001100  ......
00000048: 00000100 00011100 00011101 00011001 00000011 00000011  ......
0000004e: 00000111 00010100 00000001 10000001 10000000 00000001  ......
00000054: 00000000 00000000 00000000 00000000 00000000 10000000  ......
0000005a: 00100100 01000000                                      $@

十六进制版:

00000000: 08b7 4a22 1e61 6263 6465 6667 6869 6a6b  ..J".abcdefghijk
00000010: 6c6d 6e6f 7071 7273 7475 7677 7879 7a2c  lmnopqrstuvwxyz,
00000020: 213f 2082 082b 5204 0500 0a04 c201 2218  !? ..+R.......".
00000030: 0e14 1d00 1104 1d00 1604 120e 0c04 1b1d  ................
00000040: 1604 0207 0013 1d0c 041c 1d19 0303 0714  ................
00000050: 0181 8001 0000 0000 0080 2440            ..........$@

由于字符串直接用 ASCII 码存储,如果直接放在 string 里面,直接查看二进制就解密了,毫无惊喜可言。我是做了一些加密的。定义的数据结构如下:

message Award {
  int64 id = 1;
  string code_book = 4;

  message Bonus {
    repeated int32 indexes = 24;
  }

  Bonus bonus = 128;
  double magic = 2048;
}

其中 code_book 存放密码本,像你在二进制里直接看到的一样,含有 26 个字母和一些特殊符号。而把实际内容放在了 indexes 里面,它是一个索引数组,每一项都是某个字母的下标(从 0 开始)。通过下标对应到密码本上,就是隐藏在其中的秘密。假设得到的 indexes = [7, 4, 11, 11, 14, 29, 22, 14, 17, 3, 27],对应的信息为 hello word!。就是这么简单!

你不来试试吗?

参考资料

如果你喜欢这篇文章,欢迎赞赏作者以示鼓励