一、前言
當我們需要對一些信息進行存儲或者傳輸時,通常需要用一種數據協議,將信息轉換爲可存儲或傳輸的形式(二進制字節流、經過編碼的文本等)。
特別地,當數據源是對象時,轉化對象的過程被稱爲“序列化”,反之,從編碼數據轉化爲對象的過程被稱爲“反序列化”。
轉換爲文本的協議,最常用的是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和手機上測試了兩組數據:
- Macbook Pro
序列化耗時 (ms) | 反序列化耗時(ms) | |
---|---|---|
packable | 9 | 8 |
protobuf | 19 | 11 |
gson | 67 | 46 |
- 榮耀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,畢竟一個人精力有限,歡迎感興趣的朋友參與項目。