エンコーディング

プロトコルバッファがデータをファイルまたはワイヤにエンコードする方法を説明します。

このドキュメントでは、プロトコルバッファのワイヤフォーマットについて説明します。これは、メッセージがワイヤ上でどのように送信され、ディスク上でどれくらいのスペースを消費するかの詳細を定義します。アプリケーションでプロトコルバッファを使用するためにこれを理解する必要はないかもしれませんが、最適化を行うための有用な情報です。

概念はすでに理解しており、リファレンスが必要な場合は、「要約リファレンスカード」セクションにスキップしてください。

Protoscopeは、低レベルのワイヤフォーマットの断片を記述するための非常にシンプルな言語で、さまざまなメッセージのエンコードの視覚的なリファレンスを提供するために使用します。Protoscopeの構文は、それぞれが特定のバイトシーケンスにエンコードされるトークンのシーケンスで構成されます。

たとえば、バッククォートは、`70726f746f6275660a` のような生の16進リテラルを示します。これは、リテラルに16進数で示された正確なバイトにエンコードされます。引用符は、"Hello, Protobuf!" のようなUTF-8文字列を示します。このリテラルは、`48656c6c6f2c2050726f746f62756621` と同義です(注意深く見ると、ASCIIバイトで構成されています)。ワイヤフォーマットの側面を議論しながら、Protoscope言語のさらに多くの要素を紹介します。

Protoscopeツールは、エンコードされたプロトコルバッファをテキストとしてダンプすることもできます。例については、https://github.com/protocolbuffers/protoscope/tree/main/testdataを参照してください。

このトピックのすべての例は、Edition 2023以降を使用していることを前提としています。

シンプルなメッセージ

以下の非常にシンプルなメッセージ定義があるとしましょう

message Test1 {
  int32 a = 1;
}

アプリケーションで、`Test1` メッセージを作成し、`a` を 150 に設定します。次に、メッセージを出力ストリームにシリアル化します。エンコードされたメッセージを調べることができれば、3バイトを確認できます

08 96 01

今のところ、小さくて数字だけですが、これは何を意味するのでしょうか?Protoscopeツールを使用してこれらのバイトをダンプすると、`1: 150` のようになります。メッセージの内容がこれであるとどのようにわかるのでしょうか?

ベース128 Varint

可変幅整数、または varint は、ワイヤフォーマットの核心です。これらは符号なし64ビット整数を1バイトから10バイトの範囲でエンコードすることを可能にし、小さい値は少ないバイト数を使用します。

Varintの各バイトには、その後に続くバイトがVarintの一部であるかどうかを示す継続ビットがあります。これはバイトの最上位ビット (MSB) です(_符号ビット_とも呼ばれます)。下位7ビットはペイロードです。結果の整数は、構成要素のバイトの7ビットペイロードを連結することによって構築されます。

たとえば、1は`01`としてエンコードされます。これは単一バイトなので、MSBは設定されていません

0000 0001
^ msb

そして、150は`9601`としてエンコードされます。これは少し複雑です

10010110 00000001
^ msb    ^ msb

これが150であるとどのようにわかるのでしょうか?まず、各バイトからMSBを削除します。これは、数値の終わりに達したかどうかを示すためだけに存在します(ご覧のとおり、varintには1バイト以上あるため、最初のバイトで設定されています)。これらの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はプロトコルバッファにとって非常に重要なので、プロトスコープ構文では、それらをプレーンな整数として参照します。`150` は `9601` と同じです。

メッセージ構造

プロトコルバッファメッセージは、キーと値のペアのシリーズです。メッセージのバイナリバージョンは、フィールドの番号をキーとして使用します。各フィールドの名前と宣言された型は、デコード側でメッセージ型の定義(つまり、`.proto`ファイル)を参照することによってのみ決定できます。Protoscopeはこの情報にアクセスできないため、フィールド番号のみを提供できます。

メッセージがエンコードされると、各キーと値のペアは、フィールド番号、ワイヤタイプ、およびペイロードからなるレコードに変換されます。ワイヤタイプは、その後のペイロードの大きさをパーサーに伝えます。これにより、古いパーサーは理解できない新しいフィールドをスキップできます。この種のスキームは、タグ-長さ-値、またはTLVと呼ばれることがあります。

6つのワイヤタイプがあります: `VARINT`、`I64`、`LEN`、`SGROUP`、`EGROUP`、および`I32`

ID名前用途
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, 埋め込みメッセージ, パックされた繰り返しフィールド
3SGROUPグループ開始(非推奨)
4EGROUPグループ終了(非推奨)
5I32fixed32, sfixed32, float

レコードの「タグ」は、` (field_number << 3) | wire_type ` の式を使用して、フィールド番号とワイヤタイプから形成されたvarintとしてエンコードされます。言い換えれば、フィールドを表すvarintをデコードした後、下位3ビットはワイヤタイプを示し、残りの整数はフィールド番号を示します。

では、もう一度簡単な例を見てみましょう。ストリームの最初の数値は常にvarintキーであり、ここでは `08`、または (MSBを削除すると)

000 1000

最後の3ビットを取ってワイヤタイプ (0) を取得し、次に3ビット右シフトしてフィールド番号 (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に関連付けられているすべてのプロトコルバッファタイプはvarintとしてエンコードされます。ただし、varintは符号なしであるため、`sint32`と`sint64`、`int32`または`int64`といった異なる符号付きタイプは、負の整数を異なる方法でエンコードします。

`intN` 型は負の数を2の補数としてエンコードします。つまり、符号なしの64ビット整数として、それらは最上位ビットが設定されます。その結果、これはすべての10バイトが使用されなければならないことを意味します。たとえば、`-2` はプロトスコープによって次のように変換されます

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

これは2の2の補数であり、符号なし算術では`~0 - 2 + 1`として定義され、`~0`はすべてのビットが1の64ビット整数です。なぜこれが多くの1を生成するのかを理解することは、有用な演習です。

`sintN` は、負の整数をエンコードするために2の補数の代わりに「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ビット版の場合。

`sint32` または `sint64` がパースされると、その値は元の符号付きバージョンにデコードされます。

プロトスコープでは、整数に`z`を付加するとZigZagとしてエンコードされます。たとえば、`-500z`はvarint`999`と同じです。

非varint数値

非varint数値型はシンプルです。`double`と`fixed64`はワイヤタイプ`I64`を持ち、これはパーサーが8バイトの固定データ塊を期待することを示します。`double`値はIEEE 754倍精度形式でエンコードされます。`double`レコードは`5: 25.4`と記述でき、`fixed64`レコードは`6: 200i64`と記述できます。

同様に、`float`と`fixed32`はワイヤタイプ`I32`を持ち、代わりに4バイトを期待することを示します。`float`値はIEEE 754単精度形式でエンコードされます。これらの構文は、`i32`サフィックスを追加することで構成されます。`25.4i32`は4バイトを出力し、`200i32`も同様です。タグタイプは`I32`と推論されます。

長さ区切りレコード

長さプレフィックスは、ワイヤフォーマットにおけるもう一つの重要な概念です。`LEN`ワイヤタイプは、タグの直後にvarintによって指定される動的な長さを持ち、その後、通常通りペイロードが続きます。

このメッセージスキーマを考えてみましょう。

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 varintの `7` であり、次の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 {
  Test1 c = 3;
}

`Test1`の`a`フィールド(つまり、`Test3`の`c.a`フィールド)が150に設定されている場合、`1a03089601`が得られます。これを分解すると、

 1a 03 [08 96 01]

最後の3バイト(`[]`内)は、最初の例と全く同じです。これらのバイトは`LEN`型タグと長さ3が前に付いており、文字列がエンコードされるのと全く同じ方法です。

Protoscopeでは、サブメッセージは非常に簡潔です。`1a03089601` は `3: {1: 150}` と書くことができます。

要素の欠落

欠落したフィールドのエンコードは簡単です。存在しない場合はレコードを省略するだけです。これは、少数のフィールドしか設定されていない「巨大な」プロトが非常に疎であることを意味します。

繰り返し要素

Edition 2023以降、プリミティブ型 (string または bytes ではない任意のスカラー型) の repeated フィールドは、デフォルトで 「パック」されます。

パックされた `repeated` フィールドは、エントリごとに1つのレコードとしてエンコードされるのではなく、各要素が連結された単一の `LEN` レコードとしてエンコードされます。デコードするには、ペイロードがなくなるまで `LEN` レコードから要素が1つずつデコードされます。次の要素の開始は、前の要素の長さによって決まり、その長さはフィールドの型によって異なります。したがって、次のような場合、

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

`Test4` メッセージを作成し、`d` を `"hello"` に、`e` を `1`、`2`、`3` に設定すると、これは `3206038e029ea705` としてエンコードされるか、Protoscopeとして書き出される可能性があります。

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

ただし、繰り返しフィールドが展開状態(デフォルトのパック状態を上書き)に設定されている場合、またはパック不可(文字列とメッセージ)の場合、各個別の値のエントリがエンコードされます。また、`e` のレコードは連続して表示される必要はなく、他のフィールドと混在することができます。同じフィールドのレコードの相互の順序のみが保持されます。したがって、次のように見える場合があります。

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

プリミティブ数値型の繰り返しフィールドのみが「パック」として宣言できます。これらは、通常 `VARINT`、`I32`、または `I64` のワイヤタイプを使用する型です。

パックされた繰り返しフィールドに対して複数のキーと値のペアをエンコードする理由は通常ありませんが、パーサーは複数のキーと値のペアを受け入れる準備をする必要があります。この場合、ペイロードは連結されるべきです。各ペアは完全な数の要素を含まなければなりません。以下の例は、パーサーが受け入れなければならない、上記のメッセージの有効なエンコーディングです。

6: {3 270}
6: {86942}

プロトコルバッファパーサーは、`packed`としてコンパイルされた繰り返しフィールドを、パックされていないかのようにパースできる必要があります。またその逆も同様です。これにより、既存のフィールドに`[packed=true]`を前方互換性および後方互換性のある方法で追加できます。

Oneof

`Oneof`フィールドは、フィールドが`oneof`に含まれていないかのようにエンコードされます。`oneof`に適用されるルールは、ワイヤ上での表現方法とは独立しています。

後勝ち

通常、エンコードされたメッセージには、非`repeated`フィールドのインスタンスが2つ以上存在することはありません。しかし、パーサーはそれが起こるケースを処理することが期待されます。数値型と文字列の場合、同じフィールドが複数回出現すると、パーサーは最後に見た値を受け入れます。埋め込みメッセージフィールドの場合、パーサーは同じフィールドの複数のインスタンスを、`Message::MergeFrom`メソッドを使用した場合と同様にマージします。つまり、後者のインスタンスのすべての単一スカラーフィールドは前者のものを置き換え、単一の埋め込みメッセージはマージされ、`repeated`フィールドは連結されます。これらのルールの効果は、2つのエンコードされたメッセージの連結をパースすると、2つのメッセージを個別にパースして結果のオブジェクトをマージした場合と全く同じ結果が得られるということです。つまり、これは

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

これと同等です。

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

このプロパティは、メッセージの型を知らなくても2つのメッセージを (連結によって) マージできるため、時として有用です。

マップ

マップフィールドは、特殊な繰り返しフィールドの短縮形にすぎません。もし次のようなものがあれば、

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

これは実際には以下と同じです。

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

したがって、マップは`repeated`メッセージフィールドとほぼ同じようにエンコードされます。つまり、それぞれ2つのフィールドを持つ`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())
  • 以下は、論理的に同等のプロトコルバッファメッセージ`foo`と`bar`が異なるバイト出力にシリアル化される可能性のあるいくつかの例のシナリオです。
    • `bar`は、いくつかのフィールドを不明として扱う古いサーバーによってシリアル化されます。
    • `bar`は、異なるプログラミング言語で実装され、フィールドを異なる順序でシリアル化するサーバーによってシリアル化されます。
    • `bar`には、非決定論的な方法でシリアル化されるフィールドがあります。
    • `bar`には、異なる方法でシリアル化されるプロトコルバッファメッセージのシリアル化されたバイト出力を格納するフィールドがあります。
    • `bar`は、実装変更によりフィールドを異なる順序でシリアル化する新しいサーバーによってシリアル化されます。
    • `foo`と`bar`は、同じ個々のメッセージを異なる順序で連結したものです。

エンコードされたProtoサイズ制限

プロトは、シリアル化時に2GiBより小さくなければなりません。多くのプロト実装は、この制限を超えるメッセージをシリアル化またはパースすることを拒否します。

要約リファレンスカード

以下に、ワイヤフォーマットの最も重要な部分を、簡単に参照できる形式で示します。

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)*
メッセージは、0個以上のタグと値のペアのシーケンスとしてエンコードされます。
tag := (field << 3) bit-or wire_type
タグは、最下位3ビットに格納される`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`データ型を使用します。タグは最初以降の値では削除され、タグのコストは要素ごとではなく、フィールドごとに1回に償却されます。