一、前言
當我們需要對一些資訊進行存儲或者傳輸時,通常需要用一種資料協定,将資訊轉換為可存儲或傳輸的形式(二進制位元組流、經過編碼的文本等)。
特别地,當資料源是對象時,轉化對象的過程被稱為序列化,反之,從編碼資料轉化為對象的過程被稱為反序列化。
轉換為文本的協定,最常用的是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 |
---|---|---|
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和手機上測試了兩組資料:
- Macbook Pro
序列化耗時 (ms) | 反序列化耗時(ms) | |
---|---|---|
9 | 8 | |
19 | 11 | |
67 | 46 |
- 榮耀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