Protocol Buffers工作原理
- 2020 年 6 月 4 日
- 筆記
- Protocol Buffer, 後端開發
這裡記錄一下學習與使用Protocol Buffer的筆記,優點缺點如何使用這裡不再敘述,重點關注與理解Protocol Buffers的工作原理,其大概實現。
我們經常使用Protocol Buffer進行序列化與反序列化。理解Protocol Buffer的工作原理,就要理解序列化與反序列化。
- 序列化:將數據結構或對象轉換為二進制串的過程;
- 反序列化:序列化的逆過程;
如何實現呢?核心有兩點:編碼 + 存儲。數據在計算機間通過網絡進行傳輸時,傳輸的是比特流,只有0和1,並沒有你所定義的各種類對象等,你如果想將一個類對象傳輸到對方,怎麼辦呢?字符是用過ASCII碼編碼的,這裡也可以設計一套編碼方案來對類似類對象這種數據進行編碼,只要對方收到後能正確的解碼就可以了。編碼後還要確定編碼後的數據存儲方式,這樣位元組流才是有意義的位元組流,這樣才能知道讀取的位元組流有什麼含義,代表什麼。
好了,我們看一下Protocol Buffer是如何編碼和存儲的。
Protocol Buffer是如何編碼的
Varint編碼
Varint編碼是一種變長的編碼方式,核心思想是對數值越小的數字,用越少的位元組數表示,這樣可以減少數字的位元組數,進行數據壓縮。
舉個例子:對int
數據類型,一般需要4個位元組來表示,而實際上,對與數值較小的數字而言,無需這麼多位元組,00000000 00000000 00000000 01111111 | 127
,只需要一個位元組就能表示,前面3個位元組意義不大,浪費了許多空間。
當然,這種編碼並不是所有情況下都會變小,當數值非常大時,所需的位元組會增多,但因為大多數情況下數值小的數字遠比數值大的多,所以整體看來,數據是被壓縮了的。
具體的,Varint編碼時,對每個位元組的最高位賦予特殊含義:
- 1:表示後序的位元組也是該數字的一部分;
- 0:表示這是最後一個位元組,且剩餘7bit都用來表示數字(所以Varint解碼時,如果讀到最高位為0的位元組時,就表示已經是Varing的最後一個位元組);
因為每個位元組的最高位都被佔用,用來表示特殊的含義,所以,當數值非常大時,原有的位元組數就不夠用了,所以編碼時要增加位元組數。
可以參考下圖加深理解:
編碼示例:
解碼示例:
還有一個問題:就是負數時怎麼辦?計算機中數值用補碼形式表示和存儲(負數在計算機中往往用最位1來表示負數,0表示正數,負數的補碼最高位為1),那按Varint編碼方式所有的負數都需要增加一個位元組表示,這是不能被接受的,解決方法便是下面要講的zigzag編碼。
Zigzag編碼
Zigzag編碼是一種變長的編碼方式。zigzag按絕對值升序排列,將整數hash成遞增的數值序列,哈希函數為h(n)=(n<<1)^(n>>31)
,對應地long型(64)位的哈希函數為h(n)=(n<<1)^(n>>63)
。
n | hex | h(n) | zigzag(hex) |
---|---|---|---|
0 | 00 00 00 00 | 00 00 00 00 | 00 |
-1 | ff ff ff ff | 00 00 00 01 | 01 |
1 | 00 00 00 01 | 00 00 00 02 | 02 |
-2 | ff ff ff fe | 00 00 00 03 | 03 |
2 | 00 00 00 02 | 00 00 00 04 | 04 |
… | … | … | … |
可以看到,zigzag編碼將正數和負數重新映射為遞增的無符號數,其主要目的就是使絕對值小的數值映射為小的無符號數,已方便後序壓縮位元組。
zigzag編碼還有很多細節,比如為了保證編碼的唯一可譯性,需對哈希值進行前綴碼編碼,這裡不再細述。
解碼為編碼的逆,首先將zigzag編碼還原成哈希值,然後用哈希函數h
的逆h』(n)=(n>>>1)^-(n&1)
得到原始整數值。
這裡只重點講Varint編碼和Zigzag編碼,像string、double等類型這裡不再描述,可參考文後的參考文檔。
T-L-V的數據存儲方式
T:tag,L:length,V:value
。以標識-長度-字段值
表示單個數據,最終將所有數據拼接成一個位元組流,從而實現數據存儲的功能。
其中Length可選存儲,如存儲Varint編碼數據就不需要存儲Length,因為可根據每個位元組最高bit位來判斷這個位元組是不是該數據段的最後一個位元組。
這裡重點說一下Tag
。Tag用來標識字段,通過Tag能獲知這段位元組流是屬於什麼類型數據的,其定義為:Tag = (field_number << 3) | wire_type
。這樣,解包時就可根據tag將value對應消息中的字段。
Tag佔用一個位元組的長度(如果標識號超過了16,則多佔用一個位元組的位置,原因是field_number左移了3位,編碼方式為Varint&Zigzag,編碼的時候一個位元組不夠用了)。
message ChannelDataAck {
bytes uuid = 1; //這裡的1 、2就是field_number
uint32 result = 2;
}
Tag(字段標識號)在序列化和反序列化過程中非常重要。舉一個應用中非常常見的例子,在需要對原有結構進行增減字段的時候,同樣一個結構體定義,新版本代碼中對其增加了一個字段,那當新版本代碼序列化後給原有舊版本反序列化解析的時候,因為舊的沒有那個新增的字段,所以在解析時只解析自己有的字段,沒有的不進行解析,這樣舊的代碼依舊能從新位元組流中解析出舊數據結構。那舊的數據結構的數據解析為新數據結構時,因為沒有新字段的數據,解析為新數據時該字段置為默認值。這樣就能保證兼容性,對協議升級較為友好。
可以看到通過T-L-V
的數據存儲方式,能夠較好的解決字段不完全匹配時的如何解析的問題。
序列化 & 反序列化過程
序列化過程如下:
- 判斷每個字段是否有設置值,有值才進行編碼.
- 根據字段標識號&數據類型將字段值通過不同的編碼方式進行編碼.
反序列化過程如下:
- 解析從輸入流讀入的二進制位元組數據流.
- 將解析出來的數據按照指定的格式讀取到
C++、Rust
等對應的結構類型中.
到這裡,基本把Protocol Buffers的工作原理簡單梳理了一遍,其他技術細節待以後再深究。