深入 ProtoBuf - 編碼

在對 ProtoBuf 做了一些基本介紹之后,這篇開始進入正題,深入 ProtoBuf 的一些原理,讓我們看看 ProtoBuf 是如何盡其所能的壓榨編碼性能和效率的。

編碼結構

TLV 格式是我們比較熟悉的編碼格式。

所謂的 TLV 即 Tag - Length - Value。Tag 作為該字段的唯一標識,Length 代表 Value 數據域的長度,最后的 Value 便是數據本身。

ProtoBuf 編碼采用類似的結構,但是實際上又有較大區別,其編碼結構可見下圖:

ProtoBuf 編碼結構圖.png

我們來一步步解析上圖所表達的編碼結構。

首先,每一個 message 進行編碼,其結果由一個個字段組成,每個字段可劃分為 Tag - [Length] - Value,如下圖所示:

ProtoBuf 編碼結構圖-1.png

特別注意這里的 [Length] 是可選的,含義是針對不同類型的數據編碼結構可能會變成 Tag - Value 的形式,如果變成這樣的形式,沒有了 Length 我們該如何確定 Value 的邊界?答案就是 Varint 編碼,在后面將詳細介紹。

繼續深入 Tag ,Tag 由 field_numberwire_type 兩個部分組成:

  • field_number: message 定義字段時指定的字段編號
  • wire_type: ProtoBuf 編碼類型,根據這個類型選擇不同的 Value 編碼方案。

整個 Tag 采用 Varints 編碼方案進行編碼,Varints 編碼會在后面詳細介紹。

Tag 結構如下圖所示:

ProtoBuf 編碼結構圖-2.png

3 bit 的 wire_type 最多可以表達 8 種編碼類型,目前 ProtoBuf 已經定義了 6 種,如下圖所示:

PB 編碼數據類型.png

第一列即是對應的類型編號,第二列為面向最終編碼的編碼類型,第三列是面向開發者的 message 字段的類型。

注意其中的 Start groupEnd group 兩種類型已被遺棄。

另外要特別注意一點,雖然 wire_type 代表編碼類型,但是 Varint 這個編碼類型里針對 sint32、sint64 又會有一些特別編碼(ZigTag 編碼)處理,相當于 Varint 這個編碼類型里又存在兩種不同編碼。

重新來看完整的編碼結構圖:

ProtoBuf 編碼結構圖.png

現在我們可以理解一個 message 編碼將由一個個的 field 組成,每個 field 根據類型將有如下兩種格式:

  • Tag - Length - Value:編碼類型表中 Type = 2 即 Length-delimited 編碼類型將使用這種結構,
  • Tag - Value:編碼類型表中 Varint、64-bit、32-bit 使用這種結構。

可以思考一下為什么這么設計編碼方案?可以看完下面各種編碼詳細的介紹再來細細品味這個問題。

其中 Tag 由字段編號 field_number 和 編碼類型 wire_type 組成, Tag 整體采用 Varints 編碼。

現在來模擬一下,我們接收到了一串序列化的二進制數據,我們先讀一個 Varints 編碼塊,進行 Varints 解碼,讀取最后 3 bit 得到 wire_type(由此可知是后面的 Value 采用的哪種編碼),隨后獲取到 field_number (由此可知是哪一個字段)。依據 wire_type 來正確讀取后面的 Value。接著繼續讀取下一個字段 field...

Varints 編碼

上一節中多次提到 Varints 編碼,現在我們來正式介紹這種編碼方案。

總結的講,Varints 編碼的規則主要為以下三點:

  1. 在每個字節開頭的 bit 設置了 msb(most significant bit ),標識是否需要繼續讀取下一個字節
  2. 存儲數字對應的二進制補碼
  3. 補碼的低位排在前面

為什么低位排在前面?這里主要是為編碼實現(移位操作)做的一個小優化。可以嘗試寫個二進制移位進行編碼解碼的小例子來體會這一點。

先來看一個最為簡單的例子:

int32 val =  1;  // 設置一個 int32 的字段的值 val = 1; 這時編碼的結果如下
原碼:0000 ... 0000 0001  // 1 的原碼表示
補碼:0000 ... 0000 0001  // 1 的補碼表示
Varints 編碼:0#000 0001(0x01)  // 1 的 Varints 編碼,其中第一個字節的 msb = 0
  • 編碼過程:
    數字 1 對應補碼 0000 ... 0000 0001(規則 2),從末端開始取每 7 位一組并且反轉排序(規則 3),因為 0000 ... 0000 0001 除了第一個取出的 7 位組(即原數列的后 7 位),剩下的均為 0。所以只需取第一個 7 位組,無需再取下一個 7 bit,那么第一個 7 位組的 msb = 0。最終得到

    0 | 000 0001(0x01) 
    
  • 解碼過程:
    我們再做一遍解碼過程,加深理解。

    編碼結果為 0#000 0001(0x01)。首先,每個字節的第一個 bit 為 msb 位,msb = 1 表示需要再讀一個字節(還未結束),msb = 0 表示無需再讀字節(讀取到此為止)。

    在上面的例子中,數字 1 的 Varints 編碼中 msb = 0,所以只需要讀完第一個字節無需再讀。去掉 msb 之后,剩下的 000 0001 就是補碼的逆序,但是這里只有一個字節,所以無需反轉,直接解釋補碼 000 0001,還原即為數字 1。

注意:這里編碼數字 1,Varints 只使用了 1 個字節。而正常情況下 int32 將使用 4 個字節存儲數字 1。

再看一個需要兩個字節的數字 666 的編碼:

int32 val = 666; // 設置一個 int32 的字段的值 val = 666; 這時編碼的結果如下
原碼:000 ... 101 0011010  // 666 的源碼
補碼:000 ... 101 0011010  // 666 的補碼
Varints 編碼:1#0011010  0#000 0101 (9a 05)  // 666 的 Varints 編碼

  • 編碼過程:
    666 的補碼為 000 ... 101 0011010,從后依次向前取 7 位組并反轉排序,則得到:

    0011010 | 0000101
    

    加上 msb,則

    1 0011010 | 0 0000101 (0x9a 0x05)
    
  • 解碼過程:
    編碼結果為 1#0011010 0#000 0101 (9a 05),與第一個例子類似,但是這里的第一個字節 msb = 1,所以需要再讀一個字節,第二個字節的 msb = 0,則讀取兩個字節后停止。讀到兩個字節后先去掉兩個 msb,剩下:

    0011010  000 0101
    

    將這兩個 7-bit 組反轉得到補碼:

    000 0101 0011010
    

    然后還原其原碼為 666。

注意:這里編碼數字 666,Varints 只使用了 2 個字節。而正常情況下 int32 將使用 4 個字節存儲數字 666。

仔細品味上述的 Varints 編碼,我們可以發現 Varints 的本質實際上是每個字節都犧牲一個 bit 位(msb),來表示是否已經結束(是否還需要讀取下一個字節),msb 實際上就起到了 Length 的作用,正因為有了 msb(Length),所以我們可以擺脫原來那種無論數字大小都必須分配四個字節的窘境。通過 Varints 我們可以讓小的數字用更少的字節表示。從而提高了空間利用和效率。

這里為什么強調犧牲?因為每個字節都拿出一個 bit 做 msb,而原先這個 bit 是可直接用來表示 Value 的,現在每個字節都少了一個 bit 位即只有 7 位能真正用來表達 Value。那就意味這 4 個字節能表達的最大數字為 228,而不再是 232 了。
這意味著什么?意味著當數字大于 228 時,采用 Varints 編碼將導致分配 5 個字節,而原先明明只需要 4 個字節,此時 Varints 編碼的效率不僅不是提高反而是下降。
但這并不影響 Varints 在實際應用時的高效,因為事實證明,在大多數情況下,小于 228 的數字比大于 228 的數字出現的更為頻繁。

到目前為止,好像一切都很完美。但是當前的 Varints 編碼卻存在著明顯缺陷。我們的例子好像只給出了正數,我們來看一下負數的 Varints 編碼情況。

int32 val = -1
原碼:1000 ... 0001  // 注意這里是 8 個字節
補碼:1111 ... 1111  // 注意這里是 8 個字節
再次復習 Varints 編碼:對補碼取 7 bit 一組,低位放在前面。
上述補碼 8 個字節共 64 bit,可分 9 組且這 9 組均為 1,這 9 組的 msb 均為 1(因為還有最后一組)
最后剩下一個 bit 的 1,用 0 補齊作為最后一組放在最后,最后得到 Varints 編碼
Varints 編碼:1#1111111 ... 0#000 0001 (FF FF FF FF FF FF FF FF FF 01)

注意,因為負數必須在最高位(符號位)置 1,這一點意味著無論如何,負數都必須占用所有字節,所以它的補碼總是占滿 8 個字節。你沒法像正數那樣去掉多余的高位(都是 0)。再加上 msb,最終 Varints 編碼的結果將固定在 10 個字節。

為什么是十個字節? int32 不應該是 4 個字節嗎?這里是 ProtoBuf 基于兼容性的考慮(比如開發者將 int64 的字段改成 int32 后應當不影響舊程序),而將 int32 擴展成 int64 的八個字節。
為什么之前講正數的時候沒有這種擴展?。請仔細品味 Varints 編碼,正數的前提下 int32 和 int64 天然兼容!

所以目前的情況是我們定義了一個 int32 類型的變量,如果將變量值設置為負數,那么直接采用 Varints 編碼的話,其編碼結果將總是占用十個字節,這顯然不是我們希望得到的結果。如何解決?

ZigZag 編碼

在上一節中我們提到了 Varints 編碼對負數編碼效率低的問題。

為解決這個問題,ProtoBuf 為我們提供了 sint32、sint64 兩種類型,當你在使用這兩種類型定義字段時,ProtoBuf 將使用 ZigZag 編碼,而 ZigZag 編碼將解決負數編碼效率低的問題。

ZigZag 的原理和概念比我們想象的簡單易懂,一句話就可概括介紹 ZigZag 編碼:

ZigZag 編碼:有符號整數映射到無符號整數,然后再使用 Varints 編碼

如下圖所示:

ZigZag 編碼.png

對于 ZigZag 編碼的思維不難理解,既然負數的 Varints 編碼效率很低,那么就將負數映射到正數,然后對映射后的正數進行 Varints 編碼。解碼時,解出正數之后再按映射關系映射回原來的負數。

例如我們設置 int32 val = -2。映射得到 3,那么對數字 3 進行 Varints 編碼,將結果存儲或發送出去。接收方接到數據后進行 Varints 解碼,得到數字 3,再將 3 映射回 -2。

這里的“映射”是以移位實現的,并非存儲映射表。

Varint 類型

介紹了 Varints 編碼和 ZigZag 編碼之后,我們就可以繼續深入分析每個類型的編碼。

在第一節中我們提到了 wire_type 目前已定義 6 種,其中兩種已被遺棄(Start group 和 End group),只剩下四種類型: Varint、64-bit、Length-delimited、32-bit

接下來我們就來一個個詳細分析,徹底搞明白 ProtoBuf 針對每種類型的編碼策略。

注意,我們在之前已經強調過,與其它三種類型不同,Varint 類型里不止一種編碼策略。 除了 int32、int64 等類型的 Varints 編碼,還有 sint32、sint64 類型的 ZigZag 編碼。

int32、int64、uint32、uint64、bool、enum

當我們使用 int32、int64、uint32、uint64、bool、enum 聲明字段類型時,其字段值將使用之前介紹的 Varints 編碼。

其中 bool 的本質為 0 和 1,enum 本質為整數常量。

在結合本文開頭介紹的編碼結構: Tag - [Length] - Value,這里的 Value 采用 Varints 編碼,因此不需要 Length,則編碼結構為 Tag - Value,其中 Tag 和 Value 均采用 Vartins 編碼。

int32、int64、uint32、uint64

來看一個最簡單的 int32 的小例子:

syntax = "proto3";

// message 定義
message Example1 {
    int32 int32Val = 1;
}

在程序中設置字段值為 1,其編碼結果為:

//  設置字段值 為 1
Example1 example1;
example1.set_int32val(1);
// 編碼結果
tag-(Varints)0#0001 000 + value-(Varints)0#000 0001 = 0x08 0x01

在程序中設置字段值為 666,其編碼結果為:

//  設置字段值 為 666
Example1 example1;
example1.set_int32val(666);
// 編碼結果
tag-(Varints)00001 000 + value-(Varints)1#0011010  0#000 0101 = 0x08 0x9a 0x05

在程序中設置字段值為 -1,其編碼結果為:

//  設置字段值 為 1
Example1 example1;
example1.set_int32val(-1);
// 編碼結果
tag-(Varints)00001 000 + value-(Varints)1#1111111 ... 0#000 0001 = 0x08 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0x01

int64、uint32、uint64 與 int32 同理

bool、enum

bool 的例子:

syntax = "proto3";

// message 定義
message Example1 {
    bool boolVal = 1;
}

在程序中設置字段值為 true,其編碼結果為:

//  設置字段值 為 true
Example1 example1;
example1.set_boolval(true);

// 編碼結果
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

在程序中設置字段值為 false,其編碼結果為:

//  設置字段值 為 false
Example1 example1;
example1.set_boolval(false);

// 編碼結果
空

這里有個有意思的現象,當 boolVal = false 時,其編碼結果為空,為什么?
這里是 ProtoBuf 為了提高效率做的又一個小技巧:規定一個默認值機制,當讀出來的字段為空的時候就設置字段的值為默認值。而 bool 類型的默認值為 false。也就是說將 false 編碼然后傳遞(消耗一個字節),不如直接不輸出任何編碼結果(空),終端解析時發現該字段為空,它會按照規定設置其值為默認值(也就是 false)。如此,可進一步節省空間提高效率。

enum 的例子:

syntax = "proto3";

// message 定義
message Example1 {
    enum COLOR {
        YELLOW = 0;
        RED = 1;
        BLACK = 2;
        WHITE = 3;
        BLUE = 4;
    }
    // 枚舉常量必須在 32 位整型值的范圍
    // 使用 Varints 編碼,對負數不夠高效,因此不推薦在枚舉中使用負數
    COLOR colorVal = 1;
}

在程序中設置字段值為 Example1_COLOR_BLUE,其編碼結果為:

//  設置字段值 為 Example1_COLOR_BLUE
Example1 example1;
example1.set_colorval(Example1_COLOR_BLUE);

// 編碼結果
tag-(Varints)00001 000 + value-(Varints)0#000 0100 = 08 04

sint32、sint64

sint32、sint64 將采用 ZigZag 編碼。編碼結構依然為 Tag - Value,只不過在編碼和解碼的過程中多出一個映射的過程,映射后依然采用 Varints 編碼。
來看 sint32 的例子:

syntax = "proto3";

// message 定義
message Example1 {
    sint32 sint32Val = 1;
}

在程序中設置字段值為 -1,其編碼結果為:

//  設置字段值 為 -1
Example1 example1;
example1.set_colorval(-1);

// 編碼結果,1 映射回 -1 
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

在程序中設置字段值為 -2,其編碼結果為:

//  設置字段值 為 -2
Example1 example1;
example1.set_colorval(-2);

// 編碼結果,3 映射回 -2
編碼結果:tag-(Varints)00001 000 + value-(Varints)0#000 0011 = 08 03

sint64 與 sint32 同理。

int、uint 和 sint: 之所以同時出現了這三種類型,是因為歷史和代碼迭代的結果。ProtoBuf 最初只有 int 類型,由于 int 類型不適合負數(負數編碼效率低),所以提供了 sint。因為 sint 的一部分正數其實是表達的負數,所以其正數范圍有所減小,所以在一些全是正數場景下需要提供 uint 類型。

64-bit 和 32-bit 類型

64-bit 和 32-bit 比較簡單,與 Varints 一樣其編碼結構為 Tag-Value,不同的是不管數字大小,64-bit 存儲 8 字節,32-bit 存儲 4 字節。讀取時同理,64-bit 直接讀取 8 字節,32-bit 直接讀取 4 字節。

為什么需要 64-bit 和 32-bit?之前已經分析過了 Varints 編碼在一定范圍內是有高效的,超過某一個數字占用字節反而更多,效率更低。如果現在有場景是存在大量的大數字,那么使用 Varints 就不太合適了,此時使用 64-bit 和 32-bit 更為合適。具體的,如果數值比 256 大的話,64-bit 這個類型比 uint64 高效,如果數值比 228 大的話,32-bit 這個類型比 uint32 高效。

fixed64、sfixed64、double

來看例子:

// message 定義
syntax = "proto3";

message Example1 {
    fixed64 fixed64Val = 1;
    sfixed64 sfixed64Val = 2;
    double doubleVal = 3;
}

在程序中分別設置字段值 1、-1、1.2,其編碼結果為:

//  設置字段值 為 -2
example1.set_fixed64val(1)
example1.set_sfixed64val(-1)
example1.set_doubleval(1.2)

// 編碼結果,總是 8 個字節
09 # 01 00 00 00 00 00 00 00
11 # FF FF FF FF FF FF FF FF (沒有 ZigZag 編碼)
19 # 33 33 33 33 33 33 F3 3F

fixed32、sfixed32、float

與 64-bit 同理。

Length-delimited 類型

string、bytes、EmbeddedMessage、repeated

終于遇到了體現編碼結構圖中 [Length] 意義的類型了。Length-delimited 類型的編碼結構為 Tag - Length - Value

這種編碼方式很好理解,來看例子:

syntax = "proto3";

// message 定義
message Example1 {
    string stringVal = 1;
    bytes bytesVal = 2;
    message EmbeddedMessage {
        int32 int32Val = 1;
        string stringVal = 2;
    }
    EmbeddedMessage embeddedExample1 = 3;
    repeated int32 repeatedInt32Val = 4;
    repeated string repeatedStringVal = 5;
}

設置相應的值:

Example1 example1;
example1.set_stringval("hello,world");
example1.set_bytesval("are you ok?");

Example1_EmbeddedMessage *embeddedExample2 = new Example1_EmbeddedMessage();

embeddedExample2->set_int32val(1);
embeddedExample2->set_stringval("embeddedInfo");
example1.set_allocated_embeddedexample1(embeddedExample2);

example1.add_repeatedint32val(2);
example1.add_repeatedint32val(3);
example1.add_repeatedstringval("repeated1");
example1.add_repeatedstringval("repeated2");

最終編碼的結果為:

0A 0B 68 65 6C 6C 6F 2C 77 6F 72 6C 64 
12 0B 61 72 65 20 79 6F 75 20 6F 6B 3F 
1A 10 08 01 12 0C 65 6D 62 65 64 64 65 64 49 6E 66 6F 
22 02 02 03[ proto3 默認 packed = true](編碼結果打包處理,見下一小節的介紹)
2A 09 72 65 70 65 61 74 65 64 31 2A 09 72 65 70 65 61 74 65 64 32(repeated string 為啥不進行默認 packed ?)

讀者可對照上面介紹過的編碼來理解這段相對復雜的編碼結果。(為降低難度,已按字段分行,即第一個字段的編碼結果對應第一行,第二個字段對應第二行...)

補充 packed 編碼

在 proto2 中為我們提供了可選的設置 [packed = true],而這一可選項在 proto3 中已成默認設置。

packed 目前只能用于 primitive 類型。

packed = true 主要使讓 ProtoBuf 為我們把 repeated primitive 的編碼結果打包,從而進一步壓縮空間,進一步提高效率、速度。這里打包的含義其實就是:原先的 repeated 字段的編碼結構為 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因為這些 Tag 都是相同的(同一字段),因此可以將這些字段的 Value 打包,即將編碼結構變為 Tag-Length-Value-Value-Value...

上一節例子中 repeatedInt32Val 字段的編碼結果為:

22 | 02 02 03

22 即 00100010 -> wire_type = 2(Length-delimited), field_number = 4(repeatedInt32Val 字段),02 字節長度為 2,則讀取兩個字節,之后按照 Varints 解碼出數字 2 和 3。

例子代碼

本文所有例子的代碼請訪問 例子代碼

下一篇

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容