Packable-高效易用的序列化框架

一、前言

當我們需要對一些信息進行存儲或者傳輸時,通常需要用一種數據協議,將信息轉換爲可存儲或傳輸的形式(二進制字節流、經過編碼的文本等)。
特別地,當數據源是對象時,轉化對象的過程被稱爲“序列化”,反之,從編碼數據轉化爲對象的過程被稱爲“反序列化”

轉換爲文本的協議,最常用的是XML和json。
XML協議擅長描述,用於構建網頁文檔,Android的頁面搭建等效果不錯,其缺點是解析效率一般
JSON協議具備較好的可讀性,解析效率也不錯,面向閱讀和麪向機器都比較友好,在數據協議的選型時,通常會被優先選用。

二進制的數據協議,多如牛毛,不可勝數。
以使用得比較廣泛的protobuf來說,相對於json協議的各種實現,protobuf在效率和編碼體積方面有一些優勢,但在易用性方面相差太多。
筆者也比較了下一些其他的字節流的序列化協議,都存在着各種不足,相對於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]的處理是類似的,看數值的有效位決定需要編碼多少字節。
    新協議中,整數在[128,255]區間仍可以用1個字節編碼,而varint編碼則需要兩個字節;
    向上可以依此類推,極端地,varint編碼表示long最多需要10字節,而新協議中最壞的情況下仍舊是8個字節表示value。
    並且,直接讀寫int/long比varint編碼效率更高。
  • 3、當字段爲可變對象(字符串,數組,對象)時,長度也不用varint編碼,因爲從type中就知道用多少字節存儲“lenght"。

新協議充分利用了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、讀取tag
  • 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() {
    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();
}

除了以上用法,還有更多精細化的用法,項目中有各種用法的 demo, 這裏就不一一舉例了。

六、性能測試

除了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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章