Flatbuffers原理及使用详解


1.FlatBuffers原理

FlatBuffers是一个开源的、跨平台的、高效的、提供了C++/Java接口的序列化工具库。它是Google专门为游戏开发或其他性能敏感的应用程序需求而创建。尤其更适用于移动平台,这些平台上内存大小及带宽相比桌面系统都是受限的,而应用程序比如游戏又有更高的性能要求。它将序列化数据存储在缓存中,这些数据既可以存储在文件中,又可以通过网络原样传输,而不需要任何解析开销。

1.1.特点

  1. 对序列化数据的访问不需要打包和拆包——它将序列化数据存储在缓存中,这些数据既可以存储在文件中,又可以通过网络原样传输,而没有任何解析开销;
  2. 内存效率和速度——访问数据时的唯一内存需求就是缓冲区,不需要额外的内存分配。 这里可查看详细的 基准测试;
  3. 扩展性、灵活性——它支持的可选字段意味着不仅能获得很好的前向/后向兼容性(对于长生命周期的游戏来说尤其重要,因为不需要每个新版本都更新所有数据);
  4. 最小代码依赖——仅仅需要自动生成的少量代码和一个单一的头文件依赖,很容易集成到现有系统中。再次,看基准部分细节;
  5. 强类型设计——尽可能使错误出现在编译期,而不是等到运行期才手动检查和修正;
  6. 使用简单——生成的C++代码提供了简单的访问和构造接口;而且如果需要,通过一个可选功能可以用来在运行时高效解析Schema和类JSON格式的文本;
  7. 跨平台——支持C++11、Java,而不需要任何依赖库;在最新的gcc、clang、vs2010等编译器上工作良好;

1.2.存储结构

FlatBuffers其实就是一个保存了一系列标量和矢量的缓冲区。这个缓冲区中的标量和矢量可以被直接访问。缓冲区的数据一旦构造成功,里面的矢量数据一般不能变更,除非矢量的长度不大于构造时的长度,且矢量保存的不是偏移量,否则会产生错误。

参考FaceBook的文档,假设我们有一个Person类定义如下:

假设有一个叫John的人,那么此Person对象在FlatBuffer中物理存储结构简化图如下,

 

1、每一个存储在FlatBuffer中的对象,被分割成2部分:中心点(Pivot Point)左边的元数据(vtable)和中心点右边的真实数据。
2、每个字段都对应vtable中的一个槽(slot),它存储了那个字段的真实数据的偏移量。例如,John的vtable的第一个槽的值是1,表明John的名字被存储在John的中心点右边的一个字节的位置上。
3、如果一个字段是一个对象,那么它在vtable中的偏移量会指向子对象的中心点。比如,John的vtable中第三个槽指向Mary的中心点。
4、要表明某个字段现在没有数据,可以在vtable对应的槽中使用偏移量0来标注。

1.3.数据结构及Schema语言简介

Schema语言(即IDL)的语法与C语言家族、AIDL很类似。

内建标量:

内建非标量类型:
1.Vector ,其他数据类型的Vector (矢量),不支持内嵌。
2.string,只能存储UTF-8或者7-bit ASCII。如果需要存储其他编码的文本,或者通用二进制数据,请使用vector([byte]或者[ubyte])。
3.References,对其他table、struct、enum或者union的引用。
更多关于编写Schema的信息,请参考Writing Schema。

1.3.1.标量

所有的整形变量(8位~64位)和浮点变量均为标量。标量的特点是长度固定,字节序列为LittleEndian,这和大部分CPU的一样,以加快访问速度。

FlatBuffers中的偏移量也是标量,但是在构造后不能变更。

Struct结构也可以当做是标量来看待。

1.3.2.矢量

字符串和数组是矢量。字符串是以'\0'结尾。矢量的开头必须是一个32位的长度,用来指明矢量的长度,这个长度不包括'\0'和长度本身所占的空间。

如上图所示,STRING和VECTOR都是矢量,唯一的区别是STRING包含一个'\0'结束符合。VECTOR SIZE保存的是VECTOR ELEMENTS的长度,单位是字节。

如果VECTOR ELEMENTS是标量或者STRUCT,那么其中保存的内容就是其数组中的内容;如果是TABLE,那么保存的就是一个偏移量数组,这些偏移量为32位,指向TABLE OBJECT。

1.3.3.Table

Tables是在FlatBuffers中定义对象的主要方式,如下图所示,每一个字段都是可选的,可配置默认值(如果忽略的话,默认是0/NULL)。

Table类型可以随意增加和删除成员,是一个很灵活的类型。当要删除一个成员时,你把它指定为deprecated即可(这个成员必须保存在成员列表内,不能删除)。

如上图所示,一个VTABLE可以为多个TABLE OBJECT提供描述信息,每个TABLE OBJECT必须包含一个32位的TABLE OFFSET,指定哪个VTABLE描述它的字段信息。

每个VTABLE都有一个16位的VTABLE SIZE,用来记录本身的大小(包括VTABLE SIZE);TABLE OBJECT SIZE用来记录TABLE OBJECT的大小(包括VTABLE OFFSET这4个字节);TABLE FIELD OFFSET用来记录TABLE INSTANCE内的字段相对TABLE OBJECT的偏移量,即TABLE INSTANCE的第一个字段偏移量必须为4(因为VTABLE OFFSET为32位,占了4个字节)。

每个TABLE INSTANCE保存一系列字段FIELD,这些FIELD可以是普通标量或者struct,也可以是32位的偏移量,指向其它TABLE或者矢量。

1.3.4.Structs

Struct类型的数据结构是不可以更改的结构。当结构定义好,结构的成员、位置和大小就会固定,不能变更,否则所有的程序都要重新编译和升级。Struct的优点是访问速度快,占用内存少。

Structs与Table类似,不同点如下:
1.无默认值,且字段不能增加或被废弃(deprecated);
2.Struct只能包含标量或者其他struct;
如果非常确定数据结构不会发生任何变化,那么就可以使用struct。Struct使用的内存比Table少,并且读取时比Table更快(它们通常被以in-line的方式存储在它们的父对象中,并且不适用virtual table)。
Union、Enum功能与C语言类似。

1.3.5.ROOT TYPE

FlatBuffers有一个特殊类型就是ROOT TYPE,是一个顶级类型。

如图所示,ROOT OFFSET是一个32位的偏移量,指向ROOT DATA;ROOT DATA为封包内容。FILE ID为可选内容,可以不设置,可以用作这个封包的ID,注意无'\0'结尾,可以在协议文件中使用file_identifier来指定。

ROOT DATA为整个封包的数据部分,然而ROOT OFFSET未必指向ROOT DATA的开头,它是指向顶层TABLE的TABLE OBJECT,而不是指向VTABLE。因此,即使不填FILE ID,ROOT DATA的值也可能不为0。

1.3.6.Union

联合类型是一个重要的类型,是封包分支的重要方法。

如图所示,每个union有两个部分组成,一个部分是TYPE,另外一个是TABLE偏移量。

因为TYPE只要8位,所以联合类型里面最多只能包含256个TABLE类型,否则会出问题。如果想支持大于256个类型,那么必须通过union+table+union这种方法来扩充。

1.3.7.版本兼容

FlatBuffers具备良好的向前/向后兼容性,需要遵守以下规则:
1.如果要在schema中添加新字段,只能在table的末尾进行添加
2.如果不再使用某些字段了,不能从schema中删除它们;可以选择不再把它们写入到你的数据中,或者你通过deprecated来声明该字段不再使用,不过这样会使编译器不在产生该字段的代码,故存在一定的代码破坏性。

假设服务器端更新了协议,新增了一个字段,而客户端并未更新,那么当客户端收到服务器的数据时,新增的字段对客户端而言等同于没有增加,客户端不会解析新增的字段,保证了向前兼容性。

如果服务器协议回滚到一个低版本,那么当客户端收到服务器的数据进行解析时,客户端相较于服务端的新增字段,将会采用默认值,保证了向后兼容性。

更多版本控制细节,请参考Schemas and version control。

2.FlatBuffer使用步骤

2.1.总览

1. 编写Schema文件
2. 使用FlatBuffers编译器生成java bean 文件,执行命令:

flatc --java samples/monster.fbs

3. 编译FlatBuffers的java库,下载flatbuffers源码,进入目录执行mvn package,选择Target目录的完整jar包(第一个)
4. 在Android工程中引入FlatBuffers的java库及编译器生成的java bean文件。
5. 使用FlatBufferBuilder构造一个ByteBuffer(序列化对象),保存或者发送该Buffer。
6. 读取该Buffer,通过getRootAsXXX可反序列化得到其代表的对象。

此外,关于FlatBuffers可读性差的问题,可通过执行命令:

flatc --json person.fbs --raw-binary -- flat.bin

直接将二进制文件转换为json格式的文件,解决可读性差的问题。

2.2.编译&安装

编译器下载地址

也可以在github上找到最新的release版本:https://github.com/google/flatbuffers/releases

wget https://github.com/google/flatbuffers/archive/v1.0.3.zip
unzip ./v1.0.3.zip
cd flatbuffers-1.0.3/
cmake -DCMAKE_INSTALL_PREFIX:PATH=/home/coder4/soft/flatbuffers -G "Unix Makefiles"
make && make install

编译完毕的库和include就在中了。

和protobuffer类似,我们自己开发时候并不需要链接其他lib,只要include和生成的代码就可以了。

最有用的是bin/flatc,这个是编译schema、生成代码的程序。

2.3.编写自定义Schema

只有schema确定,才能保证序列化、反序列化的高性能(因为会生成裸代码,比json等动态执行的要高效很多)

我们构造一个如下的Schema文件 test.fb,

namespace TestApp;

struct KV {
 key: ulong;
 value: double;
}

table TestObj {
 id:ulong;
 name:string;
 flag:ubyte = 0;
 list:[ulong];
 kv:KV;
}

root_type TestObj;

简单解释一下,FlatBuffer,支持的数据结构有:基本类型(内建标量)和复杂类型(非内建标量)。

基本类型: 

  • 8 bit: byte ubyte bool
  • 16 bit: short ushort
  • 32 bit: int uint float
  • 64 bit: long ulong double

复杂类型:

  • 数组 (用中括号表示 [type]). 不支持嵌套数组,可以用table实现
  • 字符串 string, 支持 UTF-8 或者 7-bit ASCII. 对于其他编码可以用数组 [byte]或者[ubyte]表示。
  • Struct 只支持基本类型或者嵌套Struct
  • Table 类似Struct,但是可以支持任何类型。

看完这些,大家应该就很清楚上面的fb是怎么生成的啦。

KV是一个Struct,有2个名为key和value的变量。

TestObj是一个Table,包含了KV的成员、list数组、flag的uint8(初始值0)、以及uint64的id。

最后定义了根入口是TestObj,这句一定要有,否则无法反序列化。

2.4.编译Schema

执行:

./bin/flatc -c -b ./test.fb

会生成一个.h文件:

test_generated.h

2.5.序列化、反序列化

#include "test_generated.h"
#include 
#include 

using namespace std;
using namespace TestApp;

int main()
{
    flatbuffers::FlatBufferBuilder builder;

    /////////// Serialize //////////
    // Create list 
    std::vector vec;
    for(size_t i=0;i<10;i++)
    {
        vec.push_back(i);
    }
    // Create flat buffer inner type
    auto id = 123;
    auto name = builder.CreateString("name");
    auto list = builder.CreateVector(vec); // vector
    auto flag = 1;
    auto kv = KV(1, 1.0); // struct
    // table
    auto mloc = CreateTestObj(builder, id, name, flag, list, &kv);
    builder.Finish(mloc);

    char* ptr = (char*)builder.GetBufferPointer();
    uint64_t size = builder.GetSize();
    
    ////////// Deserialize //////////
    auto obj = GetTestObj((uint8_t*)ptr);

    cout << obj->id() << endl;
    cout << obj->name()->c_str() << endl;
    cout << obj->flag() << endl;
    for(size_t i=0;ilist()->size();i++)
    {
        cout << obj->list()->Get(i) << endl;
    }

    // can use assign to std::vector for speed up
    // vec.reserve(obj->list()->size());
    // vec.assign(obj->list()->begin(), obj->list()->end());

    cout << obj->kv()->key() << endl;
    cout << obj->kv()->value() << endl;
}

由于FlatBuffers使用了c++0x的特性,所以编译必须使用支持c++0x的版本,例如

g++ -std=c++0x ./test.cpp -I ./include/

对代码说明如下:

  1. 由于FlatBuffer中的类型小复杂,且官方也没有给出明确的例子,所以我就偷懒用了auto特性。处女座请自行参阅源代码。
  2. 基础类型直接赋值,符合类型需要用FlatBufferBuilder.CreateXXX,例如String和Vector
  3. Struct类型,直接构造
  4. Table类型,用CreateXXX,其中XXX为定义的类型,这个在生成代码的.h中
  5. 序列化时候,可以直接从Builder取出指针和length,然后就可以塞入string啦~
  6. 反序列化的时候,注意所有成员都需要用函数()而不是直接使用成员名。

小结一下,生成的代码还真是小乱,用法五花八门,为了性能就忍忍吧,用习惯就好了。

最后说一句,FlatBuffer还支持其他更为高级的用法,例如直接反序列化为Json/从Json序列化,但是性能比较慢,大家慢慢探索吧。

3.FlatBuffer对比优劣

3.1.与Java Serializable对比

传统的Java序列化相比,FlatBuffers在序列化及反序列化的速度上极具优势,在存储空间的占用上也具备一定的优势,节约近乎二倍的存储空间。测试Demo的对象比较简单,当序列化对象更为复杂的时候,FlatBuffers在解析速度与空间占用的优势将更为明显。
Demo下载地址

3.2.与JSON对比

json相比,flatbuffers具备以下优点:
1.解析速度快到飞起(json还要走一次java序列化,速度比Java序列化还慢)
2.节约空间
3.反序列化的时候无内存抖动,就一个ByteBuffer,内存占用平稳,json在反序列化的时候会产生大量临时对象,造成内存抖动。

为什么这么高效呢,援引官方的文档:

  • 对序列化数据的访问不需要打包和拆包——它将序列化数据存储在缓存中,这些数据既可以存储在文件中,又可以通过网络原样传输,而没有任何解析开销;(这是最主要的原因,ProtoBuffer、JSON等均需要拆包和解包)
  • 内存效率和速度——访问数据时的唯一内存需求就是缓冲区,不需要额外的内存分配。 这里可查看详细的基准测试;
  • 扩展性、灵活性——它支持的可选字段意味着不仅能获得很好的前向/后向兼容性(对于长生命周期的游戏来说尤其重要,因为不需要每个新版本都更新所有数据);
  • 最小代码依赖——仅仅需要自动生成的少量代码和一个单一的头文件依赖,很容易集成到现有系统中。再次,看基准部分细节;
  • 强类型设计——尽可能使错误出现在编译期,而不是等到运行期才手动检查和修正;
  • 使用简单——生成的C++代码提供了简单的访问和构造接口;而且如果需要,通过一个可选功能可以用来在运行时高效解析Schema和类JSON格式的文本;
  • 跨平台——支持C++11、Java,而不需要任何依赖库;在最新的gcc、clang、vs2010等编译器上工作良好;

3.3.为什么使用FlatBuffers

使用FlatBuffers的原因很简单,那就是简单、效率高和便利

为了传输数据,我们做了不少努力,研制出不少编解码方法,如:BER、PER、JSON、BSON、XML、HTML等。然而,不管使用何种方法,最终都是直接对数据进行操作,中间的编码和解码运算似乎是多余的。奥卡姆剃刀告诉我们:“如无必要,勿增实体”。在不增加实体的情况下,直接对数据进行操作,这就是FlatBuffers的开发目的。FlatBuffers仅仅增加了VTable和偏移量两个实体。

FlatBuffers很简单,使用起来一点都不难。首先定义好协议文件(schema),然后使用工具编译成源码文件,然后就可以直接调用源码的接口来操作数据。

FlatBuffers的效率很高。FlatBuffers数据在缓冲区内都是平整的,可以直接访问。

FlatBuffers使用起来很便利。协议文件设计好后就可以发布。任何人拿到这个文件都可以编译成源码来对数据进行操作。这对团队开发很有利。另外使用FlatBuffers不用担心协议文件设计不周到的问题,因为你可以随意往Table里面添加或者删除成员。

3.4.为什么不使用FlatBuffers

因为我不关心效率,所以我不使用FlatBuffers。但是能省点CPU资源也没什么不好,至少可以多跑几个服务。

FlatBuffers传输的数据较大,所以我不使用FlatBuffers。其实可以对数据进行压缩再传输。

因为目前开发的项目都使用XML和HTTP,所以我不使用FlatBuffers。建议能在新的模块中使用FlatBuffers。

因为打包后的FlatBuffers中的矢量数据不能随意修改,感觉不爽,所以不使用。对于一般的应用来说,打完包后就会立即发送,很少会修改数据的。

因为目前FlatBuffers的union类型不支持大于256个类型,然而项目中的union将包含超过256个类型的数据结构,所以我不使用FlatBuffers。对于这个问题,可以使用二级或者多级union来解决,即由多个union来实现,如二级union可以至少支持65536个类型。

FlatBuffers传输的数据没有JSON、XML和HTML那么直观,所以不想使用。对于这个问题,可以在收包程序进行LOG来弥补,可以通过查看LOG来获知传输的内容。另外,由于不是明文传输,所以一般的人,如果没有协议文件,即使截取到数据包还得需要花一定的力气才能解开,有利于保密。

FlatBuffers目前没有办法生成C源码,我们的项目使用的都是C源码。对于这个问题,我相信不久就会有版本能生成C源码。