編碼

說明 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,該 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",我們將其編碼為一個 LEN 記錄,其中欄位編號 2 包含 ASCII 字串 "testing"。結果為 `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;
}

如果將 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 中時相同。適用於 oneofs 的規則與它們在線路上的表示方式無關。

最後一個贏

通常,一個編碼的訊息永遠不會有多個非 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}

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

對應

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

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;
}

因此,map 的編碼方式與 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 檔案中以任何順序宣告。選擇的順序對訊息的序列化方式沒有影響。

當訊息被序列化時,無法保證其已知或未知欄位將如何寫入。序列化順序是一個實作細節,任何特定實作的細節未來可能會變更。因此,協定緩衝區解析器必須能夠以任何順序解析欄位。

含義

  • 請勿假設序列化訊息的位元組輸出是穩定的。對於具有代表其他序列化協定緩衝區訊息的傳遞位元組欄位的訊息而言尤其如此。
  • 依預設,在同一個協定緩衝區訊息實例上重複調用序列化方法可能不會產生相同的位元組輸出。也就是說,預設序列化不是確定性的。
    • 確定性序列化僅保證特定二進制檔案具有相同的位元組輸出。位元組輸出可能會在不同版本的二進制檔案之間變更。
  • 對於協定緩衝區訊息實例 foo,以下檢查可能會失敗
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • 以下是一些邏輯上等效的協定緩衝區訊息 foobar 可能會序列化為不同位元組輸出的範例情境
    • bar 由舊伺服器序列化,該伺服器將某些欄位視為未知。
    • bar 由一個以不同程式語言實作且以不同順序序列化欄位的伺服器序列化。
    • bar 有一個以非確定性方式序列化的欄位。
    • bar 有一個欄位,該欄位儲存以不同方式序列化的協定緩衝區訊息的序列化位元組輸出。
    • 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)*
訊息被編碼為零個或多個標籤和數值對的序列。
標籤 := (欄位 << 3) 位元或 線路類型
標籤是一個線路類型的組合,儲存在最低有效的三個位元中,以及在.proto檔案中定義的欄位編號。
數值 := 線路類型 == VARINT 時使用 varint,...
數值的儲存方式會根據標籤中指定的線路類型而有所不同。
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64
您可以使用 varint 來儲存任何列出的資料類型。
i32 := sfixed32 | fixed32 | float
您可以使用 fixed32 來儲存任何列出的資料類型。
i64 := sfixed64 | fixed64 | double
您可以使用 fixed64 來儲存任何列出的資料類型。
長度前綴 := 大小 (訊息 | 字串 | 位元組 | packed)
長度前綴數值會被儲存為一個長度(編碼為 varint),然後是列出的其中一種資料類型。
字串 := 有效的 UTF-8 字串 (例如 ASCII)
如上所述,字串必須使用 UTF-8 字元編碼。字串大小不能超過 2GB。
位元組 := 任何 8 位元位元組的序列
如上所述,位元組可以儲存自訂資料類型,大小上限為 2GB。
packed := varint* | i32* | i64*
當您儲存協定定義中描述的連續數值時,請使用 packed 資料類型。第一個數值之後的數值會捨棄標籤,這將標籤的成本攤銷為每個欄位一個,而不是每個元素一個。