据说facebook使用google的黑科技flatbuffers,用来替代传统的json进行数据交换,大大提高了facebook android客户端的效率。于是我在网上查找各种资料学习了一下flatbuffers,参看资料包括GOOGLE官方文档、facebook技术博客、以及其他国内的个人博客,也写了些代码做实验,以此文作为学习总结。
什么是Google FlatBuffers
FlatBuffers是一个开源的、跨平台的、高效的、提供了C++/Java接口的序列化工具库。它是Google专门为游戏开发或其他性能敏感的应用程序需求而创建。尤其更适用于移动平台,这些平台上内存大小及带宽相比桌面系统都是受限的,而应用程序比如游戏又有更高的性能要求。它将序列化数据存储在缓存中,这些数据既可以存储在文件中,又可以通过网络原样传输,而不需要任何解析开销。
为什么要使用Google FlatBuffers
- 对序列化数据的访问不需要打包和拆包——它将序列化数据存储在缓存中,这些数据既可以存储在文件中,又可以通过网络原样传输,而没有任何解析开销;
- 内存效率和速度——访问数据时的唯一内存需求就是缓冲区,不需要额外的内存分配;
- 扩展性、灵活性——它支持的可选字段意味着不仅能获得很好的前向/后向兼容性(对于长生命周期的游戏来说尤其重要,因为不需要每个新版本都更新所有数据);
- 最小代码依赖——仅仅需要自动生成的少量代码和一个单一的头文件依赖,很容易集成到现有系统中。
- 强类型设计——尽可能使错误出现在编译期,而不是等到运行期才手动检查和修正;
- 使用简单——生成的C++代码提供了简单的访问和构造接口;而且如果需要,通过一个可选功能可以用来在运行时高效解析Schema和类JSON格式的文本;
- 跨平台——支持C++11、Java,而不需要任何依赖库;在最新的gcc、clang、vs2010等编译器上工作良好。
为什么不使用Protocol Buffers或者JSON
Protocol Buffers vs FlatBuffers
Protocol Buffers的确和FlatBuffers比较类似,但其主要区别在于FlatBuffers在访问数据前不需要解析/拆包这一步,而且Protocol Buffers没有可选的文本导入/导出功能(FlatBuffers可以直接根据Schema和json文本生成对应的二进制数据文件)。
Protocol Buffers使用一些特殊的数据结构来存储数据,从而在解析数据时需要花费额外的时间。比如,使用varints来存储整型数,varints是一种使用一个或多个字节来序列化整型数的方法,越小的数占用的字节数就越少。同时,代表一个数字的多个字节采用little endian的方式来存储。这就导致在解析数据时,需要判断哪几个字节是表示这个字段的,并进行其他的数学变换来还原出原始数据。
关于protocal buffers具体的数据编码方式,可以参考google开发者网站以及IBM技术博客。
与之相对,FlatBuffers可以直接根据起始位置+偏移量直接获取到数据,无解析过程,效率更高,而伴随的副作用是FlatBuffers需要占用相对更多的空间,因为Protocol Buffers的编码在一定程度上压缩了数据。
JSON vs FlatBuffers
JSON是非常可读的,而且当和动态类型语言(如JavaScript)一起使用时非常方便。然而在静态类型语言中序列化数据时,JSON不但具有运行效率低的明显缺点,而且会让你写更多的代码来访问数据(这个与直觉相反)。
哪些项目使用了FlatBuffers
- Cocos2d-x, the #1 open source mobile game engine, uses it to serialize all their game data.
- Facebook uses it for client-server communication in their Android app. They have a nice article explaining how it speeds up loading their posts.
- Fun Propulsion Labs at Google uses it extensively in all their libraries and games.
FlatBuffers原理
假设我们有一个person类,定义如下:
1 2 3 4 5 6 | class Person { String name; int friendshipStatus; Person spouse; List<Person>friends; } |
其中的spouse和friends字段页包含了person对象,这样就形成了一个树结构。下面就是关于此对象在FlatBuffer中存储的简化图示:
从图中可以看到:
- 每个对象都被分成两部分:中心点左边的元数据部分(或者叫vtable),和中心点右边的真实数据部分。
- 每个字段都对应vtable中的一个槽(slot),它存储了那个字段的真实数据的偏移量。例如,John的vtable的第一个槽的值是1,表明John的名字被存储在John的中心点右边的一个字节的位置上。
- 如果一个字段是一个对象,那么它在vtable中的偏移量会指向子对象的中心点(pivot point)。比如,John的vtable中第三个槽指向Mary的中心点。
- 要表明某个字段现在没有数据,可以在vtable对应的槽中使用偏移量0来标注。
要了解更复杂的关于FlatBuffers字段修改的实现原理,可以查看facebook的文档中的“Mutation on FlatBuffers”部分。
简明使用步骤
- 编写一个用来定义你想序列化的数据的schema文件(又称IDL),数据类型可以是各种大小的int、float,或者是string、array,或者另一对象的引用,甚至是对象集合。各个数据属性都是可选的,且可以设置默认值,所以不必要为每个对象实例都去呈现这些字段。
- 使用FlatBuffer编译器flatc生成C++头文件或者Java类,生成的代码里额外提供了访问、构造序列化数据的辅助类。生成的代码仅仅依赖flatbuffers.h;
- 使用FlatBufferBuilder类构造一个二进制buffer。你可以向这个buffer里循环添加各种对象,而且很简单,就是一个单一函数调用;
- 保存或者发送该buffer;
- 当再次读取该buffer时,你可以得到这个buffer根对象的指针,然后就可以简单的就地读取数据内容。
FlatBuffers使用实战
构建flatc(FlatBuffers编译器)
从Google的flatbuffers仓库下载或克隆源代码,可以在Google的FlatBuffers构建文档中查看构建过程。如果你是Mac用户的话就可以直接按照下面的步骤来操作:
- 打开下载的源代码,目录是
\{extract directory}\build\Xcode\FlatBuffers.xcodeproj
,用Xcode打开此文件。 - 点击
Play
按钮或者⌘ + R
运行。 - flatc可执行文件会出现在项目的根目录下。
现在就可以使用flatc来从给定的schema生成模型类,或者把JSON转换成FlatBuffers二进制文件了。
编写一个Schema
Schema语言(即IDL)的语法与C语言家族很类似。一个简单的例子如下:
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 | // example IDL file namespace MyGame; attribute "priority"; enum Color : byte { Red = 1, Green, Blue } union Any { Monster, Weapon, Pickup } struct Vec3 { x:float; y:float; z:float; } table Monster { pos:Vec3; mana:short = 150; hp:short = 100; name:string; friendly:bool = false (deprecated, priority: 1); inventory:[ubyte]; color:Color = Blue; test:Any; } root_type Monster; |
(Weapon 和 Pickup没有在这个例子中列出。)
Tables
Tables是在FlatBuffers中定义对象的主要方式,由名字(这里的Monster)和字段列表组成。每一个字段都有名字、类型、和一个可选的默认值(如果忽略的话,默认是0/NULL)。
每一个字段都是可选的:对每一个单独的对象个体,你都可以选择忽略一些字段。也就使你可以灵活的增加字段,而不必担心数据膨胀。这个设计也是FlatBuffers向前和向后兼容的机制。
注意:
- 如果要在schema中添加新字段,只能在table的末尾进行添加。
- 如果你不再使用某些字段了,你不能从schema中删除它们。你可以不再把它们写入到你的数据中,效果是一样的。
Structs
Structs与Table类似,只不过没有字段是可选的了(所以没有默认值了),并且字段不能增加或被废弃(deprecated)。Struct只能包含标量或者其他struct。如果你非常确定任何变化都不会发生,那么就可以使用struct。Struct使用的内存比table少,并且读取时比table更快(它们通常被以in-line的方式存储在它们的父对象中,并且不适用virtual table)。
类型
内建的标量类型:
- 8 bit: byte ubyte bool
- 16 bit: short ushort
- 32 bit: int uint float
- 64 bit: long ulong double
内建的非标量类型:
- 关于任何其他类型的Vector
- string,只能存储UTF-8或者7-bit ASCII。如果需要存储其他编码的文本,或者通用二进制数据,请使用vector([byte]或者[ubyte])。
- 对其他table、struct、enum或者union的引用。
关于编写Schema的更多信息,可以参考Google文档:Writing a aschema。
在实验中,我直接使用网上的示例文件。
要处理的数据对应的JSON文件的下载地址为:https://github.com/frogermcs/FlatBuffs/blob/master/flatbuffers/repos_json.json
这个JSON的结构如下面的片段所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | { "repos": [ { "id": 27149168, "name": "acai", "full_name": "google/acai", "owner": { "login": "google", "id": 1342004, ... "type": "Organization", "site_admin": false }, "private": false, "html_url": "https://github.com/google/acai", "description": "Testing library for JUnit4 and Guice.", ... "watchers": 21, "default_branch": "master" }, ... ] } |
为了使用FlatBuffers处理这个JSON数据,我们需要编写对应的Schema,在这个例子中需要创建3个Table:ReposList
, Repo
和 User
,还要定义root_type
。
可以直接从github下载已经编写好的Schema,地址为https://github.com/frogermcs/FlatBuffs/blob/master/flatbuffers/repos_schema.fbs。
这个Schema的部分片段如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | table ReposList { repos : [Repo]; } table Repo { id : long; name : string; full_name : string; owner : User; //... labels_url : string (deprecated); releases_url : string (deprecated); } table User { login : string; id : long; avatar_url : string; gravatar_id : string; //... site_admin : bool; } root_type ReposList; |
FlatBuffers数据文件
现在我们要做的就是把JSON文件转换成FlatBuffers二进制文件,并且生成能够以JAVA友好方式表达我们的数据的JAVA模型。方法是在中断中执行以下命令:
$ ./flatc -j -b repos_schema.fbs repos_json.json
如果一切运行正常,会看到一系列新生成的文件,它们分别是:
- repos_json.bin (我们把它重命名为repos_flat.bin)
- Repos/Repo.java
- Repos/ReposList.java
- Repos/User.java
Schema编译器(即flatc)的完整使用方法为:
flatc [ -c ] [ -j ] [ -b ] [ -t ] [ -o PATH ] [ -I PATH ] [ -S ] FILES…
[ – FILES…]
具体参数说明可以在Google说明文档查看。
在Android app中读数据
在Android Studio中使用FlatBuffers,需要把repos_flat.bin放到res/raw/目录下,同时把repo.java
, ReposList.java
和 User.java
放到工程源代码的某个目录下。
FlatBuffers提供java库来直接在java中操纵这种数据格式。从这里下载jar文件(也可以自己下载的latBuffers源代码,用mvn生成这个库文件),把它放在Android工程的app/libs/目录下。
在需要读取FlatBuffers数据的地方,先把bin文件读取到一个byte数组中,然后用如下所示的代码访问各数据字段:
1 2 3 4 5 | ByteBuffer bb = ByteBuffer.wrap(dataByteArray); ReposList rootRoposList = ReposList.getRootAsReposList(bb); Log.i("TAG", "reposLength = " + rootRoposList.reposLength()); Repo repo1 = rootRoposList.repos(0); Log.i("TAG", "id: " + repo1.id() + "\nname: " + repo1.name() + "\nforks: " + repo1.forks()); |
第一行代码中传入的参数dataByteArray就是从bin文件读出的二进制byte数组。
注意:
Java不支持无符号标量。这意味着你在schema中使用的任何无符号类型实际上都会被表达成为一个有符号的值。这表明,所有的bit都仍然在,但可能代表一个负数。比如,要把一个byte b
作为一个无符号数来读取,可以这样做:(short)(b & 0xFF)
。
在Android app中写数据(以FlatBuffers格式传输数据)
先创建Builder:
1
| FlatBufferBuilder fbb = new FlatBufferBuilder();
|
创建String字段:
1
| int str = fbb.createString("MyMonster");
|
创建一个包含struct的table:
1 2 3 4 5 6 7 8 9 | Monster.startMonster(fbb); Monster.addPos(fbb, Vec3.createVec3(fbb, 1.0f, 2.0f, 3.0f, 3.0, (byte)4, (short)5, (byte)6)); Monster.addHp(fbb, (short)80); Monster.addName(fbb, str); Monster.addInventory(fbb, inv); Monster.addTest_type(fbb, (byte)1); Monster.addTest(fbb, mon2); Monster.addTest4(fbb, test4s); int mon = Monster.endMonster(fbb); |
最后,需要终止这个buffer:
1
| Monster.finishMonsterBuffer(fbb, mon);
|
这个buffer现在已经可以被传输了。它被包含在ByteBuffer中,可以通过fbb.dataBuffer()来获取。很重要的一点是,buffer中有效的数据不是从偏移量0开始的,而是从fbb.dataBuffer().position()开始的,在fbb.dataBuffer().capacity()结束。
更详细的java使用方法,可以参见google官方文档。