【Protobuf】Protobuf的編解碼規則詳解

在很多很多時候被問起,爲什麼選擇protobuf?最先被想起的回答的就是體積小、解析快。那相比較於json、XML,爲什麼protobuf能夠做到又小又快呢?

歸其原因,這與它的編解碼方式有很大的關係。本文將走進protobuf的深層原理來進行剖析。

本文實例源碼github地址https://github.com/yngzMiao/yngzmiao-blogs/tree/master/2019Q4/20191230


實例

本文針對實際的例子,來對protobuf的編解碼方式進行詳細講解。其中,.proto文件定義如下:

syntax = "proto2";

enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
}

message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2;
}

message Address {
    optional string country = 1;
    optional string detail = 2;
}

message Person {
    required int32 id =1;
    required string name = 2;
    optional int32 age = 3;
    repeated string email = 4;
    repeated PhoneNumber phone = 5;
    optional Address address = 6;
}

保存的person信息爲:

id : 1
name : 'zhangsan'
age : 18
email : ['1.qq.com', '2.qq.com']
phone : [number: "123456", type: HOME, number: "234567", type: MOBILE]
address : country: "China", detail: "Jiangsu"

這段person信息,生成的一段二進制內容爲:

08 01 12 08 7A 68 61 6E 67 73 61 6E 18 12 22 08 31 2E 
71 71 2E 63 6F 6D 22 08 32 2E 71 71 2E 63 6F 6D 2A 0A 
0A 06 31 32 33 34 35 36 10 01 2A 0A 0A 06 32 33 34 35 
36 37 10 00 32 10 0A 05 43 68 69 6E 61 12 07 4A 69 61 
6E 67 73 75

varint和ZigZag

在protobuf中,主要使用的兩種編碼方式就是varintZigZag

varint

varint是一種可變長編碼,使用1個或多個字節對整數進行編碼,可編碼任意大的整數,小整數佔用的字節少,大整數佔用的字節多,如果小整數更頻繁出現,則通過varint可實現壓縮存儲。

varint中每個字節的最高位bit稱之爲most significant bit(MSB),如果該bit爲0意味着這個字節爲表示當前整數的最後一個字節,如果爲1則表示後面還有至少1個字節,可見,varint的終止位置其實是自解釋的

也就是說,每個字節的最高位表示後面還有沒有字節,若爲0表示後面沒有字節,若爲1表示後面有字節。而每個字節的低7位就是實際的值,並且使用小端的表示方法

例如1,varint的表示方法就爲:

0000 0001

,而int本身是4字節的,採用varint的編碼方式就省了三個字節。

再例如300,varint表示爲:

10 0101100                          //300的二進制
10101100 00000010                   //300的varint編碼

,而int本身是4字節的,採用varint的編碼方式就省了兩個字節。

ZigZag

ZigZag編碼:整數壓縮編碼 ZigZag

優秀的壓縮編碼應該滿足:高概率的碼字字長應不長於低概率的碼字字長。而一般情況下,使用較多的是小整數,那麼較小的整數應使用更少的byte來編碼。基於此思想,ZigZag被提出來。

ZigZag按絕對值升序排列,將整數hash成遞增的32位bit流,其hash函數爲h(n)=(n<<1) ^ (n>>31);對應地long類型(64位)的hash函數爲h(n)=(n<<1) ^ (n>>63)。整數的補碼(十六進制)與hash函數的對應關係如下:

n hex h(n) ZigZag(hex)
0 00 00 00 00 00 00 00 00 00
-1 ff ff ff ff 00 00 00 01 01
1 00 00 00 01 00 00 00 02 02
-2 ff ff ff fe 00 00 00 03 03
2 00 00 00 02 00 00 00 04 04
-64 ff ff ff c0 00 00 00 7f 7f
64 00 00 00 40 00 00 00 80 80

可以看出,Zigzag編碼用無符號數來表示有符號數字,正數和負數交錯,這就是zigzag這個詞的含義了。

其實,正數擴大成2倍,負數取反加1

編碼小結

其實varint編碼和ZigZag,都可以編碼正數和負數,那爲什麼protobuf怎麼抉擇的呢?

如果表示的都是正數,varint的方式編碼會比ZigZag編碼小很多;但如果表示的很多都是負數,由於負數的最高位爲1,如果負數也使用varint編碼就會出現一個問題,int32總是需要5個字節,int64總是需要10個字節。此時ZigZag的編碼方式會更恰當。

爲了統一兩種方式,並效仿varint的壓縮優勢,減少ZigZag的字節數。最終,sint32被編碼爲(n<<1) ^ (n>>31)對應的varint,sint64被編碼爲(n<<1) ^ (n>>63)對應的varint,這樣,絕對值較小的整數只需要較少的字節就可以表示。

因此,protobuf對於正數的編碼採用varint,對於負數的編碼採用ZigZag編碼後的varint。

其實,這句話這樣說是不恰當的。因爲,protobuf也無法自動識別正數負數並做出不同的編碼方式的選擇。採用的做法是,在.proto結構定義文件中,如果是int32、int64、uint32、uint64採用varint的方式,如果是sint32、sint64採用ZigZag編碼後的varint的方式


protobuf編解碼

對於序列化後字節流,需要回答的一個重要問題是從哪裏到哪裏是哪個數據成員。

因此message的每個字段field在序列化時,一個field對應一個key-value對,整個二進制文件就是一連串緊密排列的key-value對,key也稱爲tag。採用這種key-value對的結構無需使用分隔符來分割不同的freld。對於可選的field,如果消息中不存在該field,那麼在最終的message中就沒有該field,這些特性都有助於節約消息本身的大小。

key由wire type和FieldNumber兩部分編碼而成,具體地說,key=(field_number<<3)|wire_type,field_number部分指示了當前是哪個數據成員,通過它將cc和h文件中的數據成員與當前的key-value對應起來。也就是.proto文件中每個字段的標識號。

key的最低3個bit爲wire type,什麼是wire type?如下表所示:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimi string, bytes, embedded messages, packed repeated fields
3 Start group Groups (deprecated)
4 End group Groups (deprecated)
5 32-bit fixed32, sfixed32, float

由於key的第三位最多表示8個值,而wire type目前的種類是6種。由於採用varint的編碼方式,只剩下4位的空閒存放field_number,因此之前在定義每個字段的標識號的時候建議不要超過15。

wire type被如此設計,主要是爲了解決一個問題,如何知道接下來value部分的長度(字節數),如果:

  • wire type=0、1、5,編碼爲key+數據,只有一個數據,可能佔數個字節,數據在編碼時自帶終止標記
  • wire type=2,編碼爲key+length+數據,length指示了數據長度,可能有多個數據,順序排序

需要注意的是:

  • 如果出現嵌套message,直接將嵌套message部分的編碼接在length後即可
  • repeated後面接的字段,如果是個message,它重複出現多少次,編碼時其key就會出現幾次;如果接的是proto定義的字段,且以packed = true壓縮存儲時,只會出現1個key;如果不以壓縮方式存儲,其key也會出現多次。在proto3中,默認以壓縮方式進行存儲,proto2中則需要顯式地聲明。

閱讀二進制文件

對於實例中的二進制文件,逐個解析:

08      // (1<<3)|0,1爲id的field_bumber,0爲id對應的wire type
01      // 0x01,id爲1

12      // (2<<3)|1,2爲name的field_bumber,1爲name對應的wire type
08      // name字段的字符串長度
7A68616E6773616E      // "zhangsan"的ASCII碼

18      // (3<<3)|0,3爲age的field_bumber,0爲age對應的wire type
12      // 0x12,age爲18

22      // (4<<3)|2,4爲email的field_bumber,2爲email對應的wire type
08      // email字段的字符串長度
312E71712E636F6D      // "1.qq.com"的ASCII碼
22      // (4<<3)|2,4爲email的field_bumber,2爲email對應的wire type
08      // email字段的字符串長度
322E71712E636F6D      // "2.qq.com"的ASCII碼

2A      // (5<<3)|2,5爲phone的field_bumber,2爲phone對應的wire type
0A      // 0x10,phone的長度爲10,1+1+6+1+1
0A      // (1<<3)|2,1爲number的field_bumber,2爲number對應的wire type
06      // number字段的字符串長度
313233343536      // "123456"的ASCII碼
10      // (2<<3)|1,2爲type的field_bumber,1爲type對應的wire type
01      // enum爲1,表示HOME

2A      // (5<<3)|2,5爲phone的field_bumber,2爲phone對應的wire type
0A      // 0x10,phone的長度爲10,1+1+6+1+1
0A      // (1<<3)|2,1爲number的field_bumber,2爲number對應的wire type
06      // number字段的字符串長度
323334353637      // "234567"的ASCII碼
10      // (2<<3)|1,2爲type的field_bumber,1爲type對應的wire type
00      // enum爲0,表示MOBILE

32      // (6<<3)|2,6爲address的field_bumber,2爲address對應的wire type
10      // 0x10,address的長度爲16,1+1+5+1+1+7
0A      // (1<<3)|2,1爲country的field_bumber,2爲country對應的wire type
05      // country字段的字符串長度
4368696E61      // "China"的ASCII碼
12      // (2<<3)|2,2爲detail的field_bumber,2爲detail對應的wire type
07      // detail字段的字符串長度
4A69616E677375      // "Jiangsu"的ASCII碼

protobuf的又小又快

數據變小一點

上文講解的數據的varint編碼方式,肯定能減少數據的大小,這點不再贅述。

諸如json、XML等數據,中間存在大量的冗餘字符,比如{、}、"、<、>等等,爲了減少數據量,我們可以暴力一點,直接把這些冗餘信息去掉。但是會帶來一些問題,就是當這段數據發送給接收端,接收端怎麼知道每個value對應哪個key呢?

比較好的解決方案是:事先跟接收端約定好有哪些字段,順序是什麼,然後接收端按照這個規則對應起來。這就是.proto文件的內容。

但是,隨之而來又有一個問題:.proto文件中的optional字段,如果沒有沒有該字段的信息,其實是不必要傳遞這個字段的。但此時在接收端,解析數據並按照順序進行字段匹配的時候就會出問題。

顯然已經亂套了,爲了保證能夠正確的配對,我們可以使用tag技術。也就是說,每個字段我們都用tag-value的方式來存儲的,在tag當中記錄兩種信息,一個是value對應的字段的編號,另一個是value的數據類型(比如是整形還是字符串等),因爲tag中有字段編號信息,所以能夠正確的配對。

可能你會問,使用tag的話,會增加額外的空間,這跟json的key-value有什麼區別嗎?

這個問題問的好,json中的key是字符串,每個字符就會佔據一個字節,所以像name這個key就會佔據4個字節,但在protobuf中,tag使用二進制進行存儲,一般只會佔據一個字節。相比較而言,tag所消耗的額外內存空間相對而言小很多。

即歸納成三點:

  • varint和ZigZag的編碼方式
  • 隔斷冗餘信息的剔除
  • tag-value方式的存儲,tag採用二進制進行存儲

解析變快一點

protobuf,它只需要簡單地將一個二進制序列,按照指定的格式讀取到C++對應的結構類型中就可以了。消息的decoding過程也可以通過幾個位移操作組成的表達式計算即可完成。而對於字符串、自定義對象類型的數據,protobuf在存儲的時候,也存儲了該數據的字節長度,讀取起來也非常快。


相關閱讀

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