收錄於 《深入微服務》
大家好,我是 “瀟灑哥老苗”。
今天,我帶大家更深層次的認識認識 Protobuf,如果你對 Protobuf 的用法還不熟悉,直接前往:developers.google.com/protocol-buf...。
當對 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” 資料如何解析。
自描述
但這樣還是有這些問題:
- name 資料如果超過 7 個位元組怎麼辦?
- age 資料超過 1 個位元組怎麼辦?
- 結構中的順序不能調整,太死了,怎麼辦?
當然,傳送方和接收方都更新下自己的 ”結構“ 資料,但這樣顯然不現實,因為資料你不能保證是固定長度。
對於 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 序列化和反序列化的核心演算法,只要這樣我才覺得真的明白了該演算法的真諦。
可持續關注該專案:github.com/miaogaolin/gofirst,該系列的所有程式碼往後都會加入進去。
參考
本作品採用《CC 協議》,轉載必須註明作者和本文連結