編碼
本文說明 Protocol Buffer *線路格式*,其中定義了訊息在線路上傳送的詳細資訊,以及在磁碟上佔用多少空間。您可能不需要了解這些就能在應用程式中使用 Protocol Buffer,但對於進行最佳化而言,這是實用的資訊。
如果您已了解概念但想要參考,請跳至「精簡參考卡」章節。
Protoscope 是一種非常簡單的語言,用於描述低階線路格式的程式碼片段,我們將使用它來為各種訊息的編碼提供視覺參考。Protoscope 的語法包含一系列*符號*,每個符號都編碼為特定的位元組序列。
例如,反引號表示原始十六進位字面值,例如 `70726f746f6275660a`
。這會編碼為字面值中以十六進位表示的確切位元組。引號表示 UTF-8 字串,例如 "Hello, Protobuf!"
。此字面值與 `48656c6c6f2c2050726f746f62756621`
同義 (如果您仔細觀察,它是由 ASCII 位元組組成的)。我們將在討論線路格式的各個方面時,介紹更多 Protoscope 語言。
Protoscope 工具也可以將編碼的 Protocol Buffer 傾印為文字。請參閱 https://github.com/protocolbuffers/protoscope/tree/main/testdata 以取得範例。
簡單訊息
假設您有以下非常簡單的訊息定義
message Test1 {
optional int32 a = 1;
}
在應用程式中,您建立一個 Test1
訊息並將 a
設定為 150。然後,您將訊息序列化到輸出串流。如果您能夠檢查編碼的訊息,您會看到三個位元組
08 96 01
到目前為止,非常小且為數字 - 但這代表什麼意義?如果您使用 Protoscope 工具傾印這些位元組,您會得到類似 1: 150
的結果。它如何知道這是訊息的內容?
Base 128 Varints
變寬度整數,或 *varints*,是線路格式的核心。它們允許使用一到十個位元組之間的任何位置來編碼未簽署的 64 位元整數,小值使用較少的位元組。
varint 中的每個位元組都有一個*繼續位元*,指示後面的位元組是否為 varint 的一部分。這是位元組的*最高有效位元* (MSB) (有時也稱為*符號位元*)。較低的 7 個位元是有效負載;產生的整數是透過將其組成位元組的 7 位元有效負載附加在一起而建構的。
因此,例如,這是數字 1,編碼為 `01`
– 它是一個位元組,因此未設定 MSB
0000 0001
^ msb
這是 150,編碼為 `9601`
– 這有點複雜
10010110 00000001
^ msb ^ msb
您如何找出這是 150?首先,您從每個位元組中移除 MSB,因為這只是為了告訴我們是否已到達數字的末尾 (如您所見,它在第一個位元組中設定,因為 varint 中有多個位元組)。這些 7 位元有效負載採用小端序。轉換為大端序,串連,並解譯為未簽署的 64 位元整數
10010110 00000001 // Original inputs.
0010110 0000001 // Drop continuation bits.
0000001 0010110 // Convert to big-endian.
00000010010110 // Concatenate.
128 + 16 + 4 + 2 = 150 // Interpret as an unsigned 64-bit integer.
由於 varint 對於 Protocol Buffer 來說非常重要,因此在 protoscope 語法中,我們將它們稱為純整數。150
與 `9601`
相同。
訊息結構
Protocol Buffer 訊息是一系列鍵值對。訊息的二進位版本僅使用欄位的編號作為索引鍵 – 每個欄位的名稱和宣告類型只能在解碼端透過參考訊息類型的定義 (即 .proto
檔案) 來判斷。Protoscope 無法存取此資訊,因此它只能提供欄位編號。
當訊息被編碼時,每個鍵值對都會轉換為*記錄*,其中包含欄位編號、線路類型和有效負載。線路類型告訴剖析器其後有效負載的大小。這允許舊的剖析器跳過它們不理解的新欄位。這種方案有時稱為 標籤-長度-值 或 TLV。
有六種線路類型:VARINT
、I64
、LEN
、SGROUP
、EGROUP
和 I32
ID | 名稱 | 用於 |
---|---|---|
0 | VARINT | int32、int64、uint32、uint64、sint32、sint64、bool、enum |
1 | I64 | fixed64、sfixed64、double |
2 | LEN | 字串、位元組、嵌入式訊息、封裝的重複欄位 |
3 | SGROUP | 群組開始 (已淘汰) |
4 | EGROUP | 群組結束 (已淘汰) |
5 | I32 | fixed32、sfixed32、float |
記錄的「標籤」編碼為 varint,由欄位編號和線路類型透過公式 (field_number << 3) | wire_type
形成。換句話說,在解碼表示欄位的 varint 之後,低 3 位元告訴我們線路類型,整數的其餘部分告訴我們欄位編號。
現在讓我們再次看看我們的簡單範例。您現在知道串流中的第一個數字始終是 varint 索引鍵,這裡它是 `08`
,或 (移除 MSB)
000 1000
您取最後三個位元以取得線路類型 (0),然後向右移位三個以取得欄位編號 (1)。Protoscope 將標籤表示為整數,後跟冒號和線路類型,因此我們可以將上述位元組寫為 1:VARINT
。
由於線路類型為 0 或 VARINT
,我們知道我們需要解碼 varint 以取得有效負載。正如我們在上面看到的,位元組 `9601`
varint-解碼為 150,為我們提供了記錄。我們可以在 Protoscope 中將其寫為 1:VARINT 150
。
如果 :
後面有空格,Protoscope 可以推斷標籤的類型。它透過向前查看下一個符號並猜測您的意思來做到這一點 (規則在 Protoscope 的 language.txt 中詳細記錄)。例如,在 1: 150
中,在未類型化的標籤之後立即有一個 varint,因此 Protoscope 推斷其類型為 VARINT
。如果您寫入 2: {}
,它會看到 {
並猜測 LEN
;如果您寫入 3: 5i32
,它會猜測 I32
,依此類推。
更多整數類型
布林值和列舉
布林值和列舉都像 int32
一樣編碼。特別是布林值始終編碼為 `00`
或 `01`
。在 Protoscope 中,false
和 true
是這些位元組字串的別名。
帶符號整數
正如您在前一節中看到的,與線路類型 0 相關聯的所有 Protocol Buffer 類型都編碼為 varint。但是,varint 是未簽署的,因此不同的帶符號類型 sint32
和 sint64
與 int32
或 int64
相對,以不同的方式編碼負整數。
intN
類型將負數編碼為二補數,這表示作為未簽署的 64 位元整數,它們的最高位元已設定。因此,這表示必須使用*所有十個位元組*。例如,-2
由 protoscope 轉換為
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
這是 2 的*二補數*,在未簽署的算術中定義為 ~0 - 2 + 1
,其中 ~0
是全為 1 的 64 位元整數。了解為什麼這會產生如此多的 1 是一個有用的練習。
sintN
使用「ZigZag」編碼而不是二補數來編碼負整數。正整數 p
編碼為 2 * p
(偶數),而負整數 n
編碼為 2 * |n| - 1
(奇數)。因此,編碼在正數和負數之間「曲折」。例如
帶符號的原始值 | 編碼為 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
… | … |
0x7fffffff | 0xfffffffe |
-0x80000000 | 0xffffffff |
換句話說,每個值 n
都使用
(n << 1) ^ (n >> 31)
用於 sint32
,或
(n << 1) ^ (n >> 63)
用於 64 位元版本。
當剖析 sint32
或 sint64
時,其值會解碼回原始的帶符號版本。
在 protoscope 中,在整數後面加上 z
會使其編碼為 ZigZag。例如,-500z
與 varint 999
相同。
非 Varint 數字
非 varint 數字類型很簡單 – double
和 fixed64
具有線路類型 I64
,這告訴剖析器預期固定的八位元組資料塊。我們可以透過寫入 5: 25.4
來指定 double
記錄,或透過 6: 200i64
來指定 fixed64
記錄。在這兩種情況下,省略明確的線路類型都表示 I64
線路類型。
同樣地,float
和 fixed32
具有線路類型 I32
,這告訴它預期四個位元組。這些的語法包括新增 i32
後綴。25.4i32
將發出四個位元組,200i32
也是如此。標籤類型推斷為 I32
。
長度分隔記錄
*長度前綴*是線路格式中的另一個主要概念。LEN
線路類型具有動態長度,由標籤後面的 varint 指定,後跟通常的有效負載。
考慮以下訊息結構描述
message Test2 {
optional string b = 2;
}
欄位 b
的記錄是字串,字串是 LEN
編碼的。如果我們將 b
設定為 "testing"
,我們會編碼為包含 ASCII 字串 "testing"
的欄位編號 2 的 LEN
記錄。結果是 `120774657374696e67`
。分解位元組,
12 07 [74 65 73 74 69 6e 67]
我們看到標籤 `12`
是 00010 010
或 2:LEN
。後面的位元組是 int32 varint 7
,接下來的七個位元組是 "testing"
的 UTF-8 編碼。int32 varint 表示字串的最大長度為 2GB。
在 Protoscope 中,這寫為 2:LEN 7 "testing"
。但是,重複字串的長度可能很不方便 (在 Protoscope 文字中,字串已經以引號分隔)。將 Protoscope 內容包裝在花括號中將為其產生長度前綴:{"testing"}
是 7 "testing"
的簡寫。欄位始終推斷 {}
為 LEN
記錄,因此我們可以將此記錄簡單地寫為 2: {"testing"}
。
bytes
欄位的編碼方式相同。
子訊息
子訊息欄位也使用 LEN
線路類型。以下是訊息定義,其中包含原始範例訊息 Test1
的嵌入式訊息
message Test3 {
optional Test1 c = 3;
}
如果 Test1
的 a
欄位 (即 Test3
的 c.a
欄位) 設定為 150,我們會得到 ``1a03089601``
。分解它
1a 03 [08 96 01]
最後三個位元組 (在 []
中) 與我們 第一個範例 中的完全相同。這些位元組前面是 LEN
類型標籤和長度 3,與字串的編碼方式完全相同。
在 Protoscope 中,子訊息非常簡潔。``1a03089601``
可以寫為 3: {1: 150}
。
選用和重複元素
遺失的 optional
欄位很容易編碼:如果欄位不存在,我們只需省略記錄即可。這表示只有少數欄位設定的「巨大」proto 非常稀疏。
repeated
欄位有點複雜。普通 (非 封裝) 重複欄位會為欄位的每個元素發出一個記錄。因此,如果我們有
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
並且我們建構一個 Test4
訊息,將 d
設定為 "hello"
,並將 e
設定為 1
、2
和 3
,則這*可以*編碼為 `220568656c6c6f280128022803`
,或寫為 Protoscope,
4: {"hello"}
5: 1
5: 2
5: 3
但是,e
的記錄不需要連續出現,並且可以與其他欄位交錯;僅保留相同欄位的記錄彼此之間的順序。因此,這也可以編碼為
5: 1
5: 2
4: {"hello"}
5: 3
Oneofs
Oneof
欄位 的編碼方式與欄位不在 oneof
中時相同。適用於 oneof
的規則與它們在線路上的表示方式無關。
最後一個勝出
通常,編碼的訊息永遠不會有多個非 repeated
欄位的實例。但是,剖析器應處理它們確實存在的情況。對於數字類型和字串,如果同一個欄位多次出現,則剖析器會接受它看到的*最後一個*值。對於嵌入式訊息欄位,剖析器會合併同一個欄位的多個實例,就像使用 Message::MergeFrom
方法一樣 – 也就是說,後一個實例中的所有單一純量欄位都會取代前一個實例中的欄位,單一嵌入式訊息會合併,而 repeated
欄位會串連。這些規則的效果是,剖析兩個編碼訊息的串連產生的結果與您分別剖析兩個訊息並合併產生的物件完全相同。也就是說,這個
MyMessage message;
message.ParseFromString(str1 + str2);
等同於這個
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
此屬性有時很有用,因為它允許您合併兩個訊息 (透過串連),即使您不知道它們的類型。
封裝的重複欄位
從 v2.1.0 開始,基本類型 (任何不是 string
或 bytes
的 純量類型) 的 repeated
欄位可以宣告為「封裝」。在 proto2 中,這是使用欄位選項 [packed=true]
完成的。在 proto3 中,這是預設值。
它們不是為每個條目編碼一個記錄,而是編碼為包含每個串連元素的單個 LEN
記錄。為了進行解碼,從 LEN
記錄逐個解碼元素,直到有效負載耗盡。下一個元素的開始取決於前一個元素的長度,而前一個元素的長度本身取決於欄位的類型。
例如,假設您有訊息類型
message Test5 {
repeated int32 f = 6 [packed=true];
}
現在假設您建構一個 Test5
,為重複欄位 f
提供值 3、270 和 86942。編碼後,這給我們 `3206038e029ea705`
,或作為 Protoscope 文字,
6: {3 270 86942}
只有基本數字類型的重複欄位才能宣告為「封裝」。這些是通常會使用 VARINT
、I32
或 I64
線路類型的類型。
請注意,雖然通常沒有理由為封裝的重複欄位編碼多個鍵值對,但剖析器必須準備好接受多個鍵值對。在這種情況下,有效負載應串連。每個配對都必須包含整數個元素。以下是剖析器必須接受的上述相同訊息的有效編碼
6: {3 270}
6: {86942}
Protocol Buffer 剖析器必須能夠剖析編譯為 packed
的重複欄位,就像它們未封裝一樣,反之亦然。這允許以向前和向後相容的方式將 [packed=true]
新增到現有欄位。
地圖
地圖欄位只是特殊類型重複欄位的簡寫。如果我們有
message Test6 {
map<string, int32> g = 7;
}
這實際上與
message Test6 {
message g_Entry {
optional string key = 1;
optional int32 value = 2;
}
repeated g_Entry g = 7;
}
因此,地圖的編碼方式與 repeated
訊息欄位完全相同:作為一系列 LEN
類型記錄,每個記錄有兩個欄位。
群組
群組是一種已淘汰的功能,不應使用,但它們仍保留在線路格式中,值得一提。
群組有點像子訊息,但它由特殊標籤而不是 LEN
前綴分隔。訊息中的每個群組都有一個欄位編號,用於這些特殊標籤。
欄位編號為 8
的群組以 8:SGROUP
標籤開頭。SGROUP
記錄具有空有效負載,因此這所做的只是表示群組的開始。列出群組中的所有欄位後,相應的 8:EGROUP
標籤表示其結束。EGROUP
記錄也沒有有效負載,因此 8:EGROUP
是整個記錄。群組欄位編號需要匹配。如果我們遇到預期 8:EGROUP
的 7:EGROUP
,則訊息格式不正確。
Protoscope 為寫入群組提供了方便的語法。而不是寫入
8:SGROUP
1: 2
3: {"foo"}
8:EGROUP
Protoscope 允許
8: !{
1: 2
3: {"foo"}
}
這將產生適當的開始和結束群組標記。!{}
語法只能在未類型化的標籤運算式 (例如 8:
) 後面立即出現。
欄位順序
欄位編號可以在 .proto
檔案中以任何順序宣告。選擇的順序對訊息的序列化方式沒有影響。
當訊息序列化時,無法保證如何寫入其已知或未知欄位的順序。序列化順序是實作細節,任何特定實作的細節在未來可能會變更。因此,Protocol Buffer 剖析器必須能夠以任何順序剖析欄位。
意涵
- 不要假設序列化訊息的位元組輸出是穩定的。對於具有表示其他序列化 Protocol Buffer 訊息的過渡位元組欄位的訊息尤其如此。
- 預設情況下,對同一個 Protocol Buffer 訊息實例重複調用序列化方法可能不會產生相同的位元組輸出。也就是說,預設序列化不是確定性的。
- 確定性序列化僅保證特定二進位檔的相同位元組輸出。位元組輸出可能會在不同版本的二進位檔之間變更。
- 以下檢查可能會針對 Protocol Buffer 訊息實例
foo
失敗foo.SerializeAsString() == foo.SerializeAsString()
Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
- 以下是一些範例情境,其中邏輯上等效的 Protocol Buffer 訊息
foo
和bar
可能序列化為不同的位元組輸出bar
由舊伺服器序列化,該伺服器將某些欄位視為未知。bar
由以不同程式設計語言實作並以不同順序序列化欄位的伺服器序列化。bar
有一個以非確定性方式序列化的欄位。bar
有一個欄位,其中儲存了以不同方式序列化的 Protocol Buffer 訊息的序列化位元組輸出。bar
由新的伺服器序列化,該伺服器由於實作變更而以不同的順序序列化欄位。foo
和bar
是相同個別訊息以不同順序串連的結果。
編碼 Proto 大小限制
Protos 在序列化時必須小於 2 GiB。許多 proto 實作會拒絕序列化或剖析超出此限制的訊息。
精簡參考卡
以下以易於參考的格式提供線路格式的最突出部分。
message := (tag value)*
tag := (field << 3) bit-or wire_type;
encoded as uint32 varint
value := varint for wire_type == VARINT,
i32 for wire_type == I32,
i64 for wire_type == I64,
len-prefix for wire_type == LEN,
<empty> for wire_type == SGROUP or EGROUP
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
encoded as varints (sintN are ZigZag-encoded first)
i32 := sfixed32 | fixed32 | float;
encoded as 4-byte little-endian;
memcpy of the equivalent C types (u?int32_t, float)
i64 := sfixed64 | fixed64 | double;
encoded as 8-byte little-endian;
memcpy of the equivalent C types (u?int64_t, double)
len-prefix := size (message | string | bytes | packed);
size encoded as int32 varint
string := valid UTF-8 string (e.g. ASCII);
max 2GB of bytes
bytes := any sequence of 8-bit bytes;
max 2GB of bytes
packed := varint* | i32* | i64*,
consecutive values of the type specified in `.proto`
另請參閱Protoscope 語言參考。
索引
message := (tag value)*
- 訊息編碼為零或多個標籤和值對的序列。
tag := (field << 3) bit-or wire_type
- 標籤是
wire_type
的組合 (儲存在最低有效的三個位元中) 和在.proto
檔案中定義的欄位編號。 value := varint for wire_type == VARINT, ...
- 值的儲存方式取決於標籤中指定的
wire_type
。 varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
- 您可以使用 varint 來儲存任何列出的資料類型。
i32 := sfixed32 | fixed32 | float
- 您可以使用 fixed32 來儲存任何列出的資料類型。
i64 := sfixed64 | fixed64 | double
- 您可以使用 fixed64 來儲存任何列出的資料類型。
len-prefix := size (message | string | bytes | packed)
- 長度前綴值儲存為長度 (編碼為 varint),然後是列出的資料類型之一。
string := 有效的 UTF-8 字串 (例如 ASCII)
- 如所述,字串必須使用 UTF-8 字元編碼。字串不能超過 2GB。
bytes := 任何 8 位元位元組序列
- 如所述,位元組可以儲存自訂資料類型,大小最大為 2GB。
packed := varint* | i32* | i64*
- 當您儲存通訊協定定義中描述的類型的連續值時,請使用
packed
資料類型。第一個值之後的值會捨棄標籤,這會將標籤的成本攤銷為每個欄位一個標籤,而不是每個元素一個標籤。