阅读 612

Packable-高效易用的序列化方案

一、前言

当我们需要对一些信息进行存储或者传输时,通常需要用一种数据协议,将信息转换为可存储或传输的形式(二进制字节流、经过编码的文本等)。
特别地,当数据源是对象时,转化对象的过程被称为序列化,反之,从编码数据转化为对象的过程被称为反序列化
而协议本身,有的地方称之为数据交换格式(data interchange format)
各类“数据交换格式”在计算机领域被广泛应用,从某些角度看,可以说是“基石”之一。

转换为文本的协议,最常用的是XML和json。
XML协议擅长描述,用于构建网页文档,Android的页面搭建等效果不错,其缺点是解析效率一般。
JSON协议具备较好的可读性,解析效率也不错,面向阅读和面向机器都比较友好,在数据协议的选型时,通常会被优先选用。

通常而言,一些实现得比较好的二进制协议的方案,相对于xml/json协议的各种实现,在效率和编码体积方面有一定优势。
当json协议性能不能满足需求时,大家会转而考虑二进制的数据协议。
而二进制的数据协议,多如牛毛,不可胜数(protobuf, protostuff, thrift, msgpack, avro ...), 挑花了眼, 然后发现在易用性方面和json差太多...

在性能和易用性方面,其实有很多空间。
在查了各种资料,耗费了许多时日之后,终于实现了一种既高效又易用的序列化方案。
目前给方案取名:Packable

本文分了几章介绍Packable:

  • 第2、3章:协议设计;
  • 第4章: 简单介绍实现;
  • 第5章:使用方法;
  • 第6章:性能测试;
  • 第7章:回顾总结。

设计和实现部分(2、3、4章)会比较晦涩,如果之前没有了解过protobuf等协议的原理/实现,光看文章的话阅读体验很差,建议先跳过;
看了使用方法和性能测试部分,如果觉得感兴趣,再回头看;
平时喜欢阅读源码,喜欢各种源码分析的朋友,可以跑一下代码,结合源码看会更有阅读体验。

二、Protobuf协议

在调研了各种二进制协议之后,最终选择参考protobuf协议来实现方案。
虽然protobuf有不少缺点,但其中也包含了一些不错的设计技巧,值得借鉴。

2.1 构型

序列化协议要想支持向前兼容和向后兼容,基本构型都是:

[key value key value ....]
复制代码

C/C++的结构体,Android的Parcel等倒是没有key,而是直接依次存取value, 但这样的话就不能版本兼容和跨平台了。
然后value可能是基础数据类型,也可能是复合对象,最终,整个构成一棵“对象树”。

2.2 数据布局

json协议是通过特定符号来分隔key/value,解析时需要找到符号对(引号,括号)来确定数据的边界;
而protobuf则是通过type和lenght来确定数据边界,从而在解析时只需前序深度遍历即可。
还有就是,由于不需要分隔符,所以不需要对特定符号转义编码,这也是相对于xml/json等效率更好的原因之一。

Protobuf的字段布局如下:

<index> <type> [length] <data>
复制代码
  • index是在.proto文件声明的编号;
  • type并不是具体语言平台的“类型”,而是proto自身声明的“类型”,用于告知程序如何编码/解码。

取值如下:

比方说.proto文件中声明fixed32或者float, 编码时type皆为5(二进制的101,占3bit)。
真正的语言层面的“类型”,在编译阶段决定, 可以是int类型,也可以是float类型。
其实json也是如此,例如{"number":100}, number是int、long、float还是double,得看怎么去读取。

  • lenght:数据长度,当value是字符串,数组或者嵌套对象时,才会有length; 基础类型不需要length,因为基础类型的length是可知的。
  • data: value的数据本身。

举例:

message Result {
    int32 count = 1;
}

message Data {
    string msg = 1;
    Result result = 2;
}
复制代码
{
    "msg":"abc",
    "result":{
        "count":1
    }
}
复制代码
|00001|010|00000011|'a' 'b' 'c'|00010|010|00000010|00001|000|00000100|
+-----+---+--------+-----------+-----+---+--------+-----+---+--------+
 index type length    data      index type length  index type  data
                                                  |<-------count---->|
|<------------ msg ----------->|<------------- result -------------->|
复制代码

type最大取值为5,用3bit即可表示,所可以联合index编码;
在protobuf协议中,(index|type)、lenght、以及当type=0时的data,都是用varint编码的。

2.3 编码

2.3.1 varint

顾名思义,“可变的整数”,用可变长编码表示整数。
4字节的varint的表示方式如下:

   0 ~ 2^07 - 1 0xxxxxxx
2^07 ~ 2^14 - 1 1xxxxxxx 0xxxxxxx
2^14 ~ 2^21 - 1 1xxxxxxx 1xxxxxxx 0xxxxxxx
2^21 ~ 2^28 - 1 1xxxxxxx 1xxxxxxx 1xxxxxxx 0xxxxxxx
2^28 ~ 2^35 - 1 1xxxxxxx 1xxxxxxx 1xxxxxxx 1xxxxxxx 0xxxxxxx
复制代码

8字节的varint以此类推。
varint编码在较小的正整数通常能节约空间,比如在[0,127]区间的整数可以用一个字节表示,但是在表示较大的整数时节约不了空间,在表示负数时甚至比会占用更多空间(int占5字节,long占10字节)。

2.3.2 zigzag

负数的最高位是“1”,所以varint编码负数会占用更大的空间,为了解决这个问题,protobuf引入zigzag编码。
其运算规则如下:

(n << 1) ^ (n >> 31) // 编码
(n >>> 1) ^ -(n & 1) // 解码
复制代码

zigzag编码后,数值变为“正整数”,按绝对值排序(原来是正数的排在原来是负数的后面)。
如此,对于一些绝对值小的负数,先经过zigzag编码,再进行varint编码时,编码长度比较短。
但对于绝对值本来就较大的整数,zigzag编码对空间占用并无帮助,甚至适得其反。
当proto文件中字段声明为sint32或者sint64时,该字段会启用zigzag编码。

2.3.3 字符串编码

protobuf对字符串统一使用utf-8编码。

2.3.4 大端小端

当type=1或者type=5, 使用固定长度,小端字节序。

三、Packable协议设计

3.1 基本编码规则

packable参考protobuf, 构型也是 :

[key value key value ....]
复制代码

但数据布局有所区别:

<flag> <type> <index> [length] [data]
复制代码
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  flag  | type  |    index    |            value           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  1bit  | 3bit  |   4~12 bit  |                            |
复制代码

和protobuf的区别在于:
1、packable的index从0开始,而protobuf从1开始;
2、不用varint去编码index和type,而是固定用一到两个字节编码;
3、value可以不存在(当type=0时)。

当index∈[0,15]时,flag=0, [flag|type|index]用一个字节表示;
当index∈[16,255]时,flag=1 [flag|type|0000]为第一个字节,index独占第二个字节。
目前暂不支持大于255的index, 事实上一个对象也没多么字段,后面真的用上的话,再拓展第一个字节的低4bit即可。
虽然布局不一样,但是效用是相似的,都是在15以内占一个字节,大于15占两个字节(Protobuf支持index的范围更大,但是通常用不到这么多)。
为什么不用varint来编码type和index呢?哈哈,既然都重新设计了,怎么方便实现就怎么来吧。

然后就是,packable的type和protobuf的定义和作用有所不同。
protobuf的type也是占用3bit, 3bit可以表示8个定义, 但并没利用起来;事实上protobuf本可用2bit来表示type(只有varint、32-bit、64-bit、Length-delimited)四种定义。

packable的Type定义和作用如下:

TypeMeaningUser For
0TYPE_00,空对象
1TYPE_NUM_8boolean, byte, short, int, long
2TYPE_NUM_16short, int, long
3TYPE_NUM_32int, long, float
4TYPE_NUM_64long, double
5TYPE_VAR_8长度在[1,255]的可变对象
6TYPE_VAR_16长度在[256, 65535]的可变对象
7TYPE_VAR_32长度大于65535的可变对象

1、一个对象有时候有很多未赋值的字段,通常默认值是0,空字符串等,可将这类值的type设为0,而lenght和value字段不需要填充。
在此情况下,相比于protobuf的varint和Length-delimited能节省1各子节,相比于protobuf的32-bit和64-bit分别节省4和8字节。

2、packable整数类型不用varint编码,因为在type中定义好了存放了多少个字节。
比如一个long类型的变量,如果其值在[1,255], 编码时将其type设为1, 解码时只读取1个字节。
type∈[1,4]的处理是类似的,看数值的有效位决定需要编码多少字节。
packable的整数在[128,255]区间仍可以用1个字节编码,而varint编码则需要两个字节;
向上可以依此类推,极端地,varint编码表示long最多需要10字节,而packable在最坏的情况下也只需8个字节。
并且,直接读写int/long比varint编码效率更高。

3、当字段为可变对象(字符串,数组,对象)时,长度也不用varint编码,因为从type中就知道用多少字节存储“lenght"。

packable充分利用了type的表示空间,从而节省编码空间和计算时间。

3.2 数组的编码

为简化描述,我们约定

key = <flag> <type> <index>
复制代码

3.2.1 基础类型数组

基础类型的数据布局:

<key> [length] [v1 v2 ...]
复制代码
  • 数组元素依此按小端编码;
  • 由于基础数据类型的长度是固定的,所以解码时读取长度之后,除以基础类型的字节数即可得出元素个数。

比如,如果是int/float数组,则size = length / 4。

3.2.2 字符串数组

<key> [length] [size] [len1 v1 len2 v2 ...]
复制代码
  • 由于字符串长度不固定,所以需要编码size.这里用varint去编码size,因为size是正整数(字符串非空时),而且通常比较小,用varint编码能节约空间。
  • 如果数组元素个数为0,则type=0, 此时不需要编码value部分。
  • 字符串的编码由“长度+内容”构成,其中“内容”是可省略的(当字符串为空字符串或者null时)。
  • 当字符串为null时,len=-1。
  • 数组的length从key中的type可以得知本身占多少字节;而字符串的len没有额外信息表示自身占多少字节,为此,len也采用varint编码(一般字符串不会太长,尤其是数组中的字符串,用varint编码可节约空间)。

3.2.3 对象数组

<key> [length] [size] [len1 v1 len2 v2 ...]
复制代码

对象数组和字符串数组的数据布局一样,
只是len的编码规则不同:

  • 当对象为null时,len=0xFFFF;
  • len<=0x7FFF时, len用两个字节编码;
  • 当len>0x7FFF时,len用4个字节编码。

为什么不和字符串一样用varint编码呢?
主要是基于实现的层面考虑: 编码对象之前不知道对象需要占用多少个字节,用varint编码的话,不知道要预留给多少空间给len,大概率会预留不准;然后当写入value完成之后,大概率需要移动字节,以便给len预留准确的空间,这样效率就低了。
所以,直接预留两个字节,可以确保长度在32767之内的对象编码写入buffer后不需要移动,以提高效率;
当长度大于32767, 需要向后移动两个字节,而这么长的对象,编码的时间本身就不少,相比而言移动字节的时间占比就低了。

3.2.4 字典

存储key-value对的数据结构,有的编程语言中叫Dictionary,有的叫Map, 是同一个东西。
编码时可以视之为 key-value 的数组:

<key> [length] [size] [k1 v1 k2 v2 ...]
复制代码

key或value的有各种类型,为基础数据类型时,直接固定长度编码,为可变长类型时,按照可变长类型数组的规则编码。

3.3 压缩编码

对于某些具备特定的特征的数值,可以添加某些编码规则,达到节省空间的目的。
需要声明的是,接下来的这些方法,不一定能”压缩“,仅当符合特征时有效。

3.3.1 zigzag

zigzag编码前面介绍过,packable也保留这个选项。

public PackEncoder putSInt(int index, int value) {
    return putInt(index, (value << 1) ^ (value >> 31));
}
复制代码

其实就是在putInt之前加一个编码。
建议仅当数值包含绝对值较小负数才启用此方法,一般情况下直接使用putInt即可。

3.3.2 double类型

关于浮点数的二进制的表示方法,如果要讲可以抽出一篇来讲,考虑篇幅和主题,本篇就不细述了。
直接说结论:

  • 1、 double类型占8个字节
  • 2、 对于一些能够以较少的2^n组合而成的数值,后面的字节都是0。
    n可正可负,n为负数时,十进制形式有“小数”,例如, 2^-1=0.5, 2^-2=0.25。
  • 3、更普适一点的结论:对于绝对值小于等于2^21(2097152)的整数,后四个字节都是0。

下面是举例一些数值,方面直观感受:

a:-2.0    1 1000000-0000 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:-1.0    1 0111111-1111 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:0.0     0 0000000-0000 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:0.5     0 0111111-1110 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:1.0     0 0111111-1111 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:1.5     0 0111111-1111 1000-00000000-00000000-00000000-00000000-00000000-00000000
a:2.0     0 1000000-0000 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:3.98    0 1000000-0000 1111-11010111-00001010-00111101-01110000-10100011-11010111
a:31.0    0 1000000-0011 1111-00000000-00000000-00000000-00000000-00000000-00000000
a:32.0    0 1000000-0100 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:33.0    0 1000000-0100 0000-10000000-00000000-00000000-00000000-00000000-00000000
a:1999.0   0 1000000-1001 1111-00111100-00000000-00000000-00000000-00000000-00000000
a:3999.0   0 1000000-1010 1111-00111110-00000000-00000000-00000000-00000000-00000000
a:2097151.0    0 1000001-0011 1111-11111111-11111111-00000000-00000000-00000000-00000000
a:2097152.0    0 1000001-0100 0000-00000000-00000000-00000000-00000000-00000000-00000000
a:2097153.0    0 1000001-0100 0000-00000000-00000000-10000000-00000000-00000000-00000000
复制代码

第三点结论比较有价值:
如果字段是double类型,但是通常情况下是整数(比方说商品价格,而商品又是整数价格居多),那么是有压缩空间的。
packable提供了double类型的压缩选项,启用时,编码过程为:
1、将double转为long;
2、调换低位的四个字节和高位的四个字节;
3、按照long的编码方式编码(long类型编码时,如果高位的四个字节是0,会用只编码低位的4个字节)。
如此,对于符合条件的double类型数据,能够节约4个字节。

3.3.3 bool数组

对于bool数组来说,如果用一个字节编码一个bool值,那太浪费了;其实很容易想到,一个字节可以编码8个bool值。
因为数组大小不一定是8的倍数,所以需要额外信息记录数组大小。
一个方案是像对象数组一样在lenght后记录size, 但是那并不是最有效的;
其实可以记录remain=size%8, 解码的时候结合length和remain可以推算出size。
当size比较大的时候,一个字节表示不了;而remian总小于8,用3bit就可以表示。

3.3.4 枚举数组

当枚举值只能取两种值(比如“是/否”,“可用/不可用”)时,可以用一个bit编码一个值;
当枚举值取值为[0,3]时,可以用2bit编码一个值。
依次类推……
当然,如果枚举值大于255,则直接用int编码就好了。
当枚举值小于等于255时,可以用一个字节编码一个或者多个值。
数据布局bool数组类似:

<key> [length] [remain] [v1 v2  ...]
复制代码

3.3.5 int/long/double数组

int/long/double作为单个字段,因为type可以记录占用几个字节的信息,所以可以压缩;
而作为数组的元素,是否可以压缩呢?
每个值用额外的2比特记录占用多少字节即可。
2比特可以表示4种情况,下面是2比特从0到4,对应各种类型所取的值。

bits0123
int-[0,7][0,15][0,31]
long-[0,7][0,15][0,63]
double-[48-63][32,63][0,63]

int和long都是从低位开始取值,因为当值比较小时高位为0;
而double由于符号为和阶码在高位,所以从从高位取值,比如对于1, 1.5, 2等值,[16,63]的比特皆为0,所以只需记录高位的2个字节即可。
如果值是0,则只用记录bits皆可,不需要再编码value了。

压缩数组数据布局如下:

<key> [length] [size] [bits] [v1 v2  ...]
复制代码

size用varint编码;额外的bits跟随在size后,每个值占用2bit; 然后后面的数组根据自己是否可以压缩而决定要占用多少子节。
这种策略不一定有压缩效果,也是要视数组本身而定,通常当大部分元素都比较小时又较好的压缩效果;
极端情况,数组所有元素皆为0,则[v1 v2 ...]部分为空,每个元素只占2bit。

如果需要传输一张数据表的数据,不妨以“列”的方式来组装数据,这样编解码更快;
对于稀疏的字段(多数情况下为0),或者字段的值比较小,建议采用压缩策略。

四、框架实现

限于篇幅,本篇只大概讲一下关键过程,更多细节大家可看源码了解。

4.1 定义类型

回顾上一节,packable的type占用3个bit, 字节的最高的bit用来表示index写在剩余的4bit还是下一个字节。

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  flag  | type  |    index    |            value           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  1bit  | 3bit  |   4~12 bit  |                            |
复制代码

为此,定义常量如下:

final class TagFormat {
    private static final byte TYPE_SHIFT = 4;
    static final byte BIG_INDEX_MASK = (byte) (1 << 7);
    static final byte TYPE_MASK = 7 << TYPE_SHIFT;
    static final byte INDEX_MASK = 0xF;
    static final int LITTLE_INDEX_BOUND = 1 << TYPE_SHIFT;

    static final byte TYPE_0 = 0;
    static final byte TYPE_NUM_8 = 1 << TYPE_SHIFT;
    static final byte TYPE_NUM_16 = 2 << TYPE_SHIFT;
    static final byte TYPE_NUM_32 = 3 << TYPE_SHIFT;
    static final byte TYPE_NUM_64 = 4 << TYPE_SHIFT;
    static final byte TYPE_VAR_8 = 5 << TYPE_SHIFT;
    static final byte TYPE_VAR_16 = 6 << TYPE_SHIFT;
    static final byte TYPE_VAR_32 = 7 << TYPE_SHIFT;
}
复制代码

4.2 实现Buffer类

public final class EncodeBuffer {
    byte[] hb;
    int position;

    public void writeInt(int v) {
        hb[position++] = (byte) v;
        hb[position++] = (byte) (v >> 8);
        hb[position++] = (byte) (v >> 16);
        hb[position++] = (byte) (v >> 24);
    }
    // ...
}
复制代码

Buffer类只需提供基本类型的编码方法即可,buffer扩容由调用者实现。
因为有时候需要连续写入多个值,调用处统一判断扩容,比每次调用Buffer接口都做判断划算。

4.3 实现编码

public final class PackEncoder {
    private final EncodeBuffer buffer;

    final void putIndex(int index) {
        if (index >= TagFormat.LITTLE_INDEX_BOUND) {
            buffer.writeByte(TagFormat.BIG_INDEX_MASK);
        }
        buffer.writeByte((byte) (index));
    }

    public PackEncoder putInt(int index, int value) {
        checkCapacity(6); // 检查buffer容量
        if (value == 0) {
            putIndex(index);
        } else {
            int pos = buffer.position;
            putIndex(index);
            if ((value >> 8) == 0) {
                buffer.hb[pos] |= TagFormat.TYPE_NUM_8;
                buffer.writeByte((byte) value);
            } else if ((value >> 16) == 0) {
                buffer.hb[pos] |= TagFormat.TYPE_NUM_16;
                buffer.writeShort((short) value);
            } else {
                buffer.hb[pos] |= TagFormat.TYPE_NUM_32;
                buffer.writeInt(value);
            }
        }
        return this;
    }
}
复制代码

编码方法的实现步骤:

  • 1、检查buffer容量,容量不足则扩容
  • 2、写入index
  • 3、写入type
    由于index和type所在比特位不同,所以用"|"操作追加即可;
    当value为0时,type=0,所以不需要特别写入。
  • 4、写入value
    如上举例的是写入int, 根据value的大小写入对应的字节。
    比如,假如value < 256, 在只需写入一个字节。
    编码其它基础类型大体步骤类似。

编码对象则相对复杂一些。
需要序列化的对象实现Packable的encode方法,用PackEncoder写入对象的字段。
如果对象的字段中又有对象,那个对象也实现Packable即可(编码时会递归调用)。

public interface Packable {
    void encode(PackEncoder encoder);
}
复制代码

具体编码对象过程如下:

    public PackEncoder putPackable(int index, Packable value) {
        if (value == null) {
            return this;
        }
        checkCapacity(6);
        int pTag = buffer.position;
        putIndex(index);
        // 预留 4 字节,用来存放length
        buffer.position += 4;
        int pValue = buffer.position;
        value.encode(this);
        if (pValue == buffer.position) {
            buffer.position -= 4; // value为空对象,回收预留空间
        } else {
            putLen(pTag, pValue);
        }
        return this;
    }

    private void putLen(int pTag, int pValue) {
        int len = buffer.position - pValue;
        if (len <= 127) {
            buffer.hb[pTag] |= TagFormat.TYPE_VAR_8;
            buffer.hb[pValue - 4] = (byte) len;
            System.arraycopy(buffer.hb, pValue, buffer.hb, pValue - 3, len);
            buffer.position -= 3;
        } else {
            buffer.hb[pTag] |= TagFormat.TYPE_VAR_32;
            buffer.writeInt(pValue - 4, len);
        }
    }
复制代码

和编码基础类型的步骤类似,只是写入type要后置,因为写入策略是先编码value,结束之后写入value的长度,以及type。
为了避免过多的字节移动,仅当value长度小于127时做compact操作(移动字节,压缩空间)。
那TYPE_VAR_16岂不是用不上了?
编码数组或字符串的时能用上,因为写入buffer前就知道需要占用多少字节,不需要像写入对象一样先预留length的空间。

大部分框架在实现编码时需要先填充值到容器中,然后在执行编码时遍历容器,编码各节点到buffer中。
像protobuf的java实现,写入一个对象,需要先遍历每个字段,计算总共占用多少空间,然后写入length, 然后再写入value。如此,对象的每一个字段都要访问两遍。
而packable的写入策略则是调用put方法时即刻写入,这样只需要访问一次各个字段;
虽然编码一些小对象时需要compact操作,但由于需要移动的字节数不多,而且考虑到空间局部性,总体效率还是可以的。
最重要的是,这样的策略编码实现简单!
计算每个字段占用空间,需要多出很多代码,执行效率也大打折扣。

4.4 实现解码

public interface PackCreator<T> {
    T decode(PackDecoder decoder);
}

public final class PackDecoder {
    static final long NULL_FLAG = ~0;
    static final long INT_MASK = 0xffffffffL;

    private DecodeBuffer buffer;
    private long[] infoArray;
    private int maxIndex = -1;

    private void parseBuffer() {
        // ... 初始化代码 ...
        while (buffer.hasRemaining()) {
            byte tag = buffer.readByte();
            int index = (tag & TagFormat.BIG_INDEX_MASK) == 0 ? tag & TagFormat.INDEX_MASK : buffer.readByte() & 0xff;
            if (index > maxIndex)  maxIndex = index;
            byte type = (byte) (tag & TagFormat.TYPE_MASK);
            if (type <= TagFormat.TYPE_NUM_64) {
                if (type == TagFormat.TYPE_0) {
                    infoArray[index] = 0L;
                } else if (type == TagFormat.TYPE_NUM_8) {
                    infoArray[index] = ((long) buffer.readByte()) & 0xffL;
                } else if (type == TagFormat.TYPE_NUM_16) {
                    infoArray[index] = ((long) buffer.readShort()) & 0xffffL;
                } else if (type == TagFormat.TYPE_NUM_32) {
                    infoArray[index] = ((long) buffer.readInt()) & 0xffffffffL;
                } else {
                    // TYPE_NUM_64的处理相对复杂一些,此处省略 ...
                }
            } else {
                int size;
                if (type == TagFormat.TYPE_VAR_8) {
                    size = buffer.readByte() & 0xff;
                } else if (type == TagFormat.TYPE_VAR_16) {
                    size = buffer.readShort() & 0xffff;
                } else {
                    size = buffer.readInt();
                }
                infoArray[index] = ((long) buffer.position << 32) | (long) size;
                buffer.position += size;
            }
        }
        // 函数结束时,infoArray记录了各index对应的值、或者位置、长度等信息
        // 没有赋值的且下标小于maxIndex的,infoArray[i] = NULL_FLAG
    }

    long getInfo(int index) {
        if (maxIndex < 0) {
            parseBuffer();
        }
        if (index > maxIndex) {
            return NULL_FLAG;
        }
        return infoArray[index];
    }

    public int getInt(int index, int defValue) {
        long info = getInfo(index);
        return info == NULL_FLAG ? defValue : (int) info;
    }

    public <T> T getPackable(int index, PackCreator<T> creator, T defValue) {
        long info = getInfo(index);
        if (info == NULL_FLAG) {
            return defValue;
        }
        int offset = (int) (info >>> 32);
        int len = (int) (info & INT_MASK);
        PackDecoder decoder = pool.getDecoder(offset, len);
        T object = creator.decode(decoder);
        decoder.recycle();
        return object;
    }
}
复制代码

解码是编码的反操作,基本操作包括:

  • 1、读取(type|indxe)
  • 2、分解 type 和 index
  • 3、根据 type 读取对应的值
    读取的值会缓存到infoArray[index],
    其中,如果是基本类型,可以直接将value填入infoArray中,高位补0;
    如果是可变长类型,则将offset额length拼凑成long, 再填入infoArray中。
  • 4、调用get方法时读取值
    读取基本类型时,直接读取infoArray[index];
    读取可变长类型时,拆解offset和len, 定位到对应位置,读取指定长度的value。

调用getPackable时,如果Packable对象有类型嵌套,会递归调用decode方法,这和编码时的递归是类似的。

五、用法

5.1 常规用法

序列化/反序列化对象时,实现如上接口,然后调用编码/解码方法即可。
用例如下:

static class Data implements Packable {
    String msg;
    Item[] items;

    @Override
    public void encode(PackEncoder encoder) {
        encoder.putString(0, msg)
                .putPackableArray(1, items);
    }

    public static final PackCreator<Data> CREATOR = decoder -> {
        Data data = new Data();
        data.msg = decoder.getString(0);
        data.items = decoder.getPackableArray(1, Item.CREATOR);
        return data;
    };
}

static class Item implements Packable {
    int a;
    long b;

    Item(int a, long b) {
        this.a = a;
        this.b = b;
    }

    @Override
    public void encode(PackEncoder encoder) {
        encoder.putInt(0, a);
        encoder.putLong(1, b);
    }

    static final PackArrayCreator<Item> CREATOR = new PackArrayCreator<Item>() {
        @Override
        public Item[] newArray(int size) {
            return new Item[size];
        }

        @Override
        public Item decode(PackDecoder decoder) {
            return new Item(
                    decoder.getInt(0),
                    decoder.getLong(1)
            );
        }
    };
}

static void test() {
    Data data = new Data();
    // 序列化
    byte[] bytes = PackEncoder.marshal(data);
    // 反序列化
    Data data_2 = PackDecoder.unmarshal(bytes, Data.CREATOR);
}
复制代码
  • 序列化

1、声明 implements Packable 接口;
2、实现encode()方法,编码各个字段(PackEncoder提供了各种类型的API);
3、调用PackEncoder.marshal()方法,传入对象, 得到字节数组。

  • 反序列化

1、创建一个静态对象,该对象为PackCreator的实例;
2、实现decode()方法,解码各个字段,赋值给对象;
3、调用PackDecoder.unmarshal(), 传入字节数组以及PackCreator实例,得到对象。

如果需要反序列化一个对象数组, 需要创建PackArrayCreator的实例(Java版本如此,其他版本不需要)。
PackArrayCreator继承于PackCreator,多了一个newArray方法,简单地创建对应类型对象数组返回即可。

5.2 直接编码

上面的举例只是范例之一,具体使用过程中,可以灵活运用。
1、PackCreator不一定要在需要反序列化的类中创建,在其他地方也可以,可任意命名。
2、如果只需要序列化(发送方),则只实现Packable即可,不需要实现PackCreator,反之亦然。
3、如果没有类定义,或者不方便改写类,也可以直接编码/解码。

static void test2() {
    String msg = "message";
    int a = 100;
    int b = 200;

    PackEncoder encoder = new PackEncoder();
    encoder.putString(0, msg)
                .putInt(1, a)
                .putInt(2, b);
    byte[] bytes = encoder.getBytes();

    PackDecoder decoder = PackDecoder.newInstance(bytes);
    String dMsg = decoder.getString(0);
    int dA = decoder.getInt(1);
    int dB = decoder.getInt(2);
    decoder.recycle();
}
复制代码

5.3 自定义编码

比方说下面这样一个类:

class Info  {
    public long id;
    public String name;
    public Rectangle rect;
}
复制代码

Rectangle是JDK的一个类),有四个字段:

class Rectangle {
  int x, y, width, height;
}
复制代码

当然,有很多方案去实现(让Rectangle实现Packable不在其中,因为不能修改JDK)。
packable提供的一种高效(执行效率)的方法:

public static class Info implements Packable {
    public long id;
    public String name;
    public Rectangle rect;

    @Override
    public void encode(PackEncoder encoder) {
        encoder.putLong(0, id)
                .putString(1, name);
        // 返回PackEncoder的buffer
        EncodeBuffer buf = encoder.putCustom(2, 16);     // 4个int, 占16字节
        buf.writeInt(rect.x);
        buf.writeInt(rect.y);
        buf.writeInt(rect.width);
        buf.writeInt(rect.height);
    }

    public static final PackCreator<Info> CREATOR = decoder -> {
        Info info = new Info();
        info.id = decoder.getLong(0);
        info.name = decoder.getString(1);
        DecodeBuffer buf = decoder.getCustom(2);
        if (buf != null) {
            info.rect = new Rectangle(
                    buf.readInt(),
                    buf.readInt(),
                    buf.readInt(),
                    buf.readInt());
        }
        return info;
    };
}
复制代码

通常情况下,大对象嵌套一些固定字段的小对象还是挺常见的。
用此方法,可以减少递归层次,以及减少index的解析,能提升不少效率,

5.4 类型支持

以上是packable的序列化/反序列化的整体用法。
具体到PackEncoder/PackDecoder,又提供了哪些接口呢(支持什么类型)。
以PackEncoder为例,部分接口如下:

  • 基础类型中的putSInt、putSLong和putCDouble是带压缩编码(参考3.3节)。
  • Map的key-value类型组合太多了,所以只实现了部分常用类型,然后留了一个putMap接口提供自定义实现。

六、性能测试

除了protobuf之外,还选择了gson (json协议的序列化框架之一,java平台)来做下比较。

空间方面,序列化后数据大小:

数据大小(byte)
packable2537191 (57%)
protobuf2614001 (59%)
gson4407901 (100%)

packable和protobuf大小相近(packable略小),约为gson的57%。

耗时方面,分别在PC和手机上测试了两组数据:

  1. Macbook Pro
序列化耗时 (ms)反序列化耗时(ms)
packable98
protobuf1911
gson6746
  1. 荣耀20S
序列化耗时 (ms)反序列化耗时(ms)
packable3221
protobuf8138
gson190128

需要说明的是,数据特征,测试平台等因素都会影响结果,以上测试结果仅供参考。
大家可自行用自己的业务数据对比一下。

七、总结

通常而言packable和protobuf性能方面比json的要好,但可读性方面是硬伤。
一种改善可读性的方案:将二进制内容反序列化成Java对象,再用Gson等框架转化为json。

总体而言,packable有以下优点:

  • 1、性能优异
    编码解码速度快;
    编码后的消息提交小。
  • 2、代码轻量
    一方面是包体积,以Java为例,protobuf的jar包接近2M,而packable的jar包只有37K
    另一方面是新增消息类型所需要的代码量,例如前面一节所定义的数据类型,protobuf编译出来的java文件有五千多行,而packable所定义的类文件只有百来行。
  • 3、使用方便
    使用protobuf的过程相对繁琐,需要编写.proto文件、编译成对应语言平台的代码、拷贝到项目中、项目集成SDK……
    如果需要新增字段,需要修改.proto文件,重新编辑,再次拷贝到项目中。
    相对而言,packable可以在现有的对象改造,对于已经定义好的类,实现相关接口即可,相关的实现和调用都不需要变更,
    如果需要增删字段,也只需直接在代码中增删字段即可。
  • 4、方法灵活
    可以单实现序列化的接口(或者反序列化接口);
    除了对象序列化/反序列化,也支持直接编码,自定义编码等。
  • 5、支持各种类型,可变对象支持null类型(protobuf不支持)。
  • 6、支持多种压缩策略

语言支持方面,packable目前实现了Java、C++、C#、Objective-C、Go等版本,协议是一致的,可以在不同语言平台间相互传输。

项目地址:github.com/BillyWei001…

文章分类
后端
文章标签