专栏首页呼啸长风的专栏Packable-高效易用的序列化框架
原创

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定义和作用如下:

Type

Meaning

User For

0

TYPE_0

0,空对象

1

TYPE_NUM_8

boolean, byte, short, int, long

2

TYPE_NUM_16

short, int, long

3

TYPE_NUM_32

int, long, float

4

TYPE_NUM_64

long, double

5

TYPE_VAR_8

长度在1,255的可变对象

6

TYPE_VAR_16

长度在256, 65535的可变对象

7

TYPE_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,对应各种类型所取的值。

bits

0

1

2

3

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、写入类型 由于index和type所在比特位不同,所以用"|"操作追加即可; 当value为0时,type=0,所以不需要特别写入。
  • 4、写入value 如上举例的是写入int, 根据value的大小写入对应的字节。 比如,假如value < 256, 在只需写入一个字节。

编码其他基础类型大体步骤如上。

编码对象则相对复杂一些。

首先,定义编码接口,需要序列化的对象实现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前就知道需要占用多少字节,那里用得上TYPE_VAR_16。

大部分框架在实现编码时需要先填充值到容器中,然后再执行编码时遍历容器,编码各节点到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|index)
  • 2、分解 type 和 index
  • 3、根据 type 读取对应的值 读取的值会缓存到infoArrayindex, 其中,如果是基本类型,可以直接将value填入infoArray中,高位补0; 如果是可变长类型,则将offset额length拼凑成long, 再填入infoArray中。
  • 4、调用get方法时读取值 读取基本类型时,直接读取infoArrayindex; 读取可变长类型时,拆解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() {
    Data data = new Data();

    // 编码
    PackEncoder encoder = new PackEncoder();
    encoder.putString(0, data.msg);
    encoder.putPackableArray(1, data.items);
    byte[] bytes = encoder.getBytes();

    // 解码
    PackDecoder decoder = PackDecoder.newInstance(bytes);
    Data data_2 = new Data();
    data_2.msg = decoder.getString(0);
    data_2.items = decoder.getPackableArray(1, Item.CREATOR);
    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平台)来做下比较。

数据定义如下:

enum Result {
    SUCCESS = 0;
    FAILED_1 = 1;
    FAILED_2 = 2;
    FAILED_3 = 3;
}

message Category {  
    string name = 1;
    int32 level = 2;
    int64 i_column = 3;
    double d_column = 4;
    optional string des = 5;
    repeated Category sub_category = 6;
} 

message Data {  
    bool d_bool  = 1;
    float d_float = 2;
    double d_double = 3;
    string string_1 = 4;
    int32 int_1 = 5;
    int32 int_2 = 6;
    int32 int_3 = 7;
    sint32 int_4 = 8;
    sfixed32 int_5 = 9;
    int64 long_1 = 10;
    int64 long_2 = 11;
    int64 long_3 = 12;
    sint64 long_4 = 13;
    sfixed64 long_5 = 14;
    Category d_categroy = 15;
    repeated bool bool_array = 16;
    repeated int32 int_array = 17;
    repeated int64 long_array  = 18;
    repeated float float_array = 19;
    repeated double double_array = 20;
    repeated string string_array = 21;
}

message Response {                 
    Result code = 1;
    string detail = 2;
    repeated Data data = 3;
}

三种类型的嵌套,主数据为Data类,声明了多个类型的字段。

测试数据是用按一定的规则随机生成的,测试中控制Data的数量从少到多,各项指标和Data的数量成正相关。

所以这里只展示特定数量(2000个Data)的结果。

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

数据大小(byte)

packable

2537191 (57%)

protobuf

2614001 (59%)

gson

4407901 (100%)

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

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

  1. Macbook Pro

序列化耗时 (ms)

反序列化耗时(ms)

packable

9

8

protobuf

19

11

gson

67

46

  1. 荣耀20S

序列化耗时 (ms)

反序列化耗时(ms)

packable

32

21

protobuf

81

38

gson

190

128

  • packable比protobuf快不少,比gson快很多;
  • 以上测试结果是先各跑几轮编解码之后再执行的测试,如果只跑一次的话都会比如上结果慢(JIT优化等因素所致),但对比的结果是一致的。

需要说明的是,数据特征,测试平台等因素都会影响结果,以上测试结果仅供参考。

大家可自行用自己的业务数据对比一下。

七、总结

通常而言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等版本,协议是一致的,可以在不同语言平台间相互传输。

支持的语言数量不如protobuf,毕竟一个人精力有限,欢迎感兴趣的朋友参与项目。

项目地址:https://github.com/BillyWei001/Packable

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • rpc框架之 avro 学习 2 - 高效的序列化

    同一类框架,后出现的总会吸收之前框架的优点,然后加以改进,avro在序列化方面相对thrift就是一个很好的例子。借用Apache Avro 与 Thrift ...

    菩提树下的杨过
  • 高效的序列化/反序列化数据方式 Protobuf

    上篇文章中其实已经讲过了 encode 的过程,这篇文章以 golang 为例,从代码实现的层面讲讲序列化和反序列化的过程。

    一缕殇流化隐半边冰霜
  • 在 WCF 中使用高效的 BinaryFormatter 序列化

    本文将定义一个 WCF 终结点行为扩展,以在 WCF 中使用更高效的 BinaryFormatter 进行二进制序列化,并实现对是否使用传统二进制序列化功能的可...

    用户1172223
  • 使用easyjson提高序列化传输的效率

    easyjson 是用来快速进行json序列化与反序列化的工具包,通过给我们要进行序列化的struct生成方法来实现不通过反射进行json序列化,比golang...

    Johns
  • Cocos Creator 性能优化:DrawCall

    在游戏开发中,DrawCall 作为一个非常重要的性能指标,直接影响游戏的整体性能表现。

    陈皮皮
  • 连“捉阔”是什么都不知道就不要混了!如何优化看这里!

    在游戏开发中,DrawCall 作为一个非常重要的性能指标,直接影响游戏的整体性能表现。

    张晓衡
  • Kafka 中使用 Avro 序列化框架(二):使用 Twitter 的 Bijection 类库实现 avro 的序列化与反序列化

    使用传统的 avro API 自定义序列化类和反序列化类比较麻烦,需要根据 schema 生成实体类,需要调用 avro 的 API 实现 对象到 byte[]...

    CoderJed
  • 序列化框架的选型和比对

    大白话介绍下 RPC 中序列化的概念,可以简单理解为对象 –> 字节的过程,同理,反序列化则是相反的过程。

    用户5325874
  • Kafka 中使用 Avro 序列化框架(一):使用传统的 avro API 自定义序列化类和反序列化类

    关于 avro 的 maven 工程的搭建以及 avro 的入门知识,可以参考: Apache Avro 入门

    CoderJed
  • 多边形裁剪图片升级啦!Cocos Creator !

    相比mask组件,这种meshRenderer的实现可以降低两个draw call。

    白玉无冰
  • 序列化与反序列化核心用法-JSON框架Jackson精解第一篇

    Jackson是Spring Boot默认的JSON数据处理框架,但是其并不依赖于任何的Spring 库。有的小伙伴以为Jackson只能在Spring框架...

    字母哥博客
  • Netty中序列化框架MessagePack的简单实现

      MessagePack是一个高效的二进制序列化框架,它像JSON一样支持不同语言间的数据交换,但是它的性能更快,序列化之后的码流也更小。MessagePac...

    用户4919348
  • Netty中序列化框架Protobuf的简单实现

      Protobuf是一种平台无关、语言无关、可扩展且轻便高效的序列化数据结构的协议,可以用于网络通信和数据存储。

    用户4919348
  • 推荐一款轻量化、易用的css框架bulma.css

    最大的特点:简单好用 提供二十多种常用组件,比如表单 、表格、图标、面包屑、菜单、导航、Modal 窗口等等。简单的网站,可以不用写任何 CSS 代码。

    薛定喵君
  • 404星链计划 | ysomap : Java反序列化利用框架

    Ysomap是一款适配于各类实际复杂环境的Java反序列化利用框架,可动态配置具备不同执行效果的Java反序列化利用链payload。

    Seebug漏洞平台
  • 如何让SpringMVC框架使用我们封装的JsonUtils实现消息的序列化和反序列化

    spring mvc默认使用的json序列化和反序列工具是jackson,虽然我们项目中也是默认使用jackson,但由于一些历史项目存在日期格式不统一问题,我...

    Java艺术
  • DRF框架(三)—— 响应模块(Response)、三大序列化组件介绍、Serializer组件(序列化与反序列化使用)

    1.使用序列化器的时候一定要注意,序列化器声明了以后,不会自动执行,需要我们在视图中进行调用才可以 2.序列化器无法直接接收数据,需要我们在视图中创建序列化器...

    一天不写程序难受
  • AFNetworking框架分析(四)——请求的序列化AFURLRequestSerialization分析

    之前用了两篇篇幅分析了下AFN的核心类AFURLSessionManager在网络请求之前、请求中、以及请求结束时,做了哪些工作。接下来,将用两篇文章的篇幅来分...

    我只不过是出来写写代码
  • AFNetworking框架分析(五)——响应的序列化AFURLResponseSerialization分析

    这一篇将分析网络请求收到数据时的响应AFURLResponseSerialization序列化过程。 当AFURLRequestSerialization类将...

    我只不过是出来写写代码

扫码关注云+社区

领取腾讯云代金券