在很多很多時候被問起,爲什麼選擇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中,主要使用的兩種編碼方式就是varint
和ZigZag
。
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在存儲的時候,也存儲了該數據的字節長度,讀取起來也非常快。