天天看點

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自身聲明的“類型”,用于告知程式如何編碼/解碼。

取值如下:

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

比方說.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) // 解碼           
Packable-高效易用的序列化架構

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
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
int - [0,7] [0,15] [0,31]
long [0,63]
double [48-63] [32,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)
9 8
19 11
67 46
  1. 榮耀20S
32 21
81 38
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

繼續閱讀