protobuf 是怎麼序列化的

背景

目前主流的幾種數據交互的格式主要有xmljsonprotobuf等等。xmljson我相信大家都很瞭解了。

protobuf 是一種 google 發明的數據序列化機制。官網的解釋是:

protocol buffers(簡稱 protobuf)是 google 的語言中立、平臺中立、可擴展的機制,用來對結構化數據進行序列化,類似於 xml,但是更小、更快、更簡單。只要定義好如何結構化你的數據,就可以使用生成好的代碼取寫和讀取各種數據流的數據,支持各種語言。

現在越來越多的互聯網公司選擇使用protobuf來進行序列化,就是因爲它的優勢。它被廣泛應用於 RPC 調用,數據存儲。

protobuf 的核心就是它的.proto文件,定義了數據的格式,類型和順序。

syntax = "proto3";
message Student {
    optional int32 id = 1;
    optional string name = 2;
}

注意: protobuf 語法有proto2proto3,現在一般用proto3。支持更多語言且更簡潔。

定義好 proto 文件後,通過 protobuf 提供的 protoc 編譯器對其進行編譯。它支持主流的編程語言:C++, C#, Dart, Go, Java, Kotlin, Python等。

我們只需要定義好一份.proto文件,序列化和反序列化可以使用不同的語言。

如果說 protobuf 有什麼缺點的話,那就是序列化之後的數據是二進制的,可讀性差這一點了。

假設你對 protobuf 有基礎的瞭解並使用過它,想深入瞭解一下 protobuf 的原理:

那這篇文章適合你。

爲什麼要講序列化的原理和過程呢?

寫這篇文章的原因是有一個同事問我可不可以在某個protobuf結構的中間加一個字段,然後把後面所有的字段標識標籤的數字,比如上面的 message 的 id 的字段標識是 1)都加 1。

因爲他覺得protobufjson類似,是用字段名來標識數據的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)

QhNgaV

通過位運算算法和編碼後的值的規律,我們可以看到,其實就是把最高位的符號位放到最低位,其他位左移一位。讓絕對值相等的正負數,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 TypeVarint編碼格式。最少 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 TypeVarint極高的利用了字節空間。

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 序列化原理分析,爲了有效降低序列化後數據量的大小,可以採用以下措施:

  1. 多用optionalrepeated修飾符 若 optional 或 repeated 字段沒有被設置字段值,那麼該字段在序列化時的數據中是完全不存在的,即不需要進行編碼,但相應的字段在解碼時會被設置爲默認值。

2.字段標識號儘量只使用 1-15,且不要跳動使用 Tag 是需要佔字節空間的。如果字段標識號>15時,Tag標籤的編碼就會佔用 2 個字節,如果將字段標識號定義爲連續遞增的數值,將獲得更好的編碼和解碼性能。

  1. 若需要使用的字段值出現負數,請使用 sint32/sint64,不要使用 int32/int64。採用 sint32/sint64 數據類型表示負數時,會先採用 Zigzag 編碼再採用 Varint 編碼,從而更加有效壓縮數據。

  2. 對於 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