1、概述
FlatBuffers 是一个开源的、跨平台的、高效的、提供了多种语言接口的序列化工具库。
在Java端,它使用ByteBuffer处理数据,序列化时将数据生成二进制流,反序列化时即从二进制流中读取数据。
整个反序列化的过程零拷贝,并且 FlatBuffers 可以读取任意字段,而不是像 JSON 和 protocol buffer 需要读取整个对象以后才能获取某个字段,因此它反序列化极快,它的主要优势就在反序列化这里了。
为什么它如此快呢?因为 FlatBuffers 把数据保存在ByteBuffer中(可简单理解为一维数组),并且数据被分为两部分。
- 元数据部分:负责存放索引
- 真实数据部分:存放实际的值
当用户查找数据时(即反序列化),根据索引直接轻松查找,所以反序列化效率很高
因为FlatBuffers还跨平台,为了保证各平台能无脑查找,它使用严格的对齐规则和字节顺序来确保,这一点可以在序列化一节中得到验证。
FlatBuffers 序列化基本原则:
- 小端模式。FlatBuffers 对各种基本数据的存储都是按照小端模式来进行的,因为这种模式目前和大部分处理器的存储模式是一致的,可以加快数据读写的速度。
- 写入数据方向和读取数据方向不同。
FlatBuffers 向 ByteBuffer 中写入数据的顺序是从 ByteBuffer 的尾部向头部填充,由于这种增长方向和 ByteBuffer 默认的增长方向不同,因此 FlatBuffers 在向 ByteBuffer 中写入数据的时候就不能依赖 ByteBuffer 的 position 来标记有效数据位置,而是自己维护了一个 space 变量来指明有效数据的位置。
但是,和数据的写入方向不同的是,FlatBuffers 从 ByteBuffer 中解析数据的时候又是按照 ByteBuffer 正常的顺序来进行的。FlatBuffers 这样组织数据存储的好处是,在从左到右解析数据的时候,能够保证最先读取到的就是整个 ByteBuffer 的概要信息(例如 Table 类型的 vtable 字段,即元数据部分),方便解析。
2、序列化原理
以附录中示例的 DemoEntity 为例,看看它是怎么序列化字符串和Byte类型数据的。其它类型的数据原理类似,故这里只分析字符串和Byte了
从字符串开始分析
2.1、字符串序列化
scss
public int createString(CharSequence s) {
int length = utf8.encodedLength(s);
addByte((byte)0);
startVector(1, length, 1);
bb.position(space -= length);
utf8.encodeUtf8(s, bb);
return endVector();
}
简单从上述代码看,对字符串的处理分成这几步:
- 先保存 '0',作为字符串的结尾
- 开始处理数组(字符串当成数组处理)
- 保存字符串的每一位
- 结束处理数组(字符串当成数组处理)
首先,从写 '0' 开始看起
arduino
public void addByte (byte x) { prep(Constants.SIZEOF_BYTE, 0); putByte (x); }
public void prep(int size, int additional_bytes) {
if (size > minalign) minalign = size;
//计算对齐要补多少个字节
int align_size = ((~(bb.capacity() - space + additional_bytes)) + 1) & (size - 1);
while (space < align_size + size + additional_bytes) {
int old_buf_size = bb.capacity();
ByteBuffer old = bb;
bb = growByteBuffer(old, bb_factory);
if (old != bb) {
bb_factory.releaseByteBuffer(old);
}
space += bb.capacity() - old_buf_size;
}
pad(align_size);
}
public void pad(int byte_size) {
for (int i = 0; i < byte_size; i++) bb.put(--space, (byte)0);
}
public void putByte (byte x) { bb.put (space -= Constants.SIZEOF_BYTE, x); }
pre方法大致有两个作用:
- 计算对齐所需要的额外的字节数,如果
align_size
大于0,则调用pad
方法,这些位置补0 - 如果剩下的空间不够要写入的字节数了,则扩容ByteBuffer
pre方法最难理解的是这行代码:
arduino
//bb.capacity()是ByteBuffer的总长度,space是从后往前写的指针,二者相减,则是当前已经写入多少数据
//再加上additional_bytes,是一共要写入的数据的字节长度。
//总写入数字节长度按位取反加1,再和 size - 1,做按位与。
int align_size = ((~(bb.capacity() - space + additional_bytes)) + 1) & (size - 1);
最终的结果是计算是需要补齐的位置。
看putByte
方法,它存放的位置是space
位置,而space
在构造方法中,被赋值为总长度,所以确实是从后往前写入数据
arduino
public FlatBufferBuilder(int initial_size, ByteBufferFactory bb_factory,
ByteBuffer existing_bb, Utf8 utf8) {
。。。。。。
space = bb.capacity();
}
接下来继续看startVector
方法
ini
public void startVector(int elem_size, int num_elems, int alignment) {
notNested();
vector_num_elems = num_elems;
prep(SIZEOF_INT, elem_size * num_elems);
prep(alignment, elem_size * num_elems); // Just in case alignment > int.
nested = true;
}
同样是对齐两次,并且将数组的长度保存,这两次对齐的结果都是0,即不需要补0
接下来就是写入字符串了。
ini
//bb移动指针,把字符串全写入
bb.position(space -= length);
utf8.encodeUtf8(s, bb);
再看endVector
方法
csharp
public int endVector() {
if (!nested)
throw new AssertionError("FlatBuffers: endVector called without startVector");
nested = false;
putInt(vector_num_elems);
return offset();
}
public void putInt (int x) { bb.putInt (space -= Constants.SIZEOF_INT, x); }
public int offset() {
return bb.capacity() - space;
}
上述方法将把字符串的长度写入,长度是int型,占据4个字符。
到目前为止,bb中的数据应该如下:
2.2、完整序列化
在示例代码中,字符串序列化完成后,即开始对整体序列化
scss
//注意,这里的nameOffset值等于8,即上面字符串序列化完以后,总长度减去space值
fun createDemoEntity(builder: FlatBufferBuilder, flag: Byte, nameOffset: Int) : Int {
builder.startTable(2)
addName(builder, nameOffset)
addFlag(builder, flag)
return endDemoEntity(builder)
}
//numfields表示DemoEntity一共有几个成员变量,这里的值是2
public void startTable(int numfields) {
notNested();
if (vtable == null || vtable.length < numfields) vtable = new int[numfields];
vtable_in_use = numfields;
//创建一个长度为2的数组,为后面保存各成员变量的offse值,现在先补0
Arrays.fill(vtable, 0, vtable_in_use, 0);
nested = true;
object_start = offset();
}
fun addName(builder: FlatBufferBuilder, name: Int) = builder.addOffset(1, name, 0)
public void addOffset (int o, int x, int d) { if(force_defaults || x != d) { addOffset (x); slot(o); } }
public void addOffset(int off) {
prep(SIZEOF_INT, 0); // Ensure alignment is already done.
assert off <= offset();
off = offset() - off + SIZEOF_INT;
putInt(off);
}
public void slot(int voffset) {
vtable[voffset] = offset();
}
从代码看,startTable只是初始化了一个长度为2的数组,用于保存各成员变量的offse值,目前可知,name的offset值为8
在addOffset方法中,第一次prep方法中,对齐字段依然是0,无需被数据。然后会播放一个int值,4。用它隔开字符串和第二个成员变量flag。
后面再调用slot方法,其实就是往vtable
中保存name
的offset
值
接下来再处理第二个成员变量数据,flag
scss
fun addFlag(builder: FlatBufferBuilder, flag: Byte) = builder.addByte(0, flag, 0)
public void addByte(int o, byte x, int d) { if(force_defaults || x != d) { addByte(x); slot(o); } }
public void addByte(byte x) { prep(Constants.SIZEOF_BYTE, 0); putByte(x); }
上述代码非常简单,往bb中写入byte值,并且往vtable
中保存flag
的offset
值
值得注意的是,保存byte需要对齐。当前已经保存了12个字节了,现在又要保存1个字节的byte数据,而对齐最小单位,现在已经变成4,通过断点知道,此时需要对齐,增加3个对齐字节。
此时,bb中的数据应该如下:
接下来,查看endTable方法:
scss
public int endTable() {
//先补0,也是一种间隔保护,flag已经保存了,所以插入0,隔开
addInt(0);
//现在获取vtableloc的值,即vtable的offset值,vtable类似于虚函数
int vtableloc = offset();
// Write out the current vtable.
int i = vtable_in_use - 1;
// Trim trailing zeroes.
for (; i >= 0 && vtable[i] == 0; i--) {}
int trimmed_size = i + 1;
//非常重要的代码,把name和flag的offset数据保存起来,在bb中添加两个short值
//分别是两个成员变量的offset值
for (; 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:
//保存vtable的offset距离start的差值
addShort((short)(vtableloc - object_start));
//保存vtable的长度,目前vtable中已经保存了两个成员变量的offset,再加上自己的offset与start间隔
//再加上vtable的长度,目前看长度是4
addShort((short)((trimmed_size + standard_fields) * SIZEOF_SHORT));
// Search for an existing vtable that matches the current one.
int existing_vtable = 0;
//num_vtables值是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;
}
}
//existing_vtable 值也为0
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.
//更新vtableloc这个位置上的数据,之前这个位置数据是0,现在更新为当前位置距离vtableloc的位置,
//其实依然可以理解为vtable的长度
bb.putInt(bb.capacity() - vtableloc, offset() - vtableloc);
}
nested = false;
return vtableloc;
}
这个方法非常关键,也非常得复杂,很绕。
目前bb里一共存放了16个字节数据。两个成员变量都已经保存了,接下来要来生成一个新的数据,叫vtable
,它有点类似虚函数表,它是后面用来作反序列化的利器,它内部记录了两个成员变量的offset,帮助我们快速找到它们。但vtable有点不好解理。
首先,flag已经保存成功了,保存完一个数据,先放一个int数据作间隔,很合理吧。
从这之后,所有的数据都可认为是vtable相关的数据了。
vtable要保存哪些数据呢?
而且在最后,还更新了之前的间隔里的值为vtable的长度。
注意上述代码,指定bb里 bb.capacity() - vtableloc
这个位置的值为 offset() - vtableloc
,所以bb内的数据长度并未改变
值得注意的是,endTable
函数返回的也是vtableloc,即vtable的offset值,在当前示例中,这个值为20
最后还需要调用finish
方法
scss
//root_table,即上面返回的vtable的offset值,20,size_prefix为false
protected void finish(int root_table, boolean size_prefix) {
prep(minalign, SIZEOF_INT + (size_prefix ? SIZEOF_INT : 0));
addOffset(root_table);
if (size_prefix) {
addInt(bb.capacity() - space);
}
bb.position(space);
finished = true;
}
public void addOffset(int off) {
prep(SIZEOF_INT, 0); // Ensure alignment is already done.
assert off <= offset();
off = offset() - off + SIZEOF_INT;
//off值为20,当前已经存放了28个字节,所以off最后的值将为12
putInt(off);
}
每结束一个东东的保存,都要添加一个间隔值,但在最后这里,不是补0,而是计算一个值,这个值是vtable的offset与当前结尾处的间隔值,再加上4,即12。
到目前为止,bb中所有的内容为:
到目前为止,很可能大家都要晕了,各种offset,对齐、间隔,vtable,搞啥,它们的意义是什么?
理解vtable中存放数据的意义,就理解了大半(反序列化就是靠着vtable来查找数据的),就很好理解后面的内容了
3、反序列化
从前文中我们可知道,vtable中存放了各成员变量与vtable之间的offset间距,如果找到了vtable的offset值,并且拿到了vtable与各成员变量之间的间距值,是不是就可以算出各成员变量它们本身的offset值了。
反序列化的代码是这样的:
ini
val demoArr = fb.sizedByteArray()
val demo = DemoEntity.getRootAsDemoEntity(ByteBuffer.wrap(demoArr))
println(demo.name)
println(demo.flag)
fun getRootAsDemoEntity(_bb: ByteBuffer, obj: DemoEntity): DemoEntity {
_bb.order(ByteOrder.LITTLE_ENDIAN)
return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb))
}
protected void __reset(int _i, ByteBuffer _bb) {
bb = _bb;
if (bb != null) {
bb_pos = _i;
vtable_start = bb_pos - bb.getInt(bb_pos);
vtable_size = bb.getShort(vtable_start);
} else {
bb_pos = 0;
vtable_start = 0;
vtable_size = 0;
}
}
val name : String?
get() {
val o = __offset(6)
return if (o != 0) {
__string(o + bb_pos)
} else {
null
}
}
protected String __string(int offset) {
return __string(offset, bb, utf8);
}
反序列化的时候,读是正向读的,所以现在拿着上面的图正向看。
_bb.position()
的值肯定等于0_bb.getInt(_bb.position())
的值等于12,序列化中finish阶段存储的值,它代表着vtable的起始值距离现在的位置
所以,在reset方法中i
的值为12
- bb.getInt(bb_pos)的值,即bb中索引为12的值,我们从上图中看可知它是8,这个值可以理解为vtable的长度。
vtable_start
值计算为4vtable_size
值为8
这么看是不是就非常直观了。
为什么name反序列化的时候,直接传了个6,这是flatc在生成代码时,自动赋值的,看看它是怎么找出name的位置的:
java
protected int __offset(int vtable_offset) {
return vtable_offset < vtable_size ? bb.getShort(vtable_start + vtable_offset) : 0;
}
已知vtable_start
等于4,那么 vtable_start + vtable_offset
是10,10这个位置存放着什么?正是name相对于vtable offset的间距,即 __offset(6)
方法返回值8
而bb_pos
值为12,__string(o + bb_pos)
方法中的参数值即为20。
回到上图看,20这个位置是什么内容:
红线位置之后即是20字节的数据,刚刚好是name和flag两个成员变量之间的间隔。
那有同学会说了,它还不是字符串的起点啊,怎么读得到字符串呢?别急,很快了:
ini
//offset值为20
protected static String __string(int offset, ByteBuffer bb, Utf8 utf8) {
//可以去翻看代码,当时在加间隔数据的时候是这写的,off = offset() - off + SIZEOF_INT;它的值为4
offset += bb.getInt(offset);
//offset再加上4,则等于24,24位刚刚好是字符串的长度
int length = bb.getInt(offset);
//拿到字符串长度,从28位置开始读正好读到字符串
return utf8.decodeUtf8(bb, offset + SIZEOF_INT, length);
}
终于整体分析完毕。
4、总结
可以看到反序列化中,并不需要再做内存拷贝,也不需要把所有数据全都读取才能读到数据,FlatBuffers它内部存储的全是二进制指令,再加上神奇的vtable,vtable中存储了各成员变量的相对offset值,略一推算就可直接跳转到相应位置读取对应的值。
这种思路甚至类似于HashMap,根据索引找值总是非常快的
附录:FlatBuffers使用
FlatBuffers使用的流程如下:
- 安装FlatBuffers,可以下载源码编译也可以下载软件,Releases · google/flatbuffers
- 编写 schema 文件,描述数据结构和接口定义。
- 用 flatc 编译,生成相应语言的代码文件。
- 使用 FlatBuffers 支持的语言(如C ++,Java等)生成的文件进行开发。
接下来定义一个简单的schema文件
ini
namespace com.ou;
table DemoEntity {
flag:byte;
name: string;
}
root_type DemoEntity;
接下来,使用flatc编译,生成java端可用的代码,DemoEntity.kt
css
flatc.exe --kotlin .\DemoEntity.fbs
现在就可以在代码中使用FlatBuffers了
scss
fun testFlatBuffer() {
//序列化
val fb = FlatBufferBuilder(0)
val nameIdx = fb.createString("cat")
val demoIdx = DemoEntity.createDemoEntity(fb, 1, nameIdx)
fb.finish(demoIdx)
//反序列化
val demoArr = fb.sizedByteArray()
val demo = DemoEntity.getRootAsDemoEntity(ByteBuffer.wrap(demoArr))
println(demo.name)
println(demo.flag)
}