FlatBuffers¶
FlatBuffers 是 google 推出的另一种具有 schema 的序列化格式, 面向移动端。
- github: https://github.com/google/flatbuffers
- 编码格式: https://github.com/dvidelabs/flatcc/blob/master/doc/binary-format.md
Flatbuffers 和 Protobuf¶
Flatbuffers 和 Protobuf 有很多共同点, 比如都需要先编写一个协议描述文件,通过它生成各种语言的代码才能使用。 也都是二进制编码,但是 Flatbuffers 的解码性能更好。更适合一次存储、多次读取,或者 client 解码性能很差的情况。
编码格式¶
FlatBuffers 编码方式应该是本文涉及的所有序列化方法中最复杂的。 但是它的出发点很简单: 设计一种不需要解码的数据格式,以适应低性能的移动端。
设计思路¶
在 C/C++ 的序列化 章节,提到过 C 的序列化可以直接把内存拷贝出来存储为二进制。 这种编码方式优点很明显: 完全不需要编码和解码,效率极高。
Flatbuffers 也是按照这种思路来设计的, 只不过事实上无法做到这么简单粗暴。
考虑下面存在指针的例子。
struct User {
char * name;
int age;
};
name 字段是一个指针, 只存放数据地址,真正的数据需要根据指针所指向的地址去访问。 32位环境下,指针占用 4 字节,int 占用 4 字节,User 结构体总共固定占用 8 字节。 对这种结构体,如果只是粗暴地把这8字节拷贝出去,而不管 name 真实引用的内容,会导致序列化不完整。
第一种解决方案是采用 length+data
的方法。这也是大部分序列化库选择的方案,
在前面加一个长度字段,表示后面 N 个字节就是 name 的内容。
{"name": "Washington", "age": 10}
按照这种方法序列化的内容:
0x0a W a s h i n g t o n 0x00 0x00 0x00 0x0a
这种数据结构的劣势在于不能随机访问,如果定义了一个数组 struct User users[10]
,
原本每个对象占用8字节,想访问下标为 m 的对象只需要固定偏移 m*8 字节就可以,
而这种方式导致每个对象的长度不一样,不能随机访问。
这与 Flatbuffers 复制到内存即可用 的目标是违背的。
第二种解决方案是模仿内存空间的分配策略。即模拟指针的行为。 一般的方法是为指针内容开辟另外的副数据段,将指针指向的内容存放在副数据段中, 并记录下其在副数据段中的地址,序列化到 User 中。如下:
主数据段:
0x0000: 00 00 01 00 ; name 的地址 -> Washington
0x0004: 00 00 00 0a ; age = 10
0x0008: 00 00 01 0c ; name 的地址 -> Lincoln
0x000c: 00 00 00 0b ; age = 11
副数据段:
0x0100: W a s h ;
0x0104: i n g t ;
0x0108: o n 00 00 ;
0x010c: L i n c ;
0x0110: o l n 00 ;
重要
再次提醒,这种方式需要客户端程序知道如何解释这个数据。
通过这种方式就可以实现解码 0 复杂度。
而 FlatBuffers 针对方案2还有以下改进。
相对定位
上面的方案中,第二个 User 结构体 name 内容是 00 00 01 0c , 使用时根据它找到 0x010c 地址就能获取到内容。
不过整个序列化内容所处的内存片段不可能起始地址是 0x0000 , 可能是任意值, 这里假设是 base ,真实的数据地址就需要通过 base + 0x010c 运算得到。 所以反序列化过程中,需要保留全局 base 地址。
而 flatbuffers 将绝对定位修改为相对当前位置偏移,不必保留base地址。 所以 name 字段存放的地址会变成 数据地址(0x010c) - 当前地址(0x0004)=0x0108 。
注意: 思路是这样,真实协议会兼顾到底层编码、历史原因等, 会有差异。
vtable
Flatbuffers 数据不是简单地分为两段。除了数据头部的元信息, 还有vtable, 总共四段。 vtable 的存在使数据序列化字段的存储可以乱序。 可以理解为 vtable 是数据格式, table 是上述主数据段,多个 table 数据段可以共享一个 vtable 格式。
另外, vtable 也是实现 flatbuffers schema-less 的关键。
重要
以上纯属个人对 Flatbuffers 的理解,官方并没有提及。如有出入,欢迎修正。
数据说明¶
下面通过官方案例讲解真实的 flatbuffers 数据编码。
Schema
namespace Eclectic;
enum Fruit : byte { Banana = -1, Orange = 42 }
table FooBar {
meal : Fruit = Banana;
density : long (deprecated);
say : string;
height : short;
}
file_identifier "NOOB";
root_type FooBar;
待编码的 json 内容
{ "meal": "Orange", "say": "hello", "height": -8000 }
编码结果
header:
+0x0000 00 01 00 00 ; find root table at offset +0x0000100.
+0x0004 'N', 'O', 'O', 'B' ; possibly our file identifier
...
table:
+0x0100 e0 ff ff ff ; 32-bit soffset to vtable location
; two's complement: 2^32 - 0xffffffe0 = -0x20
; effective address: +0x0100 - (-0x20) = +0x0120
+0x0104 00 01 00 00 ; 32-bit uoffset string field (FooBar.say)
; find string +0x100 = 256 bytes _from_ here
; = +0x0104 + 0x100 = +0x0204.
+0x0108 42d ; 8-bit (FooBar.meal)
+0x0109 0 ; 8-bit padding
+0x010a -8000d ; 16-bit (FooBar.height)
+0x010c ... ; (first byte after table end)
...
vtable:
+0x0120 0c 00 ; vtable length = 12 bytes
+0x0122 0c 00 ; table length = 12 bytes
+0x0124 08 00 ; field id 0: +0x08 (meal)
+0x0126 00 00 ; field id 1: <missing> (density)
+0x0128 04 00 ; field id 2: +0004 (say)
+0x012a 0a 00 ; field id 3: +0x0a (height)
...
string:
+0x0204 05 00 00 00 ; vector element count (5 ubyte elements)
+0x0208 'h' 'e' ; vector data
+0x020a 'l' 'l' ; vector data
+0x020c 'o' ; vector data
+0x020d 00 ; zero termination
; special case for string vectors
...
解释流程:
流程如下
header 首字节存放第一个 table 的地址偏移量。
table 首字节存放自己引用的 vtable 的地址偏移量。
3、4、5 步骤分别是 schema 中 0、1、2、3 字段在 table 中的偏移量,以便取数。
可以看到 vtable 中字段顺序与 schema 是严格对应的,而 table中的字段与 vtable 中的顺序可以不一样。
vtable中只是说了数据起始地址,真正的数据需要按照 schema 来解释。
比如 meal 字段是 enum, 底层存储固定为 4 字节 int, 则从起始地址 0x0108 取 4 字节数据。
say 字段为 string, 其值 0x00010000 应该解释为地址偏移量, 则找到相应地址对应的内容为 “hello”
FlexBuffers¶
官方有 FlatBuffers 的 schema less 版本: FlexBuffers
总结¶
由于数据结构比较复杂,并不能直观地看出 Flatbuffers 的编码复杂度, 目测 FlatBuffers 的相对定位策略对编码性能影响还是比较大的, 网传各种压测结果也能佐证其编码复杂度相比 Protobuf 高很多。 所以这种策略相当于加重服务端复杂度来减轻客户端负担。