概述
FlatBuffers是google最新针对游戏开发退出的高性能的跨平台序列化工具,目前已经支持C++, C#, Go, Java, JavaScript, PHP, and Python (C和Ruby正在支持中),相对于json和Protocol Buffers,FlatBuffers在序列化和反序列化方面表现更为优异,而且需要的资源更少,更适合大部分移动应用的使用场景。
FlatBuffers的特点
除了高性能和低内存消耗的特点,FlatBuffers还有其他一些优势,官方的总结说明如下:
- 不用解析也能对数据进行访问
FlatBuffers使用二进制结构化的方法来存储数据,可以不用先对整段数据进行解析就可以直接在二进制结构中访问到指定成员的信息。并且FlatBuffers还支持存储数据结构的前后兼容。 - 内存占用小,运行效率高
FlatBuffers使用ByteBuffer进行数据存储,在FlatBuffers的序列化和反序列化过程中不会额外占用其他内存空间,FlatBuffers读取数据的速度和直接从内存读取原始数据相当,仅仅多了一个相对寻址的耗时。同时,数据存储在ByteBuffer中还能够比较容易地支持内存映射(mmap)和流式读写,进一步降低对内存的消耗。 - 灵活
FlatBuffers支持选择性地写入数据成员,这不仅为某一个数据结构在应用的不同版本之间提供了兼容性,同时还能使程序员灵活地选择是否写入某些字段及灵活地设计传输的数据结构。 - 体积小,集成成本低
项目中集成FlatBuffers只需要很少的额外代码。 - 强类型约束
如果数据结构不符合FlatBufferss文件的描述,那么会在编译期间就发现问题,而不会在运行时才暴露问题。 - 使用方便
可以兼容json等其他格式的解析。 - 跨平台
FlatBuffers和Protocol Buffers是比较相似的,但是FlatBuffers不需要在读取成员变量之前必须将数据完全解析成对象,因为它所有信息的读取都是在对应的ByteBuffer中进行的,少了这些解析时必须为对象和成员变量分配的内存空间,就降低了解析过程中的内存消耗。json相对于FlatBuffers来说可读性更好,但是缺点也是明显的,那就是它的性能太低了,这点可以参见FlatBuffers的benchmarks。其他FlatBuffers的优势可以看white paper。
FlatBuffers的基本使用
由于本文仅仅介绍在Android应用中使用FlatBuffers的方法,因此基本使用方法也只针对java语言进行介绍,其他语言的使用介绍请参看官方介绍。
下载FlatBuffers源码并编译
FlatBuffersS的源码包含flatc的源代码及支持的各种语言需要依赖的代码,目前托管在github上面(这里)。下载完成后首先需要将源码中的flatc源码编译成自己所用平台上的flatc工具,flatc源码支持使用visual studio和xcode进行编译,也支持使用cmake进行跨平台编译,在mac上使用cmake进行编译的方法可以参看这里。在编译得到flatc后,就可以先将源码目录下自己所用语言(这里是java/com/google/flatbuffers)目录引入到自己的工程目录下就可以进行下一步工作了。
编写schema文件
FlatBuffers需要一个用IDL语言描述的schema文件来定义传输数据的结构,IDL是一种类似于C语言的接口定义语言,它支持bool、short、float和double几种基本数据结构及数组、字符串、Struct和Table几种复杂类型。关于如何使用IDL来编写schema文件可以参看这里,此处就不做过多的描述。这里为了方面还是以官方demo的schema文件(monster.FlatBufferss)为例,相应的代码如下:
// Example IDL file for our monster's schema.
namespace MyGame.Sample;
enum Color:byte { Red = 0, Green, Blue = 2 }
union Equipment { Weapon } // Optionally add more tables.
struct Vec3 {
x:float;
y:float;
z:float;
}
table Monster {
pos:Vec3; // Struct.
mana:short = 150;
hp:short = 100;
name:string;
friendly:bool = false (deprecated);
inventory:[ubyte]; // Vector of scalars.
color:Color = Blue; // Enum.
weapons:[Weapon]; // Vector of tables.
equipped:Equipment; // Union.
}
table Weapon {
name:string;
damage:short;
}
root_type Monster;
编译schema文件
编写完schema文件后,就需要使用flatc将其转换成对应语言所对应的类,这里使用:
flatc --java samples/monster.FlatBufferss
将schema文件转换成了如下几个java文件:
这几个文件就是要读写这个schema文件对应的FlatBuffers数据结构所需要依赖的类。flatc会按照一套严格的标准来完成转换的工作,打开生成的这些文件可以看到,这几个类的实现有很多写死的常量,例如成员变量的索引和数组的大小等,这也就说明,一旦schema文件编写完成,也就相当于确定了相应数据的存储结构。flatc还支持很多参数来完成不同的工作,更多高级特性请参看这里
使用
将上一步使用flatc编译生成的java文件引入工程,这些文件就相当于是schema中定义的数据结构的java封装,我们可以很方便地通过这些类完成数据的序列化和反序列化工作。并且只要反序列化时使用的schema和序列化时使用的schema一致,那么一定可以完整地还原序列化时的数据,而和序列化和反序列化时使用的语言无关,这一点是通过FlatBuffers构建二进制数据的规则来保证的,稍后会具体分析这一点。引入编译生成的java类后,可以通过如下代码简单地进行demo测试。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
相关的操作都已经在注释中写明白了。总的来说,FlatBuffers组织数据的格式和Java class文件的格式有点类似,一些复杂类型的成员都先按照约定的格式先写入底层的ByteBuffer,这类似于java class中的常量区,然后使用这些常量的偏移来构建Table,这就相当于java class文件中的“表”。这样的结构决定了一些复杂类型的成员都是使用相对寻址进行数据访问的,即先从Table中取到成员常量的偏移,然后根据这个偏移再去常量真正存储的地址去取真实数据。但是Strcut类型的数据算是一个例外,FlatBuffers规定Struct类型用于存储那些约定成俗、永不改变的数据,这种类型的数据结构一旦确定便永远不会改变,在这个规定之下,为了提高数据访问速度,FlatBuffers单独对Struct使用了直接寻址的方式,这也要求了其数据必须进行内联存储。
FlatBuffers数据存储结构
虽然通过使用flatc对idl文件进行编译后,会自动生成我们定义的数据结构的java类,并且在使用过程中我们也不用关心数据的存储细节。但是如果你想要了解为什么FlatBuffers会如此高效,那么首先就不得不清楚FlatBuffers中的各种不同类型的数据结构是如何存储的。
FlatBuffers底层使用了java的ByteBuffer进行数据存储,ByteBuffer可以算是java NIO体系中的重要成员,很多jvm单独为它从heap中划分了一块存储区域进行数据存储,这样就避免了java数据到native层的传输需要经过java heap到native heap的数据拷贝过程,从而提高了数据读写的效率。但是ByteBuffer是针对直接进行数据存取操作的,虽然它提供了诸如asIntBuffer等方法来构造包装类以便针对int等类型的数据进行读取,但是毕竟FlatBuffers存储的一般并不是单一的数据类型,因此如果让用户来直接操作底层的ByteBuffer的话还是非常麻烦的。幸运的是FlatBuffersBuilder已经为我们封装了很多操作。
FlatBuffers对ByteBuffer的基本使用原则
在后面详细介绍各种数据存储结构之前先说一下FlatBuffers是按照什么规则来使用ByteBuffer的,总的说来就是以下两点:
- 小端模式
FlatBuffers对各种基本数据的存储都是按照小端模式来进行的,因为这种模式目前和大部分处理器的存储模式是一致的,可以加快数据读写的数据。这一点FlatBuffers一般是在初始化ByteBuffer的时候调用ByteBuffer.order(ByteOrder.LITTLE_ENDIAN)来实现的。 - 写入数据方向和读取数据方向不同
和一般向ByteBuffer写入数据的习惯不同,FlatBuffers向ByteBuffer中写入数据的顺序是从ByteBuffer的尾部向头部填充,由于这种增长方向和ByteBuffer默认的增长方向不同,因此FlatBuffers在向ByteBuffer中写入数据的时候就不能依赖ByteBuffer的position来标记有效数据位置,而是自己维护了一个space变量来指明有效数据的位置,在分析FlatBuffersBuilder的时候要特别注意这个变量的增长特点。但是,和数据的写入方向不同的是,FlatBuffers从ByteBuffer中解析数据的时候又是按照ByteBuffer正常的顺序来进行的。FlatBuffers这样组织数据存储的好处是,在从左到右解析数据的时候,能够保证最先读取到的就是整个ByteBuffer的概要信息(例如Table类型的vtable字段),方便解析。如下图所示:
但是为什么FlatBuffers要费劲地在写的时候将数据做逆向增长?这个我也确实没有想到一个好的原因,我认为读写按照相同的顺序完全可以根据绝对地址来实现数据写入和定位读取,这个大家想到有什么好的原因可以来讨论一下。
FlatBuffers寻址特点
除了Struct类型和基本类型,FlatBuffers在ByteBuffer中存放的数据地址都是相对地址,也使用相对寻址的方式来在ByteBuffer中定位数据。存在这个特点的根本原因是因为FlatBuffers存放数据和解析数据的方向不一致造成的。因此在向ByteBuffer写入数据的时候先暂时使用offset来定义位置,offset实际上就是指定位置和ByteBuffer结尾(capacity)的距离,但是又因为之后解析数据的时候并不是从后向前解析的,因此在解析的时候不能依赖这个offset。那要通过何种手段来确定一个成员的位置呢?FlatBuffers采用的是相对位置。例如,一个复杂数据类型在Table数据字段中存储的是这个复杂数据真正存储位置和Table数据字段写入位置的相对位置,这样只要已知Table数据字段的写入位置,就能计算得到这个复杂数据的在ByteBuffer中的真正存储位置了。下面根据上面介绍过的实例并结合源码来以String类型在Table中的存储和写入来解释一下这个特点。
首先我们使用下面的代码来存储一个字符串数据到ByteBuffer中
int monsterName = builder.createString("软泥麦塔");
下面我们具体分析一下这个函数到底做了什么工作,是如何将字符串存储起来的,列出FlatBuffersBuilder中几个相关函数:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
createString函数首先将字符串按照utf-8的方式进行了编码,并且在存储字符串数据之前先写了一个字节的0,以此作为字符串存储结尾的标志。大家注意下putByte这个函数的实现,可以发现每当FlatBuffers向ByteBuffer中写入数据的时候,都是先将space往ByteBuffer的头部移动指定长度,然后再写入数据,space的初始值为ByteBuffer.capacity,它维护了FlatBuffers向当前ByteBuffer写入位置信息,作用就类似于ByteBuffer原生的postion,是FlatBuffers逆向写入数据的产物。
FlatBuffers在实现字符串写入的时候将字符串的编码数组当做了一维的vector来实现,startVector函数是写入前的初始化,并且在写入编码数组之前我们又看到了先将space往前移动数组长度的距离,然后再写入,写入完成后调用endVector进行收尾,endVector再将vector的成员数量,在这里就是字符串数组的长度写入,然后调用offset返回写入的数据结构的起点。在进行下一步分析之前,可以根据上面的分析画出写入数据的结构,如下:
之后就是将字符串写入Table,下面列出相关代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
实际上Table的数据结构比较复杂,后面会单独分析,这里只讲string在Table中的存储的关键代码。当调用addName将字符串存储到ByteBuffer的时候,需要传入刚才调用builder.createString函数返回的字符串的offset,然后addOffset会计算出传入的offset相对于当前写入位置的偏移,并将这个偏移写入。这意味着什么?意味着只要定位到当前写入的这个位置,取出写入的int值,和当前位置相加就得到了存储string数据的真实地址。这也是后面取string数据的一个依据,下面就来分析。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
这里先不讲name()函数如何通过vtable定位到table的数据字段,先认为__string() 函数传入的offset参数即是刚才写入string相对偏移的地址即可,从__string()函数的实现可以看到,首先是通过当前位置和写入的偏移计算出string数据存储的真正位置,然后根据string数据的存储格式取到string的真正数据,结束。
从上面分析流程可以看出,在FlatBuffers对ByteBuffer写入顺序和读取顺序不一致的情况,使用相对寻址都不用关心我们当前读取和写入的顺序这个细节。
FlatBuffers部分复杂数据结构存储分析
有了FlatBuffers数据存储结构的基础后,就可以紧接着分析FlatBuffers支持的几个复杂数据结构的存储了。
Struct类型
除了基本类型之外,FlatBuffers中只有Struct类型使用直接寻址进行数据访问。因此首先就来分析这种数据结构是如何进行存储的,还是结合之前的demo进行讲解。首先来看看Struct结构的java类实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
非常简单,只有两个成员变量,bb就是FlatBuffers用来存储数据的ByteBuffer,bb_post用来指明这个Struct对应的真实数在ByteBuffer中的绝对位置。这两个成员只描述了Struct最基本需求,至于Struct有几个成员变量,以及如何从底层ByteBuffer中解析Struct中的各个对象则留给了Struct的子类来实现。例如,我们可以看看Vec3的实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
从这个实现可以看到,子类自己维护了成员写入和解析的工作,例如自动根据索引写入x,y,z数据,以及根据索引从ByteBuffer中解析对应的成员变量等。因此,只要保证调用Vec3.__init方法的时候,i参数和调用Vec3.createVec3时返回的偏移相同,就一定可以成功从ByteBuffer中解析出数据。
Union类型
这个类型也比较特殊,FlatBuffers规定这个类型在使用上具有如下两个限制:
- Union类型的成员只能是Table类型。
- Union类型不能是一个schema文件的根。
实际上BF中没有任何类型来表示Union,在schema中指明类型为Union后经过flatc编译后会生成一个单独的类来对应声明的Union类型。例如demo中的Equipment就为Union类型,声明为:
union Equipment { Weapon }
经过编译后,生成的Equipment类如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
Union类的实现只是保存了Union类能够存储和表示的类型的名称,为了能够实现类似于联合体的功能,在编译Union类型的时候会为使用这个Union类型的外部类型额外生成一个代表当前Union类型的type,这个type和生成的Union类中的某一个常量type对应。真是因为需要生成一个额外的type类型和Union对应,FlatBuffers才限制了Union类型不能作为schema的根。例如,在demo中的Monster类中有如下代码就是单独为其中的Equipment生成的:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
因此,在序列化Union的时候一般先写入Union的type,然后再写入Union的数据偏移;在反序列化Union的时候一般先解析出Union的type,然后再按照type对应的Table类型来解析Union对应的数据。
enum类型
FlatBuffers中的enum类型在数据存储的时候是和byte类型存储的方式一样的,因为和Union类型相似,enum类型在FlatBuffers中也没有单独的类与它对应,在schema中声明为enum的类会被编译生成单独的类。例如demo中的Color类被编译转换成了如下代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
从类的实现上来看,Color类型只是简单地包括了枚举成员的声明和获取枚举成员名称的接口实现,在序列化和反序列化时,完全是将enum类型当做byte来处理的。例如Monster中对Color的处理如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
可以看到Monster类对Color的序列化和反序列化完全是按照byte来处理的。
Vector类型
Vector类型实际上就是schema中声明的数组类型,FlatBuffers中也没有单独的类型和它对应,但是它却有自己独立的一套存储结构,flatc编译生成的类负责按照这种结构来读写自己所用到的Vector类型的成员。Vector类型的存储结构如下:
Vector在序列化数据时先会从高位到低位依次存储vector内部的数据,然后再在数据序列化完毕后写入Vector的成员个数。下面我们根据flatc生成的相关代码来看FlatBuffers是如何实现这个存储结构的:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
这里进行简单分析。首先从序列化数据开始,首先调用startVector进行对齐等初始化工作,然后依次写入Vector的成员变量。注意由于Vector的成员是复杂的Table类型,因此flatc在处理的时候自动使用了addOffset的方法来写入成员的相对偏移,这意味着后面要反序列化数据的时候需要取出这个偏移再进行一次相对寻址才能访问到复杂类型成员的真正数据;但是如果Vector的成员是简单类型,例如byte或者int时,flatc会自动调用addByte或者addInt等函数来直接存储成员数据,这样在反序列化时可以直接取出存储的数据就能代表成员变量的值。写入成员数据后再调用endVector将Vector的成员个数写入,就完成了序列化的工作。
在反序列化的时候,先通过__offset得到Vector相对于外部Table数据字段的偏移,然后调用 __vector函数得到这个Vector真正存储数据的位置,但是刚才已经说明,由于Vector中存储的只是Weapon的相对地址,因此绝对偏移地址:__vector(o) + j x 4 写入的内容就是第j个Vector变量相对于写入地址的偏移,因此还要通过调用一次__indirect方法进行相对寻址才能得到Vector第j个成员Weapon的偏移地址。
Table类型
Table类型是FlatBuffers中的核心类型,也是其中存储最为复杂的类型,首先我们来看Table类型的存储结构,如下图:
单就结构来讲就看出这种类型的复杂性了,不过有了之前讲解的几种结构的基础,一点点剖析起来也不难。
首先可以将Table分为两个部分,第一部分是存储Table中各个成员变量的概要,这里命名为vtable,第二部分是Table的数据部分,存储Table中各个成员的值,这里命名为table_data。注意Table中的成员如果是简单类型或者Struct类型,那么这个成员的具体数值就直接存储在table_data中;如果成员是复杂类型,那么table_data中存储的只是这个成员数据相对于写入地址的偏移,也就是说要获得这个成员的真正数据还要取出table_data中的数据进行一次相对寻址,这个特点在上面已经强调过了,分析FlatBuffers的时候一定要牢记这个规则。
下面就开始结合demo和源码来分析Table的存储及序列化和反序列化的操作过程。首先看一下demo中调用startMonster开始进行序列化的时候做了什么处理,相关代码如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
做的处理主要是在内存中为当前Table分配一个vtable并初始化,vtable的长度和Table的成员变量数相同,实际上vtable中的每一项记录的就是Table中对应的成员在table_data中的偏移,这一点我们稍后会分析到。还要注意的是,这里仅仅是在内存中分配了一个vtable数组,用于暂时记录Table数据,而没有直接将这些数据写入到底层的ByteBuffer。同时,使用object_start来记录了Table数据开始的偏移。
在调用了startMonster方法后就可以开始一步步添加Monster中的各个成员,Monster为添加各种成员封装了很多add方法,但是这些add方法对应到FlatBuffers的底层无非就两类:
- 基本类型
基本类型的add方法包括addInt、addDouble、addBoolean等等,它们只是在ByteBuffer中说占用的存储长度有所不同,本质上都是将成员的数据直接进行存储。 - 偏移类型
对应为addOffset方法,这个方法接受一个相对于底层ByteBuffer的绝对偏移,然后将这个偏移根据当前写入位置相对于底层ByteBuffer的绝对偏移计算得到相对偏移,然后再将这个相对偏移写入到底层ByteBuffer中。偏移类型在ByteBuffer中占4个字节,而且在底层的存储上和addInt没有任何差别,但是毕竟addOffset和addInt所表示的意思完全不同,因此FlatBuffers就通过flatc对schema的编译来保证生成的类一定遵循“复杂类型的偏移用addOffset,简单类型使用addInt”这种规则,并且凡是使用addOffset进行序列化存储的数据在反序列化时一定会进行一次相对寻址。
这里可以从demo中择抄几段代码来进一步验证上面的分析。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
从以上代码可以看到,FlatBuilder虽然为不同类型的存储封装了不同的方法,但是在底层序列化存储数据的时候不区分要存储的数据是那一类类型,不同数据类型在序列化和反序列化时的正确对应是由flatc生成的类来保证的。
当所有的数据存储完毕后,就可以调用Monster.endMonster方法来标志Monster数据存储的结束,这个函数一系列的操作很关键,下面来分析:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
代码中的一堆注释已经解释了操作的各个步骤完成的任务,总的说来,就是完成了table_data的构建,同时将内存中的vtable写入到了底层的ByteBuffer中。但是有一点需要注意的是,一般来说vtable和table_data就和上面图中画出的一样,是相邻的,但是从代码中来看,如果在调用endObject的时候ByteBuffer中已经存在一个和当前table_data需要的vtable一样的vtable存在时,当前的table_data是会直接复用ByteBuffer中的这个vtable,因此就可能出现二者不相邻的情况,这也说明了:一个vtable在ByteBuffer中可以对应多个table_data,因为vtable只记录Table结构相关的概要信息,和Table具体存储的数据无关 。这也是FlatBuffers将Table数据拆成两部分的一个原因吧,因为如果ByteBuffer中有多个类型相同的Table数据时,这样可以节省存储空间。
调用endObject()后就表示一个Table数据写入完毕,但是如果这个Table是整个schema的root_table,那么还需要调用FlatBuilder.finish()方法来在底层的ByteBuffer的开头写入这个Table的偏移,这样就能够在反序列化的时候通过读取ByteBuffer的前4个字节就能够确定root_table的位置并顺次解析数据。调用完FlatBuilder.finish()方法后就不能再往ByteBuffer中添加任何数据。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
在解析Table数据的时候,FlatBuffers都是通过偏移先找到table_data的位置,然后再根据table_data开始的4个直接找到vtable,然后根据vtalbe来辅助table_data中的数据解析。例如:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
请注意,上面的代码中_bb.getInt(_bb.position())+_bb.position()得到的就是table_data的偏移,因为FlatBuilder.endObject()返回的是table_data的偏移,FlatBuilder.finish()又将这个偏移计算成了相对偏移并记录。因此Table中的bb_pos就是这个Table中的table_data字段在ByteBuffer中的偏移地址。
到这里基本上就将FlatBuffers中的Table讲解完毕了,只剩下最后一个小的细节,那就是:对于简单数据,如果指定写入的数据和默认值相同,FlatBuffers是不会将此数据写入到底层ByteBuffer中的,这又是FlatBuffers节省数据长度的一个优化。大家可以从下面的代码看到这个特点:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
以上代码FlatBuilder.addShort()方法在force_defaults为false的情况下,如果写入的值和当前值相同,那么并不会将数据写入到table_data中,相应的vtable[i]就为0,并且后面通过FlatBuilder.endObject()写入到ByteBuffer中的vtable的第i个成员也为0。此后,调用Monster.hp()的时候__offset()函数返回的值就是0,在这种情况下,FlatBuffers不会再去table_data中去寻找成员的值或者偏移,而是直接返回了schema中规定的默认值。同理,复杂类型数据也有和简单数据类型类似的处理。
结束
本文结合demo和源码对FlatBuffers进行了剖析,在解释原理的时候为了方便省略了其中的一些细节,对于省略的内容并不是不重要,比如说其中的对齐操作和相对偏移的计算都是至关重要的,大家可以参考本文大概把握FlatBuffers的原理,然后对其中的一些细节使用自己的方法来理解。本文的内容是我个人总结,如有偏颇,烦请指正!