php发展

注册

 

发新话题 回复该主题

深入浅出FlatBuffers原理 [复制链接]

1#

一前言

FlatBuffers是一个开源的、跨平台的、高效的、提供了多种语言接口的序列化工具库。实现了与ProtocalBuffers类似的序列化格式。主要由WoutervanOortmerssen编写,并由Google开源。Oortmerssen最初为Android游戏和注重性能的应用而开发了FlatBuffers,现在它具有C++、C#、C、Go、Java、PHP、Python和JavaScript的接口。

高德地图数据编译增量发布使用了FlatBuffers序列化工具,借此契机对FlatBuffers原理进行研究并分享于此。本文简单介绍FlatBuffersScheme,通过剖析FlatBuffers序列化与反序列化原理,重点回答以下问题:

问题1:FlatBuffers如何做到反序列化速度极快的(或者说无需解码)。

问题2:FlatBuffers如何做到默认值不占存储空间的(Table结构内的变量)。

问题3:FlatBuffers如何做到字节对齐的。

问题4:FlatBuffers如何做到向前向后兼容的(Struct结构除外)。

问题5:FlatBuffers在add字段时有没有顺序要求(Table结构)。

问题6:FlatBuffers如何根据Scheme自动生成编解码器。

问题7:FlatBuffers如何根据Scheme自动生成Json。

二FlatBuffersScheme

FlatBuffers通过Scheme文件定义数据结构,Schema定义与其他框架使用的IDL(Interfacedescriptionlanguage)语言类似简单易懂,FlatBuffers的Scheme是一种类C的语言(尽管FlatBuffers有自己的接口定义语言Scheme来定义要与之序列化的数据,但它也支持ProtocolBuffers中的.proto格式)。下面以官方Tutorial中的monster.fbs为例进行说明:

//ExampleIDLfileforourmonstersschema.namespaceMyGame.Sample;enumColor:byte{Red=0,Green,Blue=2}unionEquipment{Weapon}//Optionallyaddmoretables.structVec3{xloat;yloat;zloat;}tableMonster{pos:Vec3;mana:short=;hp:short=;name:string;friendly:bool=false(deprecated);inventory:[ubyte];color:Color=Blue;weapons:[Weapon];equipped:Equipment;path:[Vec3];}tableWeapon{name:string;damage:short;}root_typeMonster;

namespaceMyGame.Sample;

namespace定义命名空间,可以定义嵌套的命名空间,用.分割。

enumColor:byte{Red=0,Green,Blue=2};

enum定义枚举类型。和常规的枚举类稍有不同的地方是可以定义类型。比如这里的Color是byte类型。enum字段只能新增,不能废弃。

unionEquipment{Weapon}//Optionallyaddmoretables

union类似C/C++中的概念,一个union中可以放置多种类型,共同使用一个内存区域。这里的使用是互斥的,即这块内存区域只能由其中一种类型使用。相对struct来说比较节省内存。union跟enum比较类似,但是union包含的是table,enum包含的是scalar或者struct。union也只能作为table的一部分,不能作roottype。

structVect3{xloat;yloat;z:float;};

struct所有字段都是必填的,因此没有默认值。字段也不能添加或者废弃,且只能包含标量或者其他struct。struct主要用于数据结构不会发生改变的场景,相对table使用更少的内存,lookup的时候速度更快(struct保存在父table中,不需要使用vtable)。

tableMonster{};

table是在FlatBuffers中定义对象的主要方式,由一个名称(这里是Monster)和一个字段列表组成。可以包含上面定义的所有类型。每个字段(Field)包括名称、类型和默认值三部分;每个字段都有默认值,如果没有明确写出则默认为0或者null。每个字段都不是必须的,可以为每个对象选择要省略的字段,这是FlatBuffers向前和向后兼容的机制。

root_typeMonster;

用于指定序列化后的数据的roottable。

Scheme设计需要特别注意的:

新字段只能加在table的后面。旧代码会忽略这个字段,仍然可以正常执行。新代码读取旧的数据,会取到新增字段的默认值。

即使字段不再使用了也不能从Scheme中删除。可以标记为deprecated,在生成代码的时候不会生成该字段的访问器。

如果需要嵌套的vector,可以将vector包装在table中。string对于其他编码可以使用[byte]或者[ubyte]支持。

三FlatBuffers的序列化

简单来说FlatBuffers就是把对象数据,保存在一个一维的数组中,将数据都缓存在一个ByteBuffer中,每个对象在数组中被分为两部分。元数据部分:负责存放索引。真实数据部分:存放实际的值。然而FlatBuffers与大多数内存中的数据结构不同,它使用严格的对齐规则和字节顺序来确保buffer是跨平台的。此外,对于table对象,FlatBuffers提供前向/后向兼容性和optional字段,以支持大多数格式的演变。除了解析效率以外,二进制格式还带来了另一个优势,数据的二进制表示通常更具有效率。我们可以使用4字节的UInt而不是10个字符来存储10位数字的整数。

FlatBuffers对序列化基本使用原则:

小端模式。FlatBuffers对各种基本数据的存储都是按照小端模式来进行的,因为这种模式目前和大部分处理器的存储模式是一致的,可以加快数据读写的数据。

写入数据方向和读取数据方向不同。

FlatBuffers向ByteBuffer中写入数据的顺序是从ByteBuffer的尾部向头部填充,由于这种增长方向和ByteBuffer默认的增长方向不同,因此FlatBuffers在向ByteBuffer中写入数据的时候就不能依赖ByteBuffer的position来标记有效数据位置,而是自己维护了一个space变量来指明有效数据的位置,在分析FlatBuffersBuilder的时候要特别注意这个变量的增长特点。但是,和数据的写入方向不同的是,FlatBuffers从ByteBuffer中解析数据的时候又是按照ByteBuffer正常的顺序来进行的。FlatBuffers这样组织数据存储的好处是,在从左到右解析数据的时候,能够保证最先读取到的就是整个ByteBuffer的概要信息(例如Table类型的vtable字段),方便解析。

对于每种数据类型的序列化:

1标量类型

标量类型即基本类型,如:int,double,bool等,标量类型使用直接寻址进行数据访问。

示例:shortmana=;12个字节,存储结构如下:

schema中定义标量可以设置默认值。文章最初提到FlatBuffers的默认值不占存储空间的,对于table内部的标量,是可以做到默认值不存储的,如果变量的值不需要改变,该字段在vtable中对应的offset的值设置为0即可,默认值被记录在解码接口内,解码时获取该字段的offset为0时,解码接口则返回默认值。对于struct结构因为没有使用vtable结构,因此内部的标量没有默认值,必须存储(struct类型和table类型的序列化原理在下文会详细说明)。

//Computeshowmanybytesyoudhavetopadtobeabletowritean//"scalar_size"scalarifthebufferhadgrownto"buf_size"(downwardsin//memory).inlinesize_tPaddingBytes(size_tbuf_size,size_tscalar_size){return((~buf_size)+1)(scalar_size-1);}

标量数据类型是按其本身字节数大小进行对齐。通过PaddingBytes函数计算,所有标量都会调用这个函数,进行字节对齐。

2Struct类型

除了基本类型之外,FlatBuffers中只有Struct类型使用直接寻址进行数据访问。FlatBuffers规定Struct类型用于存储那些约定成俗、永不改变的数据,这种类型的数据结构一旦确定便永远不会改变,没有任何字段是可选的(也没有默认值),字段可能不会被添加或被弃用,所以structs不提供前向/后向兼容性。在这个规定之下,为了提高数据访问速度,FlatBuffers单独对Struct使用了直接寻址的方式。字段的顺序即为存储的顺序。struct有的特性一般不作为schema文件的根。

示例:structVec3(16,17,18);12个字节

struct定义了一个固定的内存布局,其中所有字段都与其大小对齐,并且struct与其最大标量成员对齐。

3vector类型

vector类型实际上就是schema中声明的数组类型,FlatBuffers中也没有单独的类型和它对应,但是它却有自己独立的一套存储结构,在序列化数据时先会从高位到低位依次存储vector内部的数据,然后再在数据序列化完毕后写入Vector的成员个数。数据存储结构如下:

示例:byte[]treasure={0,1,2,3,4,5,6,7,8,9};

vectorsize的类型为int,因此在初始化申请内存时vector进行四字节字节对齐。

4String类型

FlatBuffers字符串按照utf-8的方式进行了编码,在实现字符串写入的时候将字符串的编码数组当做了一维的vector来实现。string本质上也可以看做是byte的vector,因此创建过程和vector基本一致,唯一的区别就是字符串是以null结尾,即最后一位是0。string写入数据的结构如下:

示例:stringname=“Sword”;

vectorsize的类型为int,因此在初始化申请内存时字符串进行四字节字节对齐。

5Union类型

Union类型比较特殊,FlatBuffers规定这个类型在使用上具有如下两个限制:

Union类型的成员只能是Table类型。

Union类型不能是一个schema文件的根。

FlatBuffers中没有特定类型表示union,而是会生成一个单独的类对应union的成员类型。与其他类型的主要区别是需要先指定类型,在序列化Union的时候一般先写入Union的type,然后再写入Union的数据偏移;在反序列化Union的时候一般先

析出Union的type,然后再按照type对应的Table类型来解析Union对应的数据。

6Enum类型

FlatBuffers中的enum类型在数据存储的时候是和byte类型存储的方式一样的。因为和Union类型相似,enum类型在FlatBuffers中也没有单独的类与它对应,在schema中声明为enum的类会被编译生成单独的类。

enum类型不能是一个schema文件的根。

7Table类型

table是FlatBuffers的基石,为了解决数据结构变更的问题,table通过vtable间接访问字段。每个table都带有一个vtable(可以在具有相同布局的多个table之间共享),并且包含存储此特定类型vtable实例的字段的信息。vtable还可能表明该字段不存在(因为此FlatBuffers是使用旧版本的代码编写的,仅仅因为信息对于此实例不是必需的,或者被视为已弃用),在这种情况下会返回默认值。

table的内存开销很小(因为vtables很小并且共享)访问成本也很小(间接访问),但是提供了很大的灵活性。table在特殊情况下可能比等价的struct花费更少的内存,因为字段在等于默认值时不需要存储在buffer中。这样的结构决定了一些复杂类型的成员都是使用相对寻址进行数据访问的,即先从Table中取到成员常量的偏移,然后根据这个偏移再去常量真正存储的地址去取真实数据。

单就结构来讲:首先可以将Table分为两个部分,第一部分是存储Table中各个成员变量的概要,这里命名为vtable,第二部分是Table的数据部分,存储Table中各个成员的值,这里命名为table_data。注意Table中的成员如果是简单类型或者Struct类型,那么这个成员的具体数值就直接存储在table_data中;如果成员是复杂类型,那么table_data中存储的只是这个成员数据相对于写入地址的偏移,也就是说要获得这个成员的真正数据还要取出table_data中的数据进行一次相对寻址。

vtable是一个short类型的数组,其长度为(字段个数+2)*2字节,第一个字段是vtable的大小,包括这个大小本身;第二个字段是vtable对应的对象的大小,包括到vtable的offset;接下来是每个字段相对于对象开始位置的offset。

table_data的开头是vtable开始位置减去当前table对象开始位置的INT型offset,由于vtable可能在任意的地方,这个值有可能是负值。table_data开始用int存储了vtable的offset,因此进行了四字节对齐的。

add的操作是添加table_data,由于Table数据结构的是通过vtable-table_data机制存储的,这个操作没有强制要求字段的先后顺序,对顺序没有要求,因为vtable在记录每个字段相对于对象开始位置的offset时是按照schema中定义的顺序进行存储的,所以在add字段的时候即使没有顺序也可以根据offset获取正确的值。需要注意的是,每次add字段时FlatBuffers都会做字节对齐处理。

std::stringe_poiId="";doublee_coord_x=0.1;doublee_coord_y=0.2;inte_minZoom=10;inte_maxZoom=;//addfeatureBuilder.add_poiId(nameData);featureBuilder.add_x(e_coord_x);featureBuilder.add_y(e_coord_y);featureBuilder.add_maxZoom(e_maxZoom);featureBuilder.add_minZoom(e_minZoom);autorootData=featurePoiBuilder.Finish();flatBufferBuilder.Finish(rootData);blob=flatBufferBuilder.GetBufferPointer();blobSize=flatBufferBuilder.GetSize();

add顺序1:最终二进制的大小为72字节。

std::stringe_poiId="";doublee_coord_x=0.1;doublee_coord_y=0.2;inte_minZoom=10;inte_maxZoom=;//addfeatureBuilder.add_poiId(nameData);featureBuilder.add_x(e_coord_x);featureBuilder.add_minZoom(e_minZoom);featureBuilder.add_y(e_coord_y);featureBuilder.add_maxZoom(e_maxZoom);autorootData=featurePoiBuilder.Finish();flatBufferBuilder.Finish(rootData);blob=flatBufferBuilder.GetBufferPointer();blobSize=flatBufferBuilder.GetSize();

add顺序2:最终二进制的大小为80字节。

add顺序1和add顺序2对应的schema文件一样,表达的数据也一样,Table结构在add字段时有没有顺序要求。序列化后的数据大小差8个字节,原因就是字节对齐导致的。因此add字段的时候,尽量把相同类型的字段放在一起进行add,这样会避免不必要的字节对齐,获取更小的序列化结果。

FlatBuffers的向前向后兼容指的是table结构。table结构每个字段都有默认值,如果没有明确写出则默认为0或者null。每个字段都不是必须的,可以为每个对象选择要省略的字段,这是FlatBuffers向前和向后兼容的机制。需要注意的是:

新的字段只能加在table的后面。旧的代码会忽略这个字段,仍然可以正常执行。新的代码读取旧的数据,新增的字段会返回默认值。

即使字段不再使用了也不能从schema中删除。可以标记为deprecated,在生成代码的时候该字段不会生成该字段的接口。

四FlatBuffers的反序列化

FlatBuffers反序列化的过程就很简单了。由于序列化的时候保存好了各个字段的offset,反序列化的过程其实就是把数据从指定的offset中读取出来。反序列化的过程是把二进制流从roottable往后读。从vtable中读取对应的offset,然后在对应的object中找到对应的字段,如果是引用类型,string/vector/table,读取出offset,再次寻找offset对应的值,读取出来。如果是非引用类型,根据vtable中的offset,找到对应的位置直接读取即可。对于标量,分2种情况,默认值和非默认值。默认值的字段,在读取的时候,会直接从flatc编译后的文件中记录的默认值中读取出来。非默认值字段,二进制流中就会记录该字段的offset,值也会存储在二进制流中,反序列化时直接根据offset读取字段值即可。

整个反序列化的过程零拷贝,不消耗占用任何内存资源。并且FlatBuffers可以读取任意字段,而不是像Json和protocolbuffer需要读取整个对象以后才能获取某个字段。FlatBuffers的主要优势就在反序列化这里了。所以FlatBuffers可以做到解码速度极快,或者说无需解码直接读取。

五FlatBuffers的自动化

FlatBuffers的自动化包括自动生成编码解码接口和自动生成Json,自动化生成编解码接口和自动生成Json,都依赖schem的解析。

1schema描述文件解析

FlatBuffers描述文件解析器按游标的方式顺序进行识别FlatBuffers支持的数据结构。获取字段名称、字段类型、字段默认值、是否弃用等属性。支持关键字:标量类型、非标量类型、include、namespace、root_type。

如果需要嵌套的vector,可以将vector包装在table中。

2自动生成编码解码接口

FlatBuffers使用模板编程,编码解码接口仅生成h文件。实现数据结构的定义,并特化出变量的Add函数、Get函数,校验函数接口。对应的文件名为filename_generated.h。

3自动生成Json

FlatBuffers的主要目标是避免反序列化。通过定义二进制数据协议来实现的,一种将定义好的将数据转换为二进制数据的方法。由该协议创建的二进制结构无需进一步解码即可读取。因此在自动生成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、vs等编辑器上也工作良好。使用简单方便,仅仅需要自动生成的少量代码和一个单一的头文件依赖,很容易集成到现有系统中,生成的C++代码提供了简单的访问和构造接口,可以兼容Json等其他格式的解析。

2缺点

数据无可读性,必须进行数据可视化才能理解数据。

向后兼容性局限,在schema中添加或删除字段必须小心。

七总结

相比其它的序列化工具,FlatBuffers最大的优势是反序列化速度极快,或者说无需解码。如果使用场景是需要经常解码序列化的数据,则有可能从FlatBuffers的特性获得一定的好处。

招聘

欢迎加入高德地图智能技术中心团队。数据是高德的根本,我们的使命是打造高德大数据处理平台,实现高德数据端到端全链路的实时化,在线化,智能化。如果你想知道高德的数据从哪里来,到哪儿去,如何将现实世界转化为人能够理解的活地图,欢迎加入我们。转岗或者推荐,联系人张启鑫:qixin.zqx

alibaba-inc.
分享 转发
TOP
发新话题 回复该主题