1前言FlatBuffers是一个开源、跨平台、高效的序列化工具库,提供多语言接口。实现类似于ProtocolBuffers的序列化格式。主要由WoutervanOortmerssen编写并由Google开源。Oortmerssen最初为Android游戏和注重性能的应用程序开发FlatBuffers,现在它有C++、C#、C、Go、Java、PHP、Python和JavaScript的接口。FlatBuffers序列化工具在高德地图数据编译的增量发布中使用,借此机会研究一下FlatBuffers的原理,在此分享。本文简要介绍FlatBuffersScheme。通过分析FlatBuffers序列化和反序列化的原理,着重回答以下问题:问题1:FlatBuffers反序列化是如何做到极速(或不解码)的。问题2:FlatBuffers如何保证默认值不占用存储空间(Table结构中的变量)。问题三:FlatBuffers是如何实现字节对齐的。问题四:FlatBuffers是如何实现向前和向后兼容的(Struct结构除外)。问题5:FlatBuffers在添加字段时是否有顺序要求(表结构)。问题六:FlatBuffers是如何根据Scheme自动生成编解码器的。问题七:FlatBuffers是如何根据Scheme自动生成Json的。两个FlatBuffersSchemeFlatBuffers通过Scheme文件来定义数据结构。Schema定义类似于其他框架使用的IDL(接口描述语言)语言。FlatBuffers的Scheme是一种类C语言(虽然FlatBuffers有自己的接口定义语言Scheme来定义要序列化的数据,但它也支持ProtocolBuffers中的.proto格式)。以官方教程中的monster.fbs为例://ExampleIDLfileforourmonster'sschema.namespaceMyGame.Sample;enumColor:byte{Red=0,Green,Blue=2}unionEquipment{Weapon}//可选择添加更多表。structVec3{x:float;是:浮动;z:float;}tableMonster{pos:Vec3;法力:短=150;马力:短=100;名称:字符串;friendly:bool=false(已弃用);库存:[ubyte];颜色:颜色=蓝色;武器:[武器];equipped:设备;路径:[Vec3];}表武器{名称:字符串;damage:short;}root_typeMonster;namespaceMyGame.Sample;namespace定义一个命名空间,可以定义嵌套的命名空间,以.分隔。枚举颜色:字节{红色=0,绿色,蓝色=2};enum定义枚举类型。与常规枚举类的细微差别是可以定义类型。比如这里的Color就是byte类型。枚举字段只能添加,不能丢弃。unionEquipment{Weapon}//可选添加更多tablesunion类似于C/C++中的概念。多个类型可以放在一个联合中并共享一个内存区域。这里的使用是互斥的,即这块内存区域只能被其中一种类型使用。与struct相比,节省内存。Union类似于enum,但union包含table,而enum包含scalar或struct。Union只能用作表的一部分,不能用作根类型。结构Vect3{x:浮动;y:浮动;z:float;};struct所有字段都是必填项,所以没有默认值。字段也不能添加或弃用,并且只能包含标量或其他结构。struct主要用于数据结构不会变化的场景,比table占用内存少,查找时速度更快(struct保存在父表中,不需要使用vtable)。表怪物{};table是Fl??atBuffers中定义对象的主要方式,由名称(此处为Monster)和字段列表组成。可以包含上面定义的所有类型。每个字段(Field)包括名称、类型、默认值三部分;每个字段都有一个默认值,如果没有明确写入,则默认为0或null。每个字段都不是必须的,可以为每个对象选择要省略的字段,这是FlatBuffers的前向和后向兼容机制。root_type怪物;用于指定序列化数据的根表。Scheme设计要特别注意:新字段只能在表后添加。旧代码忽略这个字段,仍然正常执行。新代码在读取旧数据时,会获取到新增字段的默认值。即使不再使用,也不能从Scheme中删除字段。可以标记为deprecated,生成代码时不会生成该字段的accessor。如果需要嵌套向量,可以将向量包装在表中。string可以使用[byte]或[ubyte]支持其他编码。三个FlatBuffer的序列化简单来说,FlatBuffers将对象数据保存在一个一维数组中,将数据缓存在一个ByteBuffer中,并将每个对象在数组中分成两部分。元数据部分:负责存储索引。真实数据部分:存储实际值。然而,与大多数内存中的数据结构不同,FlatBuffers使用严格的对齐方式和字节序来确保缓冲区是跨平台的。此外,对于表对象,FlatBuffers提供向前/向后兼容性和可选字段以支持大多数格式的演变。除了解析效率之外,二进制格式还带来了另一个优势,数据的二进制表示通常效率更高。我们可以使用4字节UInts而不是10个字符来存储10位整数。FlatBuffers使用序列化的基本原理:littleendian模式。FlatBuffers以little-endian方式存储各种基础数据,因为这种方式目前与大部分处理器的存储方式一致,可以加快数据读写速度。写入数据方向与读取数据方向不同。FlatBuffers向ByteBuffer写入数据的顺序是从ByteBuffer的末尾向头部填充。由于这个增长方向与ByteBuffer默认的增长方向不同,所以FlatBuffers在向ByteBuffer写入数据时不能依赖ByteBuffer的位置。它不标记有效数据的位置,而是维护一个空间变量来指示有效数据的位置。在分析FlatBuffersBuilder时,要特别注意这个变量的增长特征。但是,与数据的写入方向不同,FlatBuffers是按照ByteBuffer的正常顺序从ByteBuffer中解析数据的。在FlatBuffers中这样组织数据存储的好处是,从左到右解析数据时,可以保证最先读取的是整个ByteBuffer的汇总信息(比如Table类型的vtable字段),方便用于解析。对于各个数据类型的序列化:1标量类型标量类型是基本类型,如:int、double、bool等,标量类型使用直接寻址进行数据访问。示例:短法力=150;12字节,存储结构如下:schema中定义的标量可以设置默认值。文章开头提到FlatBuffers的默认值是不占用存储空间的。对于表内部的标量,可以不存储默认值。如果变量的值不需要改变,vtable中该字段对应的偏移值设置为0即可,解码接口记录默认值。当解码时得到的该字段的偏移量为0时,解码接口返回默认值。对于struct结构,由于没有使用vtable结构,内部标量没有默认值,必须存储(下面会详细介绍struct类型和table类型的序列化原理)。//Computeshowmanybytesyou'dhavetopadtobeabletowritean//"scalar_size"scalarifthebufferhadgrowthto"buf_size"(downwardsin//memory).inlinesize_tPaddingBytes(size_tbuf_size,size_tscalar_size){return((~buf_size)type+1)&(scalar_size)}它自己的字节大小是对齐的。通过PaddingBytes函数计算,所有标量都会调用该函数进行字节对齐。2Struct类型除了基本类型外,FlatBuffers中只有Struct类型使用直接寻址方式进行数据访问。FlatBuffers规定使用Struct类型来存储习惯的、永不改变的数据。这种数据结构一旦确定,就永远不会改变。没有字段是可选的(也没有默认值),并且该字段可能未被添加或弃用,因此结构不提供向前/向后兼容性。在这个规则下,为了提高数据访问速度,FlatBuffers单独对Struct使用了直接寻址。字段的顺序就是存储的顺序。struct的某些特性一般不用作模式文件的根。示例:structVec3(16,17,18);一个12字节的结构定义了一个固定的内存布局,其中所有字段都与其大小对齐,并且该结构与其最大的标量成员对齐。3vector类型vector类型其实就是schema中声明的数组类型,在FlatBuffers中并没有单独的类型与之对应,但是它有自己独立的一套存储结构,在序列化数据的时候会按照从高到低的顺序排列将数据存储在vector内部,数据序列化后写入Vector的成员个数。数据存储结构如下:例:byte[]treasure={0,1,2,3,4,5,6,7,8,9};vectorsize的类型是int,所以vector在初始化内存的时候进行了四次操作Byte字节对齐。4String类型的FlatBuffers字符串按照utf-8方式编码。在实现字符串写入时,将编码后的字符串数组实现为一维向量。string本质上也可以看做是一个字节的vector,所以创建过程和vector基本一样,唯一的区别是string以null结尾,即最后一位为0。字符串写入的数据如下:例:stringname=“Sword”;vectorsize的类型是int,所以初始化内存的时候字符串是四字节对齐的。5Union类型Union类型比较特殊。FlatBuffers规定该类型在使用上有以下两个限制:Union类型的成员只能是Table类型。联合类型不能是模式文件的根。FlatBuffers中并没有具体的类型来表示union,而是会生成一个单独的类对应union的成员类型。与其他类型的主要区别是需要先指定类型。序列化Union时,一般先写Union的类型,再写Union的数据偏移量;Union反序列化的时候,一般先分析Union的类型。然后根据type对应的Table类型解析Union对应的数据。6枚举类型FlatBuffers中枚举类型的存储方式与存储数据时的字节类型相同。因为和Union类型类似,枚举类型在FlatBuffers中没有单独的类与之对应,schema中声明为枚举的类会被编译生成单独的类。枚举类型不能是模式文件的根。7Table类型table是Fl??atBuffers的基石。为了解决数据结构变化的问题,table通过vtable间接访问字段。每个表都带有一个vtable(可以在具有相同布局的多个表之间共享),并包含有关存储这种特定类型vtable实例的字段的信息。vtable也可能表明该字段不存在(因为这个FlatBuffers是使用旧版本代码编写的,仅仅是因为该信息对于这个实例不是必需的,或者被认为已弃用),在这种情况下返回默认值.表的内存开销很小(因为vtable很小且共享),访问成本也很小(间接),但提供了很大的灵活性。在特殊情况下,表可能比等效结构占用更少的内存,因为当字段等于默认值时不需要存储在缓冲区中。这种结构决定了一些复杂类型的成员使用相对寻址进行数据访问,即先从Table中获取成员常量的偏移量,然后根据这个去常量的真实存储地址获取真实数据抵消。单从结构上来说:首先,Table可以分为两部分。第一部分是将各个成员变量的汇总存储在Table中,这里命名为vtable。第二部分是Table的数据部分,存放的是Table中各个成员的值。这里命名为table_data。注意,如果Table中的成员是简单类型或者Struct类型,那么这个成员的具体值直接存储在table_data中;如果成员是复杂类型,那么table_data中存储的只是成员数据相对于写入地址的偏移量。也就是说,要想得到这个成员真正的数据,必须要取table_data中的数据,进行相对寻址。vtable是一个short类型的数组,它的长度是(字段数+2)*2个字节,第一个字段是vtable的大小,包括大小本身;第二个字段是vtable对应的对象的大小,包括到vtable的偏移量;后跟每个字段相对于对象开头的偏移量。table_data的开头是vtable起始位置减去当前table对象起始位置的INT偏移量。由于vtable可能在任何地方,所以这个值可能是负值。table_data开始使用int来存储vtable的偏移量,所以是四字节对齐的。add的操作是添加table_data。由于Table数据结构是通过vtable-table_data机制存储的,所以这个操作不强制字段的顺序,对顺序没有要求,因为vtable记录的是每个字段相对于表起始位置的偏移量object的时间是按照schema中定义的顺序存储的,所以即使在添加字段的时候没有顺序,也可以根据偏移量得到正确的值。需要注意的是,FlatBuffers会在每增加一个字段时进行字节对齐。std::stringe_poiId="1234567890";double_coord_x=0.1;doublee_coord_y=0.2;inte_minZoom=10;inte_maxZoom=200;add_maxZoom(e_maxZoom);featureBuilder.add_minZoom(e_minZoom);autorootData=featurePoiBuilder.Finish();flatBufferBuilder.Finish(rootData);blob=flatBufferBuilder.GetBufferPointer();blobSize=flatBufferBuilder.GetSize();添加顺序1:finalbinary大小为72字节。std::stringe_poiId="1234567890";double_coord_x=0.1;doublee_coord_y=0.2;inte_minZoom=10;inte_maxZoom=200;//addfeatureBuilder.add_poiId(nameData);featureBuilder.add_x(e_coord_x);featureBuilder.add_minZoom(e_minZoom.);add_y(e_coord_y);featureBuilder.add_maxZoom(e_maxZoom);autorootData=featurePoiBuilder.Finish();flatBufferBuilder.Finish(rootData);blob=flatBufferBuilder.GetBufferPointer();blobSize=flatBufferBuilder.GetSize();添加顺序2:最终二进制大小为80字节。addorder1和addorder2对应的schema文件是一样的,表达的数据也是一样的。Table结构在添加字段时有顺序要求吗?序列化后的数据大小相差8个字节,这是字节对齐造成的。因此,在添加字段时,尽量将相同类型的字段放在一起添加,这样可以避免不必要的字节对齐,得到更小的序列化结果。FlatBuffers的前向和后向兼容性指的是表结构。表结构的每个字段都有一个默认值,如果没有明确写,默认为0或null。每个字段都不是必须的,可以为每个对象选择要省略的字段,这是FlatBuffers的前向和后向兼容机制。需要注意的是,新字段只能在表后添加。旧代码忽略这个字段,仍然正常执行。新代码读取旧数据,新字段??返回默认值。字段不能从模式中删除,即使它们不再使用也是如此。可以标记为deprecated,代码生成时不会生成该字段的接口。四FlatBuffers反序列化FlatBuffers反序列化过程非常简单。由于在序列化的时候保存了每个字段的偏移量,所以反序列化的过程实际上就是从指定的偏移量开始读取数据。反序列化的过程就是从根表向后读取二进制流。从vtable中读取对应的偏移量,然后在对应的对象中找到对应的字段。如果是引用类型,string/vector/table,读取偏移量,重新找到偏移量对应的值,读取。如果是非引用类型,根据vtable中的offset,找到对应的位置,直接读取。对于标量,有两种情况,默认值和非默认值。默认值字段,在读取的时候,会直接从flatc编译的文件中记录的默认值中读取。对于非默认值字段,该字段的偏移量将记录在二进制流中,值也会存储在二进制流中。反序列化时,直接根据偏移量读取字段值即可。整个反序列化过程是零拷贝的,不消耗任何内存资源。而且FlatBuffers可以读取任意字段,而不是像Json、protocolbuffer一样需要读取整个对象才能得到某个字段。FlatBuffers的主要优点是反序列化。所以FlatBuffers可以解码的速度极快,或者不解码直接读取。五、FlatBuffers的自动化FlatBuffers的自动化包括编解码接口的自动生成和Json的自动生成,编解码接口的自动生成和Json的自动生成,都是依赖于schema的解析。1Schema描述文件解析FlatBuffers描述文件解析器根据游标的顺序识别FlatBuffers支持的数据结构。获取字段名称、字段类型、字段默认值、是否弃用等属性。支持的关键字:标量类型、非标量类型、include、namespace、root_type。如果需要嵌套向量,可以将向量包装在表中。2自动生成编解码接口FlatBuffers使用模板编程,编解码接口只生成h文件。实现数据结构的定义,特化变量的Add函数、Get函数和校验函数接口。对应的文件名为filename_generated.h。3自动生成JsonFlatBuffers的主要目的是为了避免反序列化。这是通过定义二进制数据协议来实现的,这是一种将定义的数据转换为二进制数据的方法。无需进一步解码即可读取由此协议创建的二进制结构。因此,在自动生成json时,只需要提供二进制数据流和二进制定义结构,即可读取数据并转换为json。Json结构与FlatBuffers结构一致。默认值不输出Json。FlatBuffers的六大优缺点FlatBuffers通过Scheme文件来定义数据结构。Schema定义类似于其他框架使用的IDL(Interfacedescriptionlanguage)语言,易于理解。FlatBuffers的Scheme是一种类C语言(虽然FlatBuffers有自己的接口定义语言。Scheme来定义要序列化的数据,但它也支持ProtocolBuffers中的.proto格式)。下面以官方Tutorial中的monster.fbs为例进行说明:1优点解码速度极快,序列化后的数据存放在缓存中。没有任何解析开销的读取,访问数据时唯一的内存需求是缓冲区,不需要额外的内存分配。可扩展性、灵活性:它支持的可选字段意味着良好的向前/向后兼容性。FlatBuffers支持数据成员的选择性写入,不仅提供了某种数据结构不同版本之间的兼容性,还允许程序员灵活选择是否写入某些字段,灵活设计传输数据结构。跨平台:支持C++11和Java,无任何依赖库,在最新的gcc、clang、vs2010等编辑器上运行良好。使用简单方便,只需要少量的自动生成代码和单一的头文件依赖,易于集成到现有系统中。生成的C++代码提供了简单的访问和构造接口,兼容解析Json等其他格式。2缺点数据不可读,必须进行数据可视化才能理解数据。由于向后兼容性限制,在架构中添加或删除字段时必须小心。七小结与其他序列化工具相比,FlatBuffers最大的优势在于反序列化速度极快,或者说不需要解码。如果使用场景是序列化数据需要经常解码,可以利用FlatBuffers的特性获得一定的收益。
