編碼

解釋了 Protocol Buffers 如何將資料編碼到檔案或網路中。

本文件描述了 protocol buffer 的 線格式,它定義了訊息如何在網路上傳輸以及在磁碟上佔用多少空間的詳細資訊。你可能不需要理解這些就能在你的應用程式中使用 protocol buffers,但這些資訊對於進行最佳化很有用。

如果你已經瞭解這些概念但需要參考,請跳到 精簡參考卡片 部分。

Protoscope 是一種非常簡單的語言,用於描述低階線格式的片段,我們將用它來為各種訊息的編碼提供視覺化參考。Protoscope 的語法由一系列 令牌 組成,每個令牌都編碼成特定的位元組序列。

例如,反引號表示原始十六進位制字面量,如 `70726f746f6275660a`。這會編碼成字面量中十六進位制表示的確切位元組。引號表示 UTF-8 字串,如 "Hello, Protobuf!"。這個字面量與 `48656c6c6f2c2050726f746f62756621` 同義(如果你仔細觀察,它是由 ASCII 位元組組成的)。我們將在討論線格式的各個方面時介紹更多 Protoscope 語言。

Protoscope 工具還可以將編碼後的 protocol buffers 匯出為文字。請參閱 https://github.com/protocolbuffers/protoscope/tree/main/testdata 以獲取示例。

本主題中的所有示例都假定你正在使用 Edition 2023 或更高版本。

一個簡單的訊息

假設你有以下非常簡單的訊息定義

message Test1 {
  int32 a = 1;
}

在應用程式中,你建立一個 Test1 訊息並將 a 設定為 150。然後將訊息序列化到輸出流中。如果你能夠檢查編碼後的訊息,你會看到三個位元組

08 96 01

到目前為止,它很小且是數字——但它意味著什麼?如果你使用 Protoscope 工具匯出這些位元組,你會得到類似 1: 150 的結果。它如何知道這是訊息的內容?

Base 128 Varints

變長整數(或 varints)是線格式的核心。它們允許使用一到十個位元組編碼無符號 64 位整數,其中小值使用較少的位元組。

變長整數中的每個位元組都有一個 延續位,指示其後的位元組是否屬於變長整數。這是位元組的 最高有效位 (MSB)(有時也稱為 符號位)。低 7 位是有效載荷;最終的整數是透過將其組成位元組的 7 位有效載荷連線起來構建的。

例如,數字 1 編碼為 `01` ——它是一個位元組,因此 MSB 未設定

0000 0001
^ msb

而 150 編碼為 `9601` ——這有點複雜

10010110 00000001
^ msb    ^ msb

你如何計算出這是 150?首先,你從每個位元組中刪除 MSB,因為這只是為了告訴我們是否已達到數字的末尾(如你所見,由於變長整數中包含多個位元組,因此它在第一個位元組中設定)。這些 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.

由於變長整數對於 protocol buffers 至關重要,在 protoscope 語法中,我們將其稱為純整數。150`9601` 相同。

訊息結構

Protocol buffer 訊息是一系列鍵值對。訊息的二進位制版本只使用欄位編號作為鍵——每個欄位的名稱和宣告型別只能在解碼端透過引用訊息型別的定義(即 .proto 檔案)來確定。Protoscope 無法訪問此資訊,因此它只能提供欄位編號。

當訊息被編碼時,每個鍵值對都會轉換為一個 記錄,由欄位編號、線型別和有效負載組成。線型別告訴解析器其後有效負載的大小。這允許舊解析器跳過它們不理解的新欄位。這種方案有時被稱為 標籤-長度-值 或 TLV。

共有六種線型別:VARINTI64LENSGROUPEGROUPI32

ID名稱用途
0VARINTint32、int64、uint32、uint64、sint32、sint64、bool、enum
1I64fixed64、sfixed64、double
2LENstring、bytes、嵌入訊息、packed repeated 欄位
3SGROUP組開始(已棄用)
4EGROUP組結束(已棄用)
5I32fixed32、sfixed32、float

記錄的“標籤”編碼為由欄位編號和線型別透過公式 (field_number << 3) | wire_type 形成的變長整數。換句話說,解碼錶示欄位的變長整數後,低 3 位告訴我們線型別,整數的其餘部分告訴我們欄位編號。

現在我們再次來看我們的簡單示例。你現在知道流中的第一個數字始終是變長整數鍵,這裡是 `08`,或者(去除 MSB)

000 1000

你取最後三位得到線型別 (0),然後右移三位得到欄位編號 (1)。Protoscope 將標籤表示為整數後跟冒號和線型別,因此我們可以將上述位元組寫為 1:VARINT

因為線型別是 0,即 VARINT,我們知道需要解碼一個變長整數來獲取有效載荷。如上所示,位元組 `9601` 變長整數解碼為 150,從而得到我們的記錄。我們可以將其在 Protoscope 中寫為 1:VARINT 150

如果 : 後有空白,Protoscope 可以推斷標籤的型別。它透過檢視下一個令牌並猜測你的意思(規則在 Protoscope 的 language.txt 中有詳細說明)。例如,在 1: 150 中,未型別化標籤後緊跟著一個變長整數,因此 Protoscope 推斷其型別為 VARINT。如果你寫 2: {},它會看到 { 並猜測 LEN;如果你寫 3: 5i32,它會猜測 I32,依此類推。

更多整數型別

布林值和列舉

布林值和列舉都被編碼,就像它們是 int32 一樣。特別是,布林值總是編碼為 `00``01`。在 Protoscope 中,falsetrue 是這些位元組字串的別名。

有符號整數

正如你在上一節中看到的,所有與線型別 0 關聯的 protocol buffer 型別都被編碼為變長整數。但是,變長整數是無符號的,因此不同的有符號型別,sint32sint64int32int64,對負整數的編碼方式不同。

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(奇數)。因此,編碼在正數和負數之間“之字形”交替。例如

有符號原始值編碼為
00
-11
12
-23
0x7fffffff0xfffffffe
-0x800000000xffffffff

換句話說,每個值 n 編碼使用

(n << 1) ^ (n >> 31)

對於 sint32s,或

(n << 1) ^ (n >> 63)

對於 64 位版本。

解析 sint32sint64 時,其值將被解碼回原始的有符號版本。

在 protoscope 中,在整數後面加上 z 會使其編碼為 ZigZag。例如,-500z 與變長整數 999 相同。

非變長整數

非變長數字型別很簡單。doublefixed64 的線型別是 I64,它告訴解析器期望一個固定的八位元組資料塊。double 值以 IEEE 754 雙精度格式編碼。我們可以透過寫入 5: 25.4 來指定一個 double 記錄,或者透過寫入 6: 200i64 來指定一個 fixed64 記錄。

類似地,floatfixed32 的線型別是 I32,這告訴它期望四個位元組。float 值以 IEEE 754 單精度格式編碼。這些的語法是在後面加上 i32 字尾。25.4i32 將發出四個位元組,200i32 也會。標籤型別被推斷為 I32

長度分隔記錄

長度字首 是線格式中的另一個主要概念。LEN 線型別具有動態長度,由標籤後緊跟的變長整數指定,然後是通常的有效載荷。

考慮以下訊息 schema

message Test2 {
  string b = 2;
}

欄位 b 的記錄是一個字串,字串是 LEN 編碼的。如果我們將 b 設定為 "testing",我們將其編碼為一個欄位編號為 2 的 LEN 記錄,其中包含 ASCII 字串 "testing"。結果是 `120774657374696e67`。分解位元組,

12 07 [74 65 73 74 69 6e 67]

我們看到標籤 `12`00010 010,即 2:LEN。緊隨其後的位元組是 int32 變長整數 7,接下來的七個位元組是 "testing" 的 UTF-8 編碼。int32 變長整數意味著字串的最大長度為 2GB。

在 Protoscope 中,這寫為 2:LEN 7 "testing"。然而,重複字串的長度(在 Protoscope 文字中,它已經被引號分隔)可能不方便。將 Protoscope 內容括在括號中會為其生成長度字首:{"testing"}7 "testing" 的簡寫。{} 總是被欄位推斷為 LEN 記錄,因此我們可以簡單地將此記錄寫為 2: {"testing"}

bytes 欄位也以相同的方式編碼。

子訊息

子訊息欄位也使用 LEN 線型別。這是一個訊息定義,其中包含我們原始示例訊息 Test1 的嵌入訊息

message Test3 {
  Test1 c = 3;
}

如果 Test1a 欄位(即 Test3c.a 欄位)設定為 150,我們將得到 ``1a03089601``。分解它

 1a 03 [08 96 01]

最後三個位元組(在 [] 中)與我們 第一個示例 中的位元組完全相同。這些位元組前面是一個 LEN 型別的標籤和一個長度為 3 的長度,編碼方式與字串完全相同。

在 Protoscope 中,子訊息非常簡潔。``1a03089601`` 可以寫成 3: {1: 150}

缺失元素

缺少欄位很容易編碼:如果欄位不存在,我們只需省略該記錄。這意味著僅設定了少數字段的“巨大”proto 相當稀疏。

重複元素

從 Edition 2023 開始,原始型別(任何非 stringbytes標量型別)的 repeated 欄位預設是“packed”的。

packed repeated 欄位不是將每個條目編碼為一個記錄,而是編碼為一個包含每個元素連線的單個 LEN 記錄。為了解碼,元素從 LEN 記錄中逐一解碼,直到有效負載耗盡。下一個元素的開始由前一個元素的長度決定,而前一個元素的長度又取決於欄位的型別。因此,如果我們有

message Test4 {
  string d = 4;
  repeated int32 e = 6;
}

我們構造一個 Test4 訊息,其中 d 設定為 "hello"e 設定為 123,這 可以 編碼為 `3206038e029ea705`,或寫成 Protoscope,

4: {"hello"}
6: {3 270 86942}

但是,如果重複欄位設定為擴充套件(覆蓋預設的 packed 狀態)或不可打包(字串和訊息),則會為每個單獨的值編碼一個條目。此外,e 的記錄不需要連續出現,並且可以與其他欄位交錯;只保留相同欄位的記錄彼此之間的順序。因此,這可能如下所示

6: 1
6: 2
4: {"hello"}
6: 3

只有原始數字型別的重複欄位可以宣告為“packed”。這些是通常使用 VARINTI32I64 線型別的型別。

請注意,儘管通常沒有理由為 packed repeated 欄位編碼多個鍵值對,但解析器必須準備好接受多個鍵值對。在這種情況下,有效載荷應連線起來。每個對必須包含整數個元素。以下是解析器必須接受的上面相同訊息的有效編碼

6: {3 270}
6: {86942}

Protocol buffer 解析器必須能夠解析編譯為 packed 的重複欄位,就像它們未打包一樣,反之亦然。這允許以向前和向後相容的方式將 [packed=true] 新增到現有欄位。

Oneof

Oneof 欄位 的編碼方式與欄位不在 oneof 中時相同。適用於 oneofs 的規則與其線上路上表示方式無關。

最後一個生效

通常,編碼訊息中不會有超過一個非 repeated 欄位的例項。但是,解析器應該能夠處理這種情況。對於數字型別和字串,如果同一個欄位多次出現,解析器將接受它看到的 最後一個 值。對於嵌入式訊息欄位,解析器會合並同一個欄位的多個例項,就像使用 Message::MergeFrom 方法一樣——也就是說,後一個例項中的所有單個標量欄位會替換前一個例項中的欄位,單個嵌入式訊息會合並,repeated 欄位會連線。這些規則的效果是,解析兩個編碼訊息的連線會產生與你分別解析這兩個訊息併合並結果物件完全相同的結果。也就是說,這

MyMessage message;
message.ParseFromString(str1 + str2);

等同於此

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

此屬性偶爾很有用,因為它允許你合併兩個訊息(透過連線),即使你不知道它們的型別。

對映

Map 欄位只是特殊重複欄位的簡寫。如果我們有

message Test6 {
  map<string, int32> g = 7;
}

這實際上與

message Test6 {
  message g_Entry {
    string key = 1;
    int32 value = 2;
  }
  repeated g_Entry g = 7;
}

因此,對映的編碼方式幾乎與 repeated 訊息欄位完全相同:作為一系列 LEN 型別記錄,每個記錄有兩個欄位。例外情況是,在序列化過程中,對映的順序不能保證保留。

Groups

組是已棄用的功能,不應使用,但它們仍保留線上格式中,值得一提。

組有點像子訊息,但它由特殊標籤而不是 LEN 字首分隔。訊息中的每個組都有一個欄位編號,用於這些特殊標籤。

欄位編號為 8 的組以 8:SGROUP 標籤開始。SGROUP 記錄的有效載荷為空,因此這隻表示組的開始。一旦組中的所有欄位都列出,相應的 8:EGROUP 標籤表示其結束。EGROUP 記錄也沒有有效載荷,因此 8:EGROUP 是整個記錄。組欄位編號需要匹配。如果我們遇到 7:EGROUP 而我們期望 8:EGROUP,則訊息格式不正確。

Protoscope 提供了一種方便的語法來編寫組。不必編寫

8:SGROUP
  1: 2
  3: {"foo"}
8:EGROUP

Protoscope 允許

8: !{
  1: 2
  3: {"foo"}
}

這將生成適當的組開始和結束標記。!{} 語法只能緊跟在未型別化標籤表示式(如 8:)之後。

欄位順序

欄位編號可以在 .proto 檔案中以任何順序宣告。選擇的順序對訊息的序列化方式沒有影響。

當訊息序列化時,其已知欄位或未知欄位的寫入順序沒有保證。序列化順序是實現細節,任何特定實現的細節將來都可能更改。因此,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 訊息 foobar 可能會序列化為不同的位元組輸出
    • bar 由一箇舊伺服器序列化,該伺服器將某些欄位視為未知。
    • bar 由一個用不同程式語言實現的伺服器序列化,並且以不同順序序列化欄位。
    • bar 有一個以非確定性方式序列化的欄位。
    • bar 有一個欄位儲存了以不同方式序列化的 protocol buffer 訊息的序列化位元組輸出。
    • bar 由一個新伺服器序列化,該伺服器由於實現更改而以不同順序序列化欄位。
    • foobar 是相同獨立訊息以不同順序連線的結果。

編碼 Proto 大小限制

Proto 序列化後必須小於 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 (float is IEEE 754
                single-precision); memcpy of the equivalent C types (u?int32_t,
                float)
i64        := sfixed64 | fixed64 | double;
                encoded as 8-byte little-endian (double is IEEE 754
                double-precision); 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
你可以使用變長整數來儲存任何列出的資料型別。
i32 := sfixed32 | fixed32 | float
你可以使用 fixed32 儲存任何列出的資料型別。
i64 := sfixed64 | fixed64 | double
你可以使用 fixed64 儲存任何列出的資料型別。
len-prefix := size (message | string | bytes | packed)
長度字首值儲存為長度(編碼為變長整數),然後是列出的資料型別之一。
string := 有效的 UTF-8 字串(例如 ASCII)
如所述,字串必須使用 UTF-8 字元編碼。字串不能超過 2GB。
bytes := 任意 8 位位元組序列
如所述,位元組可以儲存自定義資料型別,最大大小為 2GB。
packed := varint* | i32* | i64*
當你儲存協議定義中描述的型別的連續值時,使用 packed 資料型別。第一個值之後的值的標籤會被丟棄,這將標籤的成本分攤到每個欄位一個,而不是每個元素一個。