protobuf 是怎麼序列化的
背景
目前主流的幾種數據交互的格式主要有xml
、json
、protobuf
等等。xml
和json
我相信大家都很瞭解了。
-
xml
在 webservice 中應用最爲廣泛,但是相比於 json,它的數據更加冗餘,因爲需要成對的閉合標籤。json 使用了鍵值對的方式,不僅壓縮了一定的數據空間,同時也具有可讀性。 -
json
一般的 web 項目中,最流行的主要還是 json。因爲瀏覽器對於 json 數據支持非常好,有很多內建的函數支持。 -
而
protobuf
是後起之秀,是谷歌開源的一種數據格式,適合高性能,對響應速度有要求的數據傳輸場景。因爲 profobuf 是二進制
數據格式,需要編碼和解碼。數據本身不具有可讀性。因此只能反序列化之後得到真正可讀的數據。
protobuf 是一種 google 發明的數據序列化機制。官網的解釋是:
protocol buffers(簡稱 protobuf)是 google 的語言中立、平臺中立、可擴展的機制,用來對結構化數據進行序列化,類似於 xml,但是更小、更快、更簡單。只要定義好如何結構化你的數據,就可以使用生成好的代碼取寫和讀取各種數據流的數據,支持各種語言。
現在越來越多的互聯網公司選擇使用protobuf
來進行序列化,就是因爲它的優勢。它被廣泛應用於 RPC 調用,數據存儲。
protobuf 的核心就是它的.proto
文件,定義了數據的格式,類型和順序。
syntax = "proto3";
message Student {
optional int32 id = 1;
optional string name = 2;
}
注意: protobuf 語法有proto2
和proto3
,現在一般用proto3
。支持更多語言且更簡潔。
定義好 proto 文件後,通過 protobuf 提供的 protoc 編譯器對其進行編譯。它支持主流的編程語言:C++, C#, Dart, Go, Java, Kotlin, Python
等。
我們只需要定義好一份.proto
文件,序列化和反序列化可以使用不同的語言。
如果說 protobuf 有什麼缺點
的話,那就是序列化之後的數據是二進制的,可讀性差這一點了。
假設你對 protobuf 有基礎的瞭解並使用過它,想深入瞭解一下 protobuf 的原理:
-
protobuf 的數據是怎麼序列化的?
-
protobuf 是怎麼保持向後兼容性的?
-
protobuf 的原理是什麼?
-
protobuf 是怎麼極致的利用空間來儲存數據的?
那這篇文章適合你。
爲什麼要講序列化的原理和過程呢?
寫這篇文章的原因是有一個同事問我可不可以在某個protobuf結構
的中間加一個字段,然後把後面所有的字段標識
(標籤的數字,比如上面的 message 的 id 的字段標識
是 1)都加 1。
因爲他覺得protobuf
跟json
類似,是用字段名
來標識數據的Key
的,所以順序和字段標識
不重要。
重點: 這是錯的。字段標識
很重要,爲了保持兼容性,定義好之後是不能修改的。增加新的字段只能增加新的字段標識
。
儲備知識
要了解 Protobuf 的編碼和序列化,我們首先需要了解點技術的儲備知識點。弄懂了儲備知識點之後,才能更快的讓我們理解 protobuf 的序列化過程。
儲備知識包括:
•Varint 編碼
•Zigzag 編碼
如果你已經瞭解了這兩個技術點,或者不想了解這些編碼細節,可以直接跳到下個章節:Protobuf序列化
。
Varint 編碼
Varint 編碼是一種變長的編碼方式,編碼原理是用字節表示數字
,值越小的數字,使用越少的字節數表示。因此,可以通過減少表示數字的字節數進行數據壓縮。
對 int32 類型的數字,一般需要4個字節
表示。如果採用Varint編碼
,對於很小的 int32 類型數字,則可以用1個字節
來表示;雖然大的數字會需要5個字節
來表示,但大多數情況下,消息都不會有很大的數字,所以採用 Varint 編碼方式總是可以用更少的字節數來表示數字。
Varint 編碼後每個字節的最高位
都有特殊含義:
• 如果是 1,表示後續的字節也是數字的一部分。
• 如果是 0,表示本字節是最後一個字節,且剩餘 7 位都用來表示數字。
重點: 每個字節的最高一位只是標識位,沒有具體數字含義。
我們舉兩個例子可能說的更清楚一點:
例一:數字 1
這裏是數字1
,它是一個單字節,只需要一個字節就可以表示。
[0]000 0001
例二:數字 300
這裏是數字300
,這有點複雜。單字節最大隻能儲存2^7-1
, 也就是 0111 1111
, 肯定是不夠的。[注:最高位是特殊含義的標識符]
300 = 256 + 32 + 8 + 4 = 0000 0001 0010 1100
到這一步我相信都能看懂。
我們繼續一步推到底。
300
= 256 + 32 + 8 + 4
= 0000 0001 0010 1100 (二進制)
= 0000010 0101100 (每7位代表一個字節)
= 0101100 0000010 (小端儲存,低位在前)
= 10101100 00000010 (高位補1/0)
所以 300 需要兩個字節儲存。
更大的數據都是類似的方式轉換,對於很大的整數,32/7=4.57,向上取整是 5,所以我們最大需要 5 字節來表示一個大的 32 位整數。
所以我們使用Varint編碼
,需要 1~5 個字節表示。因爲大部分使用的數字都比較小,所以平均下來比較省空間。這也是爲什麼 protobuf 使用 Varint 的原因。
Zigzag 編碼
由於負數
的補碼錶示很大(最高位是符號位爲 1),直接使用 Varint 編碼,會佔用較多的字節。
這種情況使用了 ZigZag 編碼,轉換成比較小的正數,再使用 Varint 編碼,這樣最終生成的數據佔用較少的字節。
Zigazg 編碼是一種變長的編碼方式,其編碼原理是使用無符號數來表示有符號數字,使得絕對值小的數字都可以採用較少字節來表示,特別對錶示負數的數據能更好地進行數據壓縮。
Zigzag 編碼對 Varint 編碼在表示負數時不足的補充,從而更好的幫助 Protobuf 進行數據的壓縮。因此,如果提前預知字段值是可能取負數的時候,需要採用sint32/sint64
數據類型。
Protobuf 通過 Varint 和 Zigzag 編碼後,大大減少了字段值佔用字節數。
簡單來說: 對於小的負數,比如-2
,如果使用 Varint 編碼,在計算機內,負數一般會被表示爲很大的整數,因爲計算機定義負數的符號位爲數字的最高位。所以這個-2
會佔用 5 個字節。因爲負數的最高位是1,會被當做很大的整數去處理
。
而我們使用Zigzag
+varint
編碼的方式,(使用 protobuf 定義的 sint32/sint64 類型)就會將有符號數轉換成無符號數,再採用Varint
編碼,數字較小的負數,最終佔用的字節數也就更少。
sint32 類型的數字n
:
(n << 1) ^ (n >> 31)
sint64 類型的數字n
:
(n << 1) ^ (n >> 63)
通過位運算
算法和編碼後的值的規律,我們可以看到,其實就是把最高位的符號位
放到最低位
,其他位左移一位。讓絕對值相等的正負數,Zigzag 編碼後的數字相鄰。
絕對值小的值 Zigzag 編碼後的值也更小。然後使用 Varint 編碼後佔用的字節數也更少。
Protobuf 序列化
花了半天時間瞭解了兩種編碼,終於要講文章的主題,Protobuf 序列化了。
要了解 Protobuf 序列化,我們還是需要了解兩個知識點:
•Wire Type 類型
•T-L-V 儲存方式。(Tag - Length — Value)
這兩個要一起講。
Wire Type
Wire Type
是 google 爲 protobuf 專門定義的類型,不同的Wire Type
最終序列化爲二進制數據流的格式不一樣。這樣我們在序列化和反序列化的時候,很容易通過這個Wire Type
來解析後續數據。
Wire Type
是用來生成Tag
用的,準確的來說,Tag 包含了字段標識
和Wire Type
。這個 Tag 在後續講T-L-V儲存
的時候會講到。
這是WireType
的定義。
enum WireType {
WIRETYPE_VARINT = 0,
WIRETYPE_FIXED64 = 1,
WIRETYPE_LENGTH_DELIMITED = 2,
WIRETYPE_START_GROUP = 3, //deprecated
WIRETYPE_END_GROUP = 4, //deprecated
WIRETYPE_FIXED32 = 5
};
這是各個Wire Type
對應的具體類型。
每個Wire Type
可以對應多個具體的數據類型,因爲我們有.proto
文件,序列化和反序列化都是基於 proto 文件的,所以我們是明確知道類型的。
問題: 那爲什麼需要把Wire Type
編碼到數據裏面呢,因爲不同的Wire Type
對應的儲存方式
不同, 可以通過序列化的Wire Type
知道後續的數據是怎麼儲存的。
看上圖可知,當我們從 Tag 裏面讀到:
•Wire Type=0 時,T-V 儲存,V 是 Varint 編碼方式,編碼長度是 1-10 字節。
•Wire Type=1 時,T-V 儲存,V 是固定的 64 位,編碼長度是 8 個字節。
•Wire Type=5 時,T-V 儲存,V 是固定的 32 位,編碼長度是 4 個字節。
•Wire Type=3/4 已經廢棄不用。
•Wire Type=2 時,也是最複雜的一種,T-L-V 儲存。需要一個 Length 來記錄 Value 的長度。Value 是編碼後的值。
通過不同的Wire Type
使用不同的儲存方式,來最大化的節省空間。
T-L-V 儲存方式
T-L-V(Tag - Length - Value),即標籤-長度-字段值
的存儲方式,其原理是以標籤-長度-字段值
表示單個數據,最終將所有數據拼接成一個字節流
,從而實現數據存儲的功能。
其中Length可選存儲
,如儲存 Varint 編碼數據就不需要存儲 Length,此時爲 T-V 存儲方式。
T-L-V 存儲方式的優點:
• 不需要分隔符就能分隔開字段,減少了分隔符的使用。• 各字段存儲得非常緊湊,存儲空間利用率非常高。• 如果某個字段沒有被設置字段值,那麼該字段在序列化時的數據中是完全不存在
的,即不需要編碼,相應字段在解碼時會被設置爲默認值。
Tag 標籤
Tag 就是字段標識號
+Wire Type
的Varint
編碼格式。最少 1 字節。
因爲Wire Type
只有這 6 個定義,所以使用3bit
完全夠用。Tag 的最低三位表示Wire Type
,高位表示字段標識號
, Tag標籤
所佔用的長度,取決於字段標識號
的大小。
Wire Type只有六種類型,所以用三位二進制數完全足夠表示。
Tag = (字段標識號 << 3) | WireType
也就是如果如果字段標識號
<= 15 (4bit),那麼 Tag 就只需要一字節。
Length 長度
Length 通過上圖可知,它是可選的。只有Wire Type = 2
時,才需要 Length。
Value:只有 WireType=2 時,具體長度由 Length 指定。其他的Wire Type
, 不需要 Length 也知道 Value 的長度。可以參考上面的Wire Type
圖對應的編碼長度
。
不同 Wire Type 的數據序列化方式
下面我們就針對不同的 Wir eType,來以一個個分析,我們的數據是怎麼編碼的。
WireType = 0/1/5 時
所有的 Tag 都是 Varint,Tag = (字段標識號 << 3) | WireType
。
•int32: Wire Type = 0, Value:Varint,變長,1-10 字節。(最大 64 位 Varint)。
•double: Wire Type = 1, Value: double,固定 8 字節。
•float: Wire Type = 5, Value: float,固定 4 字節。
可以看到這幾種類型,不需要指定 Length,通過Wire Type
和Varint
極高的利用了字節空間。
WireType = 2 時
相比較其他Wire Type
多了一個Length
,用於標識 Value 的長度。Length 使用Varint
編碼。
Value
:消息字段經過編碼後的值。
其中 Value 也需要區分幾種類型:
1.String 類型。2. 嵌套消息類型(Message)3. 通過 packed 修飾的 repeat 字段(即 packed repeated fields)
String 類型 其中 String 類型使用 UTF8 編碼。
message Test
{
...
optional string str = 4;
...
}
// 將str設置爲:testing
Test.setStr(“testing”)
// 經過protobuf編碼序列化後的數據以二進制的方式輸出
// 輸出爲:18, 7, 116, 101, 115, 116, 105, 110, 103
輸出爲二進制數據流,展示爲數字,方便可讀。
Tag = 4 << 3 | 2 = 34
Length = 7
Value = UTF8("testing") = 116, 101, 115, 116, 105, 110, 103
嵌套消息類型
message Test
{
...
optional Internal internal = 5;
...
}
message Internal
{
optional string a = 1;
optional string b = 2;
}
嵌套消息類型,就是 message 裏面套一個 message,其實就是把內層的消息的T-L-V
數據儲存作爲外層數據的Value
。
外部的 Length 代表整個 Value 的長度,也就是內部的 message 的長度。
內部的 Message 就是另一個T-L-V模型
。
通過 packed 修飾的 repeat 字段
•proto2
: 由於歷史原因,標量數字類型(例如 int32、int64、enum)的重複字段沒有儘可能高效地編碼。新代碼應使用特殊選項 [packed = true] 以獲得更有效的編碼。
•proto3
: 此字段可以在格式良好的消息中重複任意次數(包括零次)。重複值的順序將被保留。在 proto3 中,標量數字類型(例如 int32、int64、enum)的重複字段默認使用 packed 編碼。
message Test
{
...
repeated int32 lists = 6; // 表達方式1:不帶packed=true
repeated int32 lists = 7 [packed=true]; // 表達方式2:帶packed=true
}
也就是說packed=true
是專門針對標量數字類型做的一個編碼空間優化策略。對於proto3
而言,它是默認使用的,對於proto2
而言,由於歷史原因,它默認沒有使用,需要手動使用。
我們看沒有packed
的 repeat,這幾個 Tag 是完全一樣的,因爲它們有着一樣的字符標識
和Wire Type
,是浪費空間的。所以才引入了packed=true
的修飾。
使用 Length 代表 Value 列表的總字節長度,Value 則使用 Varint 編碼,多個 Value 連續儲存。不需要每個 Value 前面放個相同的 Tag,極大的節省了空間。
總結
講解 protobuf 序列化,比我想象的佔用了更多的篇幅,因爲它的確有不少知識點要講解,不懂這些知識點的情況下,很難深入理解這個流程。我已經儘量簡化我的描述,同時能讓人一眼看懂。
當然 Protobuf 博大精深,我們這篇博客只是針對 protobuf 怎麼序列化的進行抽絲剝繭,理解了 protobuf 序列化的方式,讓你使用 protobuf 的時候知道應該選擇什麼類型。有什麼優缺點。
Protobuf 如何保證向後兼容性的呢?
• 通過添加可選字段,不可修改已經存在的字段標識
(標籤的數字)。
• 在序列化和反序列化的過程中,如果某個字段沒有賦值,則不會序列化這個字段。
• 反序列化的過程中,如果發現某個字段不存在,且不是required
類型,就會忽略這個字段的反序列化,繼續序列化其他字段。
• 由於 proto3 已經去掉了required
這個類型,所以沒有這方面的擔心,如果使用 proto2 的話,慎用 required 類型,尤其針對已有的結構增加新的字段。
Protobuf 使用建議
基於 Protobuf 序列化原理分析,爲了有效降低序列化後數據量的大小,可以採用以下措施:
- 多用
optional
或repeated
修飾符 若 optional 或 repeated 字段沒有被設置字段值,那麼該字段在序列化時的數據中是完全不存在的,即不需要進行編碼,但相應的字段在解碼時會被設置爲默認值。
2.字段標識號
儘量只使用 1-15,且不要跳動使用 Tag 是需要佔字節空間的。如果字段標識號>15
時,Tag標籤
的編碼就會佔用 2 個字節,如果將字段標識號定義爲連續遞增的數值,將獲得更好的編碼和解碼性能。
-
若需要使用的字段值出現負數,請使用 sint32/sint64,不要使用 int32/int64。採用 sint32/sint64 數據類型表示負數時,會先採用 Zigzag 編碼再採用 Varint 編碼,從而更加有效壓縮數據。
-
對於 repeated 字段,儘量增加 packed=true 修飾 增加 packed=true 修飾,repeated 字段會採用連續數據存儲方式,即
T-L-V-V-V
方式。
歡迎關注我的公衆號:碼農在新加坡
碼農在新加坡 Leftpocket 是一名後端程序員,擅長 C++, Golang, 網絡編程, Redis, MySQL, TiDB 等技術棧。有多年高併發開發和麪試官經驗,在這裏分享各種後端技術與面試等專業知識。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gQsiwb0eqbnIi6HardBPQA