FlatBuffers

FlatBuffers 是 google 推出的另一种具有 schema 的序列化格式, 面向移动端。

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

    ...

解释流程:

../_images/flatbuffers-example.png

流程如下

  • 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 高很多。 所以这种策略相当于加重服务端复杂度来减轻客户端负担。