ProtoBuffer Encoding 原理解析

ProtoBuffer Encoding 原理解析

本文參考官方的 Encoding 文檔

https://developers.google.com/protocol-buffers/docs/encoding

準備工作

下載 proto 相關工具 https://developers.google.com/protocol-buffers

準備測試用的 proto 文件 test.proto

package main;
message PB_ProtoBufEncodingTest
{
    optional int32 id = 1;
    optional string name = 2;
    optional string address = 3;
    optional int64 number = 4;
}

用官方提供的工具生成 go 代碼

protoc.exe --go_out=./ ./test.proto

然後打印幾條簡單信息的二進制數據和16進制數據:

package main
import (
    "code.google.com/p/goprotobuf/proto"
    "fmt"
)
func main() {
    test := &PB_ProtoBufEncodingTest{
        Id:   proto.Int32(150),
    }
    out, _ := proto.Marshal(test)
    for i, _ := range out {
        fmt.Printf("%02X ", out[i])
        //08 96 01 
    }
    fmt.Println()
    for i, _ := range out {
        fmt.Printf("%08b ", out[i])
        //00001000 10010110 00000001 
    }
}

Base 128 Varints 編碼

本文先從整型數據的編碼開始講起, proto 的高壓縮比很大一部分來源於對數值類型的壓縮。而在一切開始之前,先介紹下整型數據的一種常用編碼方式: varints。
​ Varints 是一種使用變長字節來序列化整數的方法,數值越小,佔用的字節數越少。那麼如何識別一個整數的邊界呢?答案是用每個字節的第一位,如果連續的字節表示的是同一個整數,那麼除了最後一個字節之外的,前面的字節的最高位都置 1 ,每當遇到的最高位非 1 的字節,即表示與之前連續最高位爲1 的字節加起來表示一個整數。由於最高位的存在,每個字節實際存儲數據的位只有7位,因此也叫 Base 128 。

​ 對於 300 來說,正常的二進制表示如下:

0000 0001 | 0010 1100

​ 其 Base 128 Varints 的二進制表示,其中第一個字節最高位需要置 1

1010 1100 | 0000 0010 

​ 那 Base 128 Varints 編碼的二進制數據如何轉換成正常的二進制數據?

1 .將每個字節的最高位去掉,因爲最高位不是用於存儲數據,而是記錄每個整數的邊界的

 1010 1100  | 0000 0010010 1100  |  000 0010

2 . 將字節逆序之後轉十進制

  010 1100  |  000 0010000 0010  |  010 1100  (逆序)100101100              (去掉最高位的 0,可以看到這裏已經是正常的二進制了)
→ 256+32+8+4 = 300      (轉十進制)

​ 上面 Base 128 Varints 格式的二進制轉正常二進制就是解碼的過程,通過最高位是否置 1,來得到每個整數的字節數據,再去掉最高有效位並逆序,就得到正常的二進制數據了。

proto 的消息結構和數據類型

​ proto 實際上是一個 key-value 結構的類型,編碼的時候,key 和 value 是連在一起寫入二進制數據的。解碼的時候解析器必須能跳過不認識的字段,這樣當同一個Proto 結構加入新的字段時,才能保證舊協議的兼容。爲了實現這種方式,key 實際上是由兩個值組成的,每個字段的編號 (field_number) + 該字段的數據類型 (wire_type)。

​ proto 給每一個數據類型都定義了一個 wire_type, 不同的 wire_type 採用不同的編碼方式。

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

​ 再回顧之前被 賦值 150 的 proto 對應的二進制數據,其中 08 就是 key 值,通過 (1 << 3 | 0 )計算得到

 key = (field_number << 3 | wire_type )

​ 那麼如何將 08 解析成 field_number 和 wire_type呢,其二進制的後三位就是 wire_type, 剩下的右移三位就是 field_number。通過對 key 的解析, 我們可以得到的接下來的字節所表示的數據類型 ,如果是 Varint 就按照上述 Base 128 Varint 的方式解析。

其他的數據類型

有符號數的 ZigZag 編碼

​ 可以看到 wire_type 爲 0 的都使用 Varint 方式編碼,其中還包括 signed int 類型。對於有符號的整數要如何表示呢? 雖然都是整數,但是當有負數存在的時候,正數和負數的表示完全不一樣。如果用 int32/int64 表示負數,將使用到長達 10個字節。而用 signed int 類型表示負數時,使用的是更高效的 ZigZag 編碼。

​ ZigZag 會將有符號的負數轉換成無符號的正數。比如 -1 將被編碼成 1 , 1 編碼成 2….

sint32 的編碼計算
(n << 1) ^ (n >> 31)
sint64 的編碼計算
(n << 1) ^ (n >> 63)
非 varints 編碼的數值類型

​ 比如 doublefixed64 , 都是 wire_type 1 格式的, 固定使用 64 位來存儲數據,同樣的,floatfixed32 固定使用 32 bits 來存儲數據。

字符串類型

​ 字符串類型的採用變長編碼,這一點跟很多協議的編碼類似,第一個字節表示字符串長度,剩下的是 UTF-8 編碼,比如 “testing” 的編碼爲:

12 07 74 65 73 74 69 6e 67

0x12 是key , 解析得到 field_number和 wire_type , 根據 wire_type 得知接下來是字符串類型。然後 0x07 表示字符串長度爲 7個字節,那麼接下來的 7 個字節就是字符串的內容。

嵌入結構

將上面的Proto結構再嵌套一層

message PB_EmbeddedTest
{
    optional PB_ProtoBufEncodingTest t = 3;
}

打印下嵌套後的proto的字節流

test2 := &PB_EmbeddedTest{
    T: test,
}
out2, _ := proto.Marshal(test2)
for i, _ := range out2 {
    fmt.Printf("%02X ", out2[i])
    //1A 03 08 96 01 
}

可以看到,後3個字節跟之前的是一樣的,第二個字節 0x03 表示後面數據的長度,這一點跟字符串一樣。第一個字節 1A 是 field_nubmer 和 wire_type 編碼的結果。

optional 和 repeated

​ 處理完了所有的數據類型,還有一個問題就是數組如何編解碼。在 proto 中數組用 repeated 表示。使用reapted 表示的數據,共用的是同一個 field_number 和 wire_type。在存儲的時候,數組數據實際上並不一定是連續存儲的,而可能被其他類型的數據分割開。

​ 對於 proto2 中 optional 類型和 proto3 中所有 非repeated 的數據,沒有值的情況下是不會被編碼的。

​ 正常的,如果不是 repeated 類型的字段,編碼後的數據是不可能出現多種類型相同的數據的,也就是 key 不會相同。一般各個語言的 API 也不會出現這種情況,但是對於給定的符合條件的二進制流,如果出現了相同key 的 非 repeated 類型的數據,解碼器會對這種情況做做一些處理,如果是 int / string 類型的,會取最後一次的賦值結果,對於重複的嵌套結構,則是做合併操作。 合併操作不同的語言生成的 API 不一樣,以go 的爲例,是這樣:

// API Generated
func (dst *PB_ProtoBufEncodingTest) XXX_Merge(src proto.Message) {
    xxx_messageInfo_PB_ProtoBufEncodingTest.Merge(dst, src)
}
//Example
test := &PB_ProtoBufEncodingTest{
    Id: proto.Int32(150),
    Name: proto.String("hello"),
}
// 等效於 ====================>
test := &PB_ProtoBufEncodingTest{
    Id: proto.Int32(150),
}
test2 := &PB_ProtoBufEncodingTest{
    Name: proto.String("hello"),
}
test1.XXX_Merge(t2)
Packed Repeated Fields

​ 對於repeated類型的字段可以進一步優化,proto2 中需要使用 [packed=true]; proto3 是默認會優化的。一個聲明瞭 packed 的 repeated 字段會被壓縮到一個 key-value 對,其 wire_type 爲 2。其優化的空間實際上是數組中多餘的 key 佔用的空間。

​ 直接看兩種編碼方式會更直觀,對於一個聲明瞭 packed=true 的 int 數組 d

message Test4 
{   
    repeated int32 d = 4 [packed=true];
}

​ 其 packed 前後的區別如下:

//packed 之後 
22        // key (field number 4, wire type 2)
06        // payload size (6 bytes)
03        // first element (varint 3)
8E 02     // second element (varint 270)
9E A7 05  // third element (varint 86942)

//packed 之前
20        // key (field number 4, wire type 0)
01        // payload size (1 bytes)
03        // first element (varint 3)
================================================
20        // key (field number 4, wire type 0)
02        // payload size (2 bytes)
8E 02     // second element (varint 270)
================================================
20        // key (field number 4, wire type 0)
03        // payload size (3 bytes)
9E A7 05  // third element (varint 86942)

​ 當然,packed 功能很明顯只能適用於數值類型,比如 varint , 32-bit ,64-bit , 因爲數組的所有元素是連續存放在一起的,只有數值類型,才能區分出每個元素明顯的邊界,比如 varint 使用最高位來標記邊界,而 32-bit 和 64-bit 是固定大小的存儲空間。 字符串無法使用 packed,是因爲本身就沒有用於區分邊界的字段。

​ 協議的解析器是可以支持將標記爲 packed 的字段當作沒有標記一樣處理和解析的。換句話說,packed 標記是前後兼容的,任何時候去掉這個標記,對已產生的數據的解析都是不影響的。

filed_number 和 field order

proto 裏是根據的 field_number 來決定一個字段的解碼方式的,因此,字段定義的順序不重要。

proto 壓縮方面的優勢

​ 與 JSON 相比

  1. proto 對數值類型的數據壓縮達到了極致,json 中所有的整型都是 long 型,proto 使用的 varints 和 zigzag 等編碼方式將數值類型的佔用空間壓到最低。特別是在數值類型的數組上,使用packed 將進一步壓縮key佔用。
  2. proto 對字符串基本沒有壓縮,也就是傳統 len + value 的格式,這一點跟 json 差不多
  3. proto 中使用一個字節的key 即可處理邊界問題,而 json 有大量的 { } ," " 等分界符。
  4. proto 對於optional 的字段可以選擇不進行序列化

更多

源碼解析:

https://halfrost.com/protobuf_decode/

性能壓測實驗:

https://github.com/eishay/jvm-serializers/wikis

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