編碼

說明 Protocol Buffers 如何將資料編碼至檔案或線路。

本文說明 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。

有六種線路類型:VARINTI64LENSGROUPEGROUPI32

ID名稱用於
0VARINTint32、int64、uint32、uint64、sint32、sint64、bool、enum
1I64fixed64、sfixed64、double
2LEN字串、位元組、嵌入式訊息、封裝的重複欄位
3SGROUP群組開始 (已淘汰)
4EGROUP群組結束 (已淘汰)
5I32fixed32、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 中,falsetrue 是這些位元組字串的別名。

帶符號整數

正如您在前一節中看到的,與線路類型 0 相關聯的所有 Protocol Buffer 類型都編碼為 varint。但是,varint 是未簽署的,因此不同的帶符號類型 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)

用於 sint32,或

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

用於 64 位元版本。

當剖析 sint32sint64 時,其值會解碼回原始的帶符號版本。

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

非 Varint 數字

非 varint 數字類型很簡單 – doublefixed64 具有線路類型 I64,這告訴剖析器預期固定的八位元組資料塊。我們可以透過寫入 5: 25.4 來指定 double 記錄,或透過 6: 200i64 來指定 fixed64 記錄。在這兩種情況下,省略明確的線路類型都表示 I64 線路類型。

同樣地,floatfixed32 具有線路類型 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 0102: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;
}

如果 Test1a 欄位 (即 Test3c.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 設定為 123,則這*可以*編碼為 `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 開始,基本類型 (任何不是 stringbytes純量類型) 的 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}

只有基本數字類型的重複欄位才能宣告為「封裝」。這些是通常會使用 VARINTI32I64 線路類型的類型。

請注意,雖然通常沒有理由為封裝的重複欄位編碼多個鍵值對,但剖析器必須準備好接受多個鍵值對。在這種情況下,有效負載應串連。每個配對都必須包含整數個元素。以下是剖析器必須接受的上述相同訊息的有效編碼

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:EGROUP7: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 訊息 foobar 可能序列化為不同的位元組輸出
    • bar 由舊伺服器序列化,該伺服器將某些欄位視為未知。
    • bar 由以不同程式設計語言實作並以不同順序序列化欄位的伺服器序列化。
    • bar 有一個以非確定性方式序列化的欄位。
    • bar 有一個欄位,其中儲存了以不同方式序列化的 Protocol Buffer 訊息的序列化位元組輸出。
    • bar 由新的伺服器序列化,該伺服器由於實作變更而以不同的順序序列化欄位。
    • foobar 是相同個別訊息以不同順序串連的結果。

編碼 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 資料類型。第一個值之後的值會捨棄標籤,這會將標籤的成本攤銷為每個欄位一個標籤,而不是每個元素一個標籤。