Protobuf 為啥比 JSON、XML 牛?

語言: CN / TW / HK

收錄於 《深入微服務》

大家好,我是 “瀟灑哥老苗”。

今天,我帶大家更深層次的認識認識 Protobuf,如果你對 Protobuf 的用法還不熟悉,直接前往:https://developers.google.com/protocol-buffers/docs/proto3

當對 Protobuf 有了基本的認識後,就會明白了 Protobuf 序列化的資料會比 JSON、XML 傳輸效率更高。

那為啥會高呢?本篇就帶著這個問題一探究竟。

看表面

對於 JSON、XML,為了便於資料傳輸時的可閱讀性,會保留資料的結構化資訊,舉個 JSON 例子,如下:

{
  "name": "laomiao",
  "age": 18
}

當傳送該資訊時,接受方收到後就會明白,這是個 “key/value” 形式的資料,並且"name" 後是姓名,"age" 後是年齡。

那如何壓縮該資料呢?

我們可以刪除 “花括號”、“name”、“age” 以及其它的 “冒號”、“逗號”、“引號” 等結構資料。

laomiao18

那這樣刪除了,接收方怎麼知道,哪個是姓名?哪個是年齡?

刪除 ”結構“

只需要傳送方和接收方都保留這份資料的 ”結構“ 就行,傳送方只發送資料,接收方接收到資料後,根據本地保留的 ”結構“ 去解析資料就 OK。

假設,該 "結構" 如下,這不是真實存在的,只是為了方便給大家描述。

{
  name string 7
  age int 1
}

通過該 ”結構“ 就可以知道:

  • name 資料在 age 資料之前。
  • name 資料型別為 string,age 資料型別為 int。
  • name 資料位元組長度為 7,age 資料位元組長度為 1。

接收方只需要拿著這份 ”結構“ 就知道了 "laomiao18" 資料如何解析。

自描述

但這樣還是有這些問題:

  1. name 資料如果超過 7 個位元組怎麼辦?
  2. age 資料超過 1 個位元組怎麼辦?
  3. 結構中的順序不能調整,太死了,怎麼辦?

當然,傳送方和接收方都更新下自己的 ”結構“ 資料,但這樣顯然不現實,因為資料你不能保證是固定長度。

對於 age 資料,我們可以定義為 4 個位元組或 8 個位元組,只要可以應對自己的業務即可。但這樣還是有問題,空間浪費?

假如,age 定義為 4 個位元組,傳輸的資料為 18,而對於 18 這個數字,只需要 1 個位元組就足以了,而剩下 3 個位元組都浪費著。但咱又不能定義為 1 個位元組,因為有可能會有大數。

那如何壓縮 age 資料呢?

對於 Protobuf,會在資料中加入解決以上問題的資訊,即資料自己描述自己,簡稱 ”自描述“。

總結下 Protobuf 做了哪些?如下:

  • 資料中加入 ”欄位“ 順序的資訊。
  • 資料中加入型別資訊。
  • 最小化壓縮整形資料。

Protobuf

Protobuf 在序列化資料時,將 Protobuf 資料型別總共劃分為 6 大類,英文稱為 "wire type"。

wire type proto 型別 含義
0 int32, int64, uint32, uint64, sint32, sint64, bool, enum Varint
1 fixed64, sfixed64, double 64-bit
2 string, bytes, embedded messages, packed repeated fields Length-delimited
3 groups (廢棄) Start group
4 groups (廢棄) End group
5 fixed32, sfixed32, float 32-bit

"wire type" 中的 ”3“ 和 ”4“ 型別已廢棄,這塊不做講解。

下來通過一個 message 資訊展開說明,如下:

message HelloRequest {
  string name = 1;
  int32 num = 2;
  float height = 3;
  repeated int32 hobbies= 4;
}

這就好比我上面所說的 ”結構“,傳送方和接收方就是通過該結構去解析資料。現在我們就針對上面留下的問題一一說明。

1. 型別和順序

那傳輸的資料中如何儲存 ”資料型別“ 和 ”順序“?

資料型別對應到 "wire type",順序對應到 ”field number“。假如 int32 num = 2 對應如下:

  • wire type:0,通過上面表格對應。
  • field number:2,欄位後的唯一編碼。

將這兩個資訊按照如下公式組裝:

(field_number << 3) | wire_type

帶入得:

(2 << 3) | 0 
→ 16

2. Varint

對於 num 欄位儲存的資料如何如何壓縮?假如 num 儲存的資料為 300。按照 4 位元組儲存如下:

00000000 00000000 00000001 00101100

從結果可以看到,真實有效的資料只有 2 位元組,為了壓縮,面對不同的資料大小會佔用不用的位元組數。

那如何記錄資料長度?我們可以再增加一個位元組去記錄真實資料所佔用的實際位元組數。對於 300 資料,增加一個位元組記錄長度,那下來和資料一塊總共需要 3 個位元組。那還有什麼辦法再減少位元組數嗎?

當然會有呀,不然我就說了一堆廢話,咱繼續。

請出 Varint 演算法,過程如下:

  • 將資料以 7 位為一組進行分割;
  • 將組的順序顛倒,即:將 ”高位 → 低位“ 規則,改為 ”低位 → 高位“;
  • 識別每一組,如果該組後還有資料,就在該組前增加一位 ”1”,否則增加 “0”。

將資料 300 帶入該演算法,過程如下:

300: 00000000 00000000 00000001 00101100
→ 7 位分割:0000 0000000 0000000 0000010 0101100
→ 顛倒順序:0101100 0000010 0000000 0000
→ 組前加 1/0:10101100 00000010
→ 十進位制:172 2

按照這套演算法下來,將資料壓縮為 2 個位元組儲存。而接收方拿到位元組資料後,只需要按照高位識別,如果為 0,說明之後沒有資料了。

最終,對於 int32 num = 2 結構和資料 300,壓縮後的結果為:

16 172 2

3. Length-delimited

現在說說 string name = 1 ,該型別對應的 "wire type" 為 2,"field number" 為 2。記錄 “順序” 和 “型別” 方式和上面講的一樣。

重點說說資料如何記錄,相比 Varint 演算法,該型別就簡單多了,只需要使用 Varint 演算法記錄資料的位元組長度。

假如,name 的值為 "miao",最終結果為:

10 4 109 105 97 111

解釋:

  • 10:(2 << 3) | 2
  • 4:字串長度。
  • 之後:按照 "UTF-8" 編碼儲存。

對於 message 巢狀、repeated (陣列或切片)、位元組陣列,也是按照該演算法得到。

例如,repeated int32 hobbies= 4 ,假設 hobbies 資料為 [10, 20],最終結果為:

34 2 10 20

4. 浮點數

針對浮點型別,就更簡單了,浮點資料使用固定位元組儲存,記錄 “順序” 和 “型別” 依然是上面講的。

假如,float height = 3 ,該型別對應的 "wire type" 為 5,資料假設為 52.1,最終結果為:

29 102 102 80 66

解釋:

  • 29:(3 << 3) | 5
  • 之後:使用固定位元組數 4。

如果使用了雙精度,那對應的 "wire type" 為 1,資料佔用位元組數為 8。

5. sint32/sint64

這兩個型別不知道你在寫 proto 檔案時有沒有用到,明白這個很重要,不然有時候資料就不能起被到壓縮的作用。

上面講到的 Varint 演算法中,我們知道了以 7 位一組,再增加一位 “識別位” 來起到壓縮資料的作用。但存在一個問題,倘若存在負數時,那這種壓縮方式就失效了。

至於為啥?如何解決的?

我先說結果,如果寫 proto 檔案時,設定的資料型別為 sint32 或 sint64 時,將採用 ZigZag 演算法進行資料壓縮。

ZigZag 演算法我就不重複講解了,直接看上一篇

小結

學完本篇我們知道了 Protobuf 怎麼做到了壓縮資料。簡單說下,就是刪除一些沒用的資訊,採用自描述的方式記錄 “型別”、“順序”、“資料”。

而對於型別,只記錄了 "wire type",該型別確定了資料的大概處理方式。

那說它就一定比 JSON、XML 好嗎?也不是。

因為要採用 Protobuf 方式傳輸資料,傳送方和接收方必須採用同一套結構規則,也可以說 “協議”。所以,如果想提高資料的閱讀性,降低這種規則的配合,就可以使用 JSON、XML。

後面我會使用 Go 語言實現 Protobuf 序列化和反序列化的核心演算法,只要這樣我才覺得真的明白了該演算法的真諦。

可持續關注該專案:https://github.com/miaogaolin/gofirst,該系列的所有程式碼往後都會加入進去。

參考