protobuf原理(一):編碼原理
protobuf自身是語言無關的,但是它所提供的編譯器以及外掛機制可以將我們編寫的proto檔案生成任意語言的程式碼,所以可以用來做IDL定義服務介面,可以很方便地讓個型別的語言接入。
protobuf自身也是序列化協議,將結構體物件序列化為二進位制資料。protobuf的編碼原理其實在我們protobuf的使用中基本上是用不到的,不過了解其原理更方便我們理解protobuf的優勢與調優。
Base 128 Varints
可變寬度的整數是protobuf編碼格式的核心。可以將任意一個64位無符號整數編碼為1~10個位元組,值越小則使用的位元組數越少。
varint中的每個位元組的最高有效位MSB用作標誌位,表示這個位元組是否是這個varint的一部分。後7位是有效的資料位,varint就是將其對應的多個位元組的後7位來構建的。
varint就是根據數值的大小,以7位為單位進行編碼。而7位二進位制能夠表示的最大數為\(2^7-1\)。編碼的過程每次取出7位,根據當前值設定MSB是否為1,來編碼成位元組。對應的go語言實現編碼過程。
一個大於\(2^{21}\)小於\(2^{28}\)的值的編碼示例如下圖
根據值的大小我們可以知道需要編碼成4個位元組,前三個位元組都需要將MSB位設定為1,最後一個位元組的MSB為0,表示為這個varint的最後一個位元組。
message
對於整數有上面的varint編碼,但是我們在編寫proto檔案的時候,一般都是以message為單位來宣告的,整數只是message中的一個欄位。
例如
message Msg {
int32 type = 1;
string msg = 2;
}
message是由鍵值對組成的,有著各種型別的欄位,但是序列化的結果是二進位制的,所以這些欄位都是需要編碼成二進位制的,並且最終還要能夠根據二進位制反序列化回message,所以二進位制資訊中需要能夠獲取到如何解碼這段二進位制資訊以及這個資料屬於message的哪個欄位。
message中的每個鍵值對都被編碼為field-number, wire-type,payload的格式。field-number表示了這段二進位制資料屬於message的哪個欄位,wire-type告訴瞭解析器payload的長度,這樣還可以讓不支援新型別的解析器跳過。這種格式有時也叫做叫做
message採用Tag-Length-Value的格式編碼成二進位制。tag是通過(field_number << 3) | wire_type
公式編碼的varint。tag中的field_number和wire_type告訴來我們資料屬於哪個欄位以及採用的編碼方式。
通過下面的方式編碼tag以及從tag中獲取field_number和wire_type。
// DecodeTag decodes the field Number and wire Type from its unified form.
// The Number is -1 if the decoded field number overflows int32.
// Other than overflow, this does not check for field number validity.
func DecodeTag(x uint64) (Number, Type) {
// NOTE: MessageSet allows for larger field numbers than normal.
if x>>3 > uint64(math.MaxInt32) {
return -1, 0
}
return Number(x >> 3), Type(x & 7)
}
// EncodeTag encodes the field Number and wire Type into its unified form.
func EncodeTag(num Number, typ Type) uint64 {
return uint64(num)<<3 | uint64(typ&7)
}
Length說明了value的長度,對於proto更新新增新欄位,仍然使用舊的proto的解析的時候會根據length跳過不認識的欄位,這樣可以保證欄位相容。但是不是所有的wire_type都有length這一部分的,例如varint,可以根據MSB來獲取資料長度。
ID | Name | 格式 | Userd For |
---|---|---|---|
0 | varint | T-V | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | T-V 固定8位元組 | fixed64, sfixed64, double |
2 | LEN | T-L-V | string, bytes, embedded messages, packed repeated fields |
3 | SGROUP | 已廢棄 | group start (deprecated) |
4 | EGROUP | 已廢棄 | group end (deprecated) |
5 | I32 | T-V 固定4位元組 | fixed32, sfixed32, float |
More Integer types
從wire_type的表中可以看出,bool和enum也是採用varint進行編碼的,也就是說bool和enum也是作為整形處理的。
還有有符號數,varint編碼是無符號的。所以對於負數的編碼有所不同。intN
型別採用二進位制的補碼來表示負數,然後使用varint編碼,使用全部10個位元組,例如-2的編碼如下:
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
使用無符號定義的話就是~0-2+1
,其中~0
表示二進位制位全1的64位整數。
而SintN
採用的則是ZigZap的處理值,然後再使用varint編碼。統一使用正數來表示並且,正數n被編碼為2n(偶數),負數n被編碼為|2n+1|(奇數),例如-2採用ZigZap編碼的結果就是3。程式碼中是採用位運算完成的,這點比較巧妙
uint64((uint32(v)<<1)^uint32((int32(v)>>31)))
這裡用的是sint32型別,將值左移以為來乘以2,然後與最高位異或,來決定最後一位是否置1。當然只有負數的最高位位1。
對於浮點數以及fixedN這樣的型別,採用了非varint的編碼,而是I32和I64這樣的編碼,固定使用4個位元組或者8個位元組。例如1使用I32編碼後為
1 0 0 0
LEN
與前面的不同就是在於編碼的結果多了表示長度的L部分,最常見的就是string型別。
// proto
// message Msg {
// string str = 1;
// }
func main() {
msg := hello.Msg{Str: "testing"}
bs, _ := proto.Marshal(&msg)
fmt.Println(bs)
}
輸出的結果為[10 7 116 101 115 116 105 110 103]
,10為tag(1<< 3 | 2
), 7表示value的長度,後續的部分就是string中的字元的位元組碼了。
總結
其實到了這裡,我們已經知道了message中編碼方式,就是按照T-L-V的方式編碼一個個鍵值對,最為核心的就是varint的編碼,即使Tag的處理方式,也是大部分整型的處理方式。有關wire-type的其它編碼也都進行了介紹,可以嘗試在自本地編碼對應型別的值,檢視是否符合預期。