总览
先来看一下 FlatBuffers 项目已经为我们提供了什么,而我们在将 FlatBuffers 用到我们的项目中时又需要做什么的整体流程。如下图:
在使用 FlatBuffers 时,我们需要以特殊的格式定义我们的结构化数据,保存为 .fbs 文件。FlatBuffers 项目为我们提供了编译器,可用于将 .fbs 文件编译为Java文件,C++文件等,以用于我们的项目。FlatBuffers 编译器在我们的开发机,比如Ubuntu,Mac上运行。这些源代码文件是基于 FlatBuffers 提供的Java库生成的,同时我们也需要利用这个Java库的一些接口来序列化或解析数据。
我们将 FlatBuffers 编译器生成的Java文件及 FlatBuffers 的Java库导入我们的项目,就可以用 FlatBuffers 来对我们的结构化数据执行序列化和反序列化了。尽管每次手动执行 FlatBuffers 编译器生成Java文件非常麻烦,但不像 Protocol Buffers 那样,当前还没有Google官方提供的gradle插件可用。不过,我们这边开发了一个简单的 FlatBuffers gradle插件,后面会简单介绍一下,欢迎大家使用。
接下来我们更详细地看一下上面流程中的各个部分。
下载、编译 FlatBuffers 编译器
我们可以在如下位置:
获取官方发布的打包好的版本。针对Windows平台有编译好的可执行安装文件,对其它平台还是打包的源文件。我们也可以指向clone repo的代码,进行手动编译。这里我们从GitHub上clone代码并手动编译编译器:
下载代码之后,我们需要用cmake工具来为flatbuffers生成Makefile文件并编译:
安装之后执行如下命令以确认已经装好:
flatc没有为我们提供 –help 选项,不过加了错误的参数时这个工具会为我们展示详细的用法:
创建 .fbs 文件
flatc支持将为 Protocol Buffers 编写的 .proto 文件转换为 .fbs 文件,如:
Protocol Buffers 消息文件中的一些写法,FlatBuffers 编译器还不能很好的支持,如option java_package,option java_outer_classname,和嵌套类。这里我们基于 FlatBuffers 编译器转换的 .proto 文件来获得我们的 .fbs 文件:
可以参考 官方的文档 来了解 .fbs 文件的详细的写法。
编译 .fbs 文件
可以通过如下命令编译 .fbs 文件:
–java用于指定编译的目标编程语言。-o 参数则用于指定输出文件的路径,如过没有提供则将当前目录用作输出目录。FlatBuffers 编译器按照为不同的数据结构声明的namespace生成目录结构。对于上面的例子,会生成如下的这些文件:
在Android项目中使用 FlatBuffers
我们将前面由 .fbs 文件生成的Java文件拷贝到我们的项目中。我们前面提到的,FlatBuffers 的Java库比较薄,当前并没有发不到jcenter这样的maven仓库中,因而我们需要将这部分代码也拷贝到我们的额项目中。FlatBuffers 的Java库在其repo仓库的 java 目录下。引入这些文件之后,我们的代码结构如下:
添加访问 FlatBuffers 的类:
使用 flatbuf-gradle-plugin
我们有开发一个 FlatBuffers 的gradle插件,以方便开发,项目位置。这个插件的设计有参考Google的protobuf-gradle-plugin,功能与用法也与protobuf-gradle-plugin类似。在这个项目中,我们也有为 FlatBuffers 的Java库创建一个module。
编译并发布flatbuf-gradle-plugin
从github上下载代码:
然后将代码导入Android Studio,将看到如下的代码结构:
app 模块是一个demo程序,flatbuf-gradle-plugin 模块是 FlatBuffers 的gradle插件,而flatbuffers模块则是 FlatBuffers 的Java库。
为了使用 flatbuf-gradle-plugin,可以将插件发布到本地文件系统。这可以通过修改flatbuf-gradle-plugin/build.gradle来完成,修改 uploadArchives task 的 repository 指向本地文件系统,如:
执行uploadArchives task,编译并发布flatbuf-gradle-plugin到本地文件系统。
应用flatbuf-gradle-plugin
修改应用程序的 build.gradle 以应用flatbuf-gradle-plugin
。
为buildscript添加对
flatbuf-gradle-plugin
的依赖:12345678910111213buildscript {//目前先发布在本地,后面会通过maven进行引用repositories {maven {url "file:///Users/netease/Projects/CorpProjects/ht-flatbuffers/app/plugin"}jcenter()mavenCentral()}dependencies {classpath 'com.netease.hearttouch:ht-flatbuf-gradle-plugin:0.0.1-SNAPSHOT'}}在
apply plugin: 'com.android.application'
后面应用flatbuf的plugin:12apply plugin: 'com.android.application'apply plugin: 'com.netease.flatbuf'添加flatbuf块,对flatbuf-gradle-plugin的执行做配置:
12345678910111213141516flatbuf {flatc {path = '/usr/local/bin/flatc'}generateFlatTasks {all().each { task ->task.builtins {remove java}task.builtins {java { }}}}}
flatc
块用于配置 FlatBuffers 编译器,这里我们指定用我们之前手动编译的编译器。task.builtins
的块必不可少,这个块用于指定我们要为那些编程语言生成代码,这里我们为Java生成代码。
- 指定 .fbs 文件的路径1234567sourceSets {main {flat {srcDir 'src/main/flat'}}}
我们将 FlatBuffers 的IDL文件放在src/main/flat目录下。
这样我们就不用再那么麻烦每次手动执行flatc了。
FlatBuffers、Protobuf及JSON对比测试
FlatBuffers相对于Protobuf的表现又如何呢?这里我们用数据说话,对比一下FlatBuffers格式、JSON格式与Protobuf的表现。测试同样用fastjson作为JSON的编码解码工具。
测试用的数据结构所有的数据结构,Protobuf相关的测试代码,及JSON的测试代码同 在Android中使用Protocol Buffers 一文所述,FlatBuffers的测试代码如上面看到的 AddressBookFlatBuffers。
通过如下的这段代码来执行测试:
这里我们执行3组编码测试及3组解码测试。对于编码测试,第一组的单个数据中包含10个Person,第二组的包含50个,第三组的包含100个,然后对每个数据分别执行5000次的编码操作。
对于解码测试,三组中单个数据同样包含10个Person、50个及100个,然后对每个数据分别执行5000次的解码码操作。
在Galaxy Nexus的Android 4.4.4 CM平台上执行上述测试,最终得到如下结果:
编码后数据长度对比 (Bytes)
Person个数 | Protobuf | Protobuf(GZIP) | JSON | JSON(GZIP) | Flatbuf | Flatbuf(GZIP) |
---|---|---|---|---|---|---|
10 | 860 | 288 | 1703 | 343 | 1532 | 513 |
50 | 4300 | 986 | 8463 | 1048 | 7452 | 1814 |
100 | 8600 | 1841 | 16913 | 1918 | 14852 | 3416 |
相同的数据,经过编码,在压缩前JSON的数据最长,FlatBuffers的数据长度与JSON的短大概10 %,而Protobuf的数据长度则大概只有JSON的一半。而在用GZIP压缩后,Protobuf的数据长度与JSON的接近,FlatBuffers的数据长度则接近两者的两倍。
编码性能对比 (S)
Person个数 | Protobuf | JSON | FlatBuffers |
---|---|---|---|
10 | 6.000 | 8.952 | 12.464 |
50 | 26.847 | 45.782 | 56.752 |
100 | 50.602 | 73.688 | 108.426 |
编码性能Protobuf相对于JSON有较大幅度的提高,而FlatBuffers则有较大幅度的降低。
解码性能对比 (S)
Person个数 | Protobuf | JSON | FlatBuffers |
---|---|---|---|
10 | 0.255 | 10.766 | 0.014 |
50 | 0.245 | 51.134 | 0.014 |
100 | 0.323 | 101.070 | 0.006 |
解码性能方面,Protobuf相对于JSON,有着惊人的提升。Protobuf的解码时间几乎不随着数据长度的增长而有太大的增长,而JSON则随着数据长度的增加,解码所需要的时间也越来越长。而FlatBuffers则由于无需解码,在性能方面相对于前两者更有着非常大的提升。
FlatBuffers 编码原理
FlatBuffers的Java库只提供了如下的4个类:
Constants 类定义FlatBuffers中可用的基本原始数据类型的长度:
FlatBufferBuilder 用于FlatBuffers编码,它会将我们的结构化数据序列化为字节数组。我们借助于 FlatBufferBuilder 在 ByteBuffer 中放置基本数据类型的数据、数组、字符串及对象。ByteBuffer 用于处理字节序,在序列化时,它将数据按适当的字节序进行序列化,在发序列化时,它将多个字节转换为适当的数据类型。在 .fbs 文件中定义的 table 和 struct,为它们生成的Java 类会继承 Table 和 Struct。
在反序列化时,输入的ByteBuffer数据被当作字节数组,Table提供了针对字节数组的操作,生成的Java类负责对这些数据进行解释。对于FlatBuffers编码的数据,无需进行解码,只需进行解释。在编译 .fbs 文件时,每个字段在这段数据中的位置将被确定。每个字段的类型及长度将被硬编码进生成的Java类。
Struct 类的代码也比较简洁:
整体的结构如下图:
在序列化结构化数据时,我们首先需要创建一个 FlatBufferBuilder ,在这个对象的创建过程中会分配或从调用者那里获取 ByteBuffer,序列化的数据将保存在这个 ByteBuffer中:
下面我们更详细地分析基本数据类型数据、数组及对象的序列化过程。ByteBuffer 为小尾端的。
FlatBuffers编码基本数据类型
FlatBuffer 的基本数据类型主要包括如下这些:
FlatBufferBuilder 提供了三组方法用于操作这些数据:
putXXX 那一组,直接地将一个数据放入 ByteBuffer 中,它们的实现基本如下面这样:
Boolean值会被先转为byte类型再放入 ByteBuffer。另外一点值得注意的是,数据是从 ByteBuffer 的结尾处开始放置的,space用于记录最近放入的数据的位置及剩余的空间。
addXXX(XXX x) 那一组在放入数据之前会先做对齐处理,并在需要时扩展 ByteBuffer 的容量:
对齐是数据存放的起始位置相对于ByteBuffer的结束位置的对齐,additional bytes被认为是不需要对齐的,且在必要的时候会在ByteBuffer可用空间的结尾处填充值为0的字节。在扩展 ByteBuffer 的空间时,老的ByteBuffer被放在新ByteBuffer的结尾处。
addXXX(int o, XXX x, YYY y) 这一组方法在放入数据之后,会将 vtable 中对应位置的值更新为最近放入的数据的offset。
后面我们在分析编码对象时再来详细地了解vtable。
基本上,在我们的应用程序代码中不要直接调用这些方法,它们主要在构造对象时用于存储对象的基本数据类型字段。
FlatBuffers编码数组
编码数组的过程如下:
先执行 startVector(),这个方法会记录数组的长度,处理元素的对齐,准备足够的空间,并设置nested,用于指示记录的开始。
然后逐个添加元素。
最后 执行 endVector(),将nested复位,并记录数组的长度。
我们前面的AddressBook例子中有如下这样的生成代码:
编码后的数组将有如下的内存分布:
其中的Vector Length为4字节的int型值。
FlatBuffers编码字符串
FlatBufferBuilder 创建字符串的过程如下:
编码字符串的过程如下:
- 对字符串进行编码,比如 UTF-8 ,编码后的数据保存在另一个 ByteBuffer 中。
- 在可用空间的结尾处添加值为 0 的byte。
- 将第 1 步中创建的 ByteBuffer 作为一个字节数组添加到 FlatBufferBuilder 的 ByteBuffer 中。这里不是逐个元素,也就是字节,添加,而是将 ByteBuffer 整体一次性添加,以保证字符串中各个字节的相对顺序不会被颠倒过来,这一点与我们前面在AddressBook 中看到的稍有区别。
编码后的字符串将有如下的内存分布:
FlatBuffers编码对象
对象的编码与数组的编码有点类似。编码对象的过程为:
- 先执行 startObject(),创建 vtable并初始化,记录对象的字段个数及对象数据的起始位置,并设置nested,指示对象编码的开始。
- 然后为对象逐个添加每个字段的值。
- 最后执行 endObject() 结束对象的编码。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061public void startObject(int numfields) {notNested();if (vtable == null || vtable.length < numfields) vtable = new int[numfields];vtable_in_use = numfields;Arrays.fill(vtable, 0, vtable_in_use, 0);nested = true;object_start = offset();}public int endObject() {if (vtable == null || !nested)throw new AssertionError("FlatBuffers: endObject called without startObject");addInt(0);int vtableloc = offset();// Write out the current vtable.for (int i = vtable_in_use - 1; i >= 0 ; i--) {// Offset relative to the start of the table.short off = (short)(vtable[i] != 0 ? vtableloc - vtable[i] : 0);addShort(off);}final int standard_fields = 2; // The fields below:addShort((short)(vtableloc - object_start));addShort((short)((vtable_in_use + standard_fields) * SIZEOF_SHORT));// Search for an existing vtable that matches the current one.int existing_vtable = 0;outer_loop:for (int i = 0; i < num_vtables; i++) {int vt1 = bb.capacity() - vtables[i];int vt2 = space;short len = bb.getShort(vt1);if (len == bb.getShort(vt2)) {for (int j = SIZEOF_SHORT; j < len; j += SIZEOF_SHORT) {if (bb.getShort(vt1 + j) != bb.getShort(vt2 + j)) {continue outer_loop;}}existing_vtable = vtables[i];break outer_loop;}}if (existing_vtable != 0) {// Found a match:// Remove the current vtable.space = bb.capacity() - vtableloc;// Point table to existing vtable.bb.putInt(space, existing_vtable - vtableloc);} else {// No match:// Add the location of the current vtable to the list of vtables.if (num_vtables == vtables.length) vtables = Arrays.copyOf(vtables, num_vtables * 2);vtables[num_vtables++] = offset();// Point table to current vtable.bb.putInt(bb.capacity() - vtableloc, offset() - vtableloc);}nested = false;return vtableloc;}
结束对象编码的过程比较有意思:
- 在可用空间的结尾处添加值为 0 的int。
- 记录下当前的offset值 vtableloc,也就是 ByteBuffer中已经保存的数据的长度。
- 编码vtable。vtable用于记录对象每个字段的存储位置,在为对象添加字段时会被更新。在这里会用 vtableloc - vtable[i],找到每个对象的保存位置相对于对象起始位置的偏移,并将这个偏移量保存到ByteBuffer中。
- 记录对象所有字段的总长度,包含对象开始初值为0的int数据。
- 记录元数据的长度。这包括vtable的长度,记录 对象所有字段的总长度 的short型值,以及这个长度本身所消耗的存储空间。
- 查找是否有一个vtable与正在创建的这个一致。
- 找到了匹配的vtable,则清除创建的元数据。第 1 步中放0的那个位置的值,被更新为找到的vtable相对于对象的数据起始位置的偏移。
- 没有找到匹配的vtable。记下vtable的位置,第 1 步中放0的那个位置的值,被更新为新创建的vtable相对于对象的数据起始位置的偏移。
就像C++中的vtable,这里的vtable也是针对类创建的,而不是对象。
编码后的对象有如下的内存分布:
图中值为0的那个位置的值实际不是0,它指向vtable,图中是指向在创建对象时创建的vtable,但它也可以相同类已经存在的vtable。
结束编码
编码数据之后,需要执行 FlatBufferBuilder 的 finish() 结束编码:
这个方法主要是记录根对象的位置。给 finish() 传入的的根对象的位置是相对于ByteBuffer结尾处的偏移,但是在 addOffset() 中,这个偏移会被转换为相对于整个数据块开始处的偏移。计算off值时,最后加的SIZEOF_INT是要给后面放入的off留出空间。
整个编码后的数据有如下的内存分布:
FlatBuffers 解码原理
这里我们通过一个生成的比较简单的类 PhoneNumber 来了解FlatBuffers的解码。
创建对象的时候,会初始化 bb 为保存有对象数据的ByteBuffer,bb_pos 为对象数据在ByteBuffer中的偏移。在 getRootAsPhoneNumber() 中会从 ByteBuffer的position处获取根对象的偏移,并加上position,以计算出对象在ByteBuffer中的位置。
通过生成的PhoneNumber类中的number()、type()两个方法来看, FlatBuffers 中是怎么访问成员的:
过程大体为:
- 获得对应字段在对象中的偏移位置。
- 根据字段的偏移位置及对象的原点位置计算出对象的位置。
- 通过ByteBuffer等提供的一些方法得到字段的值。
计算字段相对于对象原点位置的偏移的方法 __offset(4) 在com.google.flatbuffers.Table中定义:
在这个方法中,先是根据对象的原点处保存的vtable的偏移得到vtable的位置,然后在从vtable中获取对象字段相对于对象原点位置的偏移。
得到字符串字段的过程如下:
了解了前面字符串编码的过程之后,相信也不难了解这里解码字符串的过程,这里完全是那个过程的相反过程。
如我们所见,FlatBuffers编码后的数据其实无需解码,只要通过生成的Java类对这些数据进行解释就可以了。
FlatBuffers的原理大体如此。
Done。