FlatBuffers使用与原理解析

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();
    }

简单从上述代码看,对字符串的处理分成这几步:

  1. 先保存 '0',作为字符串的结尾
  2. 开始处理数组(字符串当成数组处理)
  3. 保存字符串的每一位
  4. 结束处理数组(字符串当成数组处理)

首先,从写 '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中保存nameoffset

接下来再处理第二个成员变量数据,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中保存flagoffset

值得注意的是,保存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 值计算为4
  • vtable_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)
}