在观看谷歌发布的视频时,发现视频将json或者xml成为网络传输中的horrible type。这让天天和json打交道的我感觉很疑惑,后面提到了谷歌的FlatBuffers,仿佛打开新世界的大门。虽然我现在开发的业务json的解析并不是瓶颈,但是多学点东西总不是坏事,本篇文章就从源码角度分析下flatbuffer的原理,相信大家看完也就知道flatbuffer快在哪和慢在哪了。
首先我们知道json是基于字符串和各种特殊字符进行解析的,这也就意味着我们在解析的时候需要将数据先整理成标准的json格式,这其中的内存和时间开销较大。而flatbuffers是一种基于二进制数据,根据偏移量进行解析。也就是说省略了解析过程中的各种时间和内存损耗,映衬了名字中的flat。
本文从官方给出的示例结合源码进行分析,由于源码较少,大家可以下载后编进自己项目中即可。
首先我们看,创建一个FlatBufferBuilder做了什么。
public FlatBufferBuilder(int initial_size, ByteBufferFactory bb_factory,
ByteBuffer existing_bb, Utf8 utf8) {
if (initial_size <= 0) {
initial_size = DEFAULT_BUFFER_SIZE;
}
// 这里的bb_factory是一个单例,用于申请bytebuffer的空间
this.bb_factory = bb_factory;
if (existing_bb != null) {
bb = existing_bb;
bb.clear();
bb.order(ByteOrder.LITTLE_ENDIAN);
} else {
// 这里申请了缓冲区
bb = bb_factory.newByteBuffer(initial_size);
}
this.utf8 = utf8;
// 由于基于偏移量进行存储和解析,需要时刻知道缓冲区大小以及被占用的大小
space = bb.capacity();
}
这时候我们拿到了builder对象,之后对于数据的存放均基于这个对象。也就是放到我们申请的缓冲区中。
我们以builder.createString()为例
public int createString(CharSequence s) {
// flatbuffers支持的字符串均为UTF-8编码,对于字符串的处理的性能低
// 也是因为这里有解码转码的过程
int length = utf8.encodedLength(s);
// 插入一个类似于分隔符的东西,具体操作就是加入缓冲区,更新下大小和space数据
addByte((byte)0);
// 进行空间的申请
startVector(1, length, 1);
bb.position(space -= length);
// 将数据放入缓冲区
utf8.encodeUtf8(s, bb);
// 最后把当前长度放到缓冲区末尾,返回缓冲区目前位置
return endVector();
}
放入字符串相对于是较为复杂的操作,其他的createInt()等基本数据类型,操作无外乎申请空间-放入数据-更新并返回最新位置。这些返回的位置需要进行保存,之后构建对象时会有用。
注意,flatbuffers相对麻烦的一点就是,字符串放入后无法进行更改,而且创建之后数据就存在缓冲区中了。对于基本数据类型可以进行更改,因为他们占用的空间是相同的,不会影响缓冲区的位置。
下面我们看一下table相关代码,table是flatbuffers最重要的组成,看schema可以看出table类似于一个对象,也和json中一个json对象相似。
首先,相关代码是根据schema自动生成的,官网示例schema如下:
// Example IDL file for our monster's schema.
namespace com.example.myapplication.autocode;
enum Color:byte { Red = 0, Green, Blue = 2 }
union Equipment { Weapon,Person } // 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 Person {
name:string;
age:short;
}
table Weapon {
name:string;
damage:short;
}
root_type Monster;
table中可以持有数组、基本数据类型、枚举类、其他table,完全可以实现和json相同的功能。
通过startTable(int numfileds)方法进行对象构建,其中numfileds表示参数的个数,比如上述的table Monster中有十个参数,因为union Equipment { Weapon,Person }中有两个table。
我们看看startTable干了什么
public void startTable(int numfields) {
notNested();
// 简单来说就是把当前使用的vtable变成我们正在构建的对象
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();
}
由于数据已经准备完毕,之后无论构建什么数据,所做的就是三件事:准备空间-将该变量的偏移放入缓冲区-更新vtable中该属性的偏移量。
最后结束构建,执行endTable()
public int endTable() {
if (vtable == null || !nested)
throw new AssertionError("FlatBuffers: endTable called without startTable");
// 加个0当分隔符,这个在不同元素中也会进行添加
addInt(0);
int vtableloc = offset();
int i = vtable_in_use - 1;
for (; i >= 0 && vtable[i] == 0; i--) {}
int trimmed_size = i + 1;
// 自后向前,相对偏移量放入缓冲区
for (; i >= 0 ; i--) {
// Offset relative to the start of the table.
short off = (short)(vtable[i] != 0 ? vtableloc - vtable[i] : 0);
addShort(off);
}
// 放入两个固定内容:vtable的大小以及加上两个固定内容所占的大小
final int standard_fields = 2; // The fields below:
addShort((short)(vtableloc - object_start));
addShort((short)((trimmed_size + standard_fields) * SIZEOF_SHORT));
// Search for an existing vtable that matches the current one.
int existing_vtable = 0;
outer_loop:
for (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;
}
一个vtable可以为多个table object提供描述信息,每个table object必须包含一个32位的table offset,指定哪个vtable描述它的字段信息。
每个vtable都有一个16位的vtable size,用来记录本身的大小(包括vtable size);table object size用来记录table object的大小(包括table offset这4个字节),table field offset用来记录table instance内的字段相对table object的偏移量,即table instance的第一个字段偏移量必须为4(因为vtable offset为32位,占了4个字节)。
为了让大家更感性的感知加入的这一堆偏移量,我们输出一下生成的bytebuffer。由于采用小端序存储,我们从后往前看一下,[40]-[43]为“剑”,[36]为长度,同理[28]-[33]为“斧子”,[24]为长度。
在加入“剑”这一属性的时候,“剑”的真实位置和当时的位置偏移为16,[20]记录了16。[18]记录了damage为3。接下来比较难理解,为了一个vtable存储多个table信息,我们在存储完毕所有信息的时候,buffer位于[32]转成小端序也就是[12]这个位置,[12]、[10]记录的8和6表示相对数据区的偏移,[4]和[6]存放的时固定属性,[0]的12表示整个除了数据区的table大小。
在取数的时候,我们先拿到整个vtable的大小,然后进行反向计算,[12]的8表示vtable起点的偏移,然后我们拿到了[4]和[6]的两个固定变量,注意这里,在自动生成类代码的时候,已经规定了属性在vtable中的偏移,通过table的__offset方法可以拿到当时生成的位置,具体方式就是利用vtable的起始位置加上默认的偏移,也就是8和10,注意这里其实屏蔽了我们添加的“斧子”属性,但是这个属性还是会占有空间,注意不要生成一些不需要的属性。之后再利用buffer中的[8]和[10]的位置加上vtable结尾位置12,就能找到我们放置的数据,这里利用类似操作系统中的相对寻址的方式,多次的偏移为的是使得table可以多层嵌套以及vtable的可拓展性,大家可以试着分析一下嵌套情况。不得不说谷歌程序员真的逻辑怪,大家看到这也知道为啥这玩意不易懂了,但是这里的取数据速度可以比json快上百倍,而且没有多余内存的消耗,其实就是在buffer中取数据。时间空间的消耗都很小。
鄙人才疏学浅,如有错误恳请大家指正,谢谢