エンコーディング

Protocol Buffers がデータをファイルやワイヤーにどのようにエンコードするかを説明します。

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

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

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

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

Protoscope ツールは、エンコードされた Protocol Buffer をテキストとしてダンプすることもできます。例については、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 のようになります。これがメッセージの内容であることをどのようにして知るのでしょうか?

Base 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 に複数のバイトがあるため、最初のバイトで設定されています)。これらの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 はこの情報にアクセスできないため、フィールド番号のみを提供できます。

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

ワイヤータイプには6種類あります: VARINTI64LENSGROUPEGROUP、および 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 と推測します。

その他の整数型

真偽値とEnum

真偽値と Enum はどちらも int32 としてエンコードされます。特に真偽値は常に `00` または `01` としてエンコードされます。Protoscope では、falsetrue はこれらのバイト文字列のエイリアスです。

符号付き整数

前のセクションで見たように、ワイヤータイプ0に関連付けられたすべての Protocol Buffer 型は Varint としてエンコードされます。しかし、Varint は符号なしであるため、sint32sint64int32 または int64 の異なる符号付き型は、負の整数を異なる方法でエンコードします。

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

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

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

sintN は、負の整数をエンコードするために2の補数ではなく「ZigZag」エンコーディングを使用します。正の整数 p2 * p (偶数) としてエンコードされ、負の整数 n2 * |n| - 1 (奇数) としてエンコードされます。したがって、このエンコーディングは正の数と負の数の間を「ジグザグ」に進みます。例えば、

符号付きオリジナルエンコード結果
00
-11
12
-23
0x7fffffff0xfffffffe
-0x800000000xffffffff

言い換えれば、各値 n は次のようにエンコードされます。

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

sint32 の場合、または

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

64ビット版の場合。

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

Protoscope では、整数に z を付加すると ZigZag エンコードされます。例えば、-500z は Varint 999 と同じです。

非Varint数値

非Varint数値型はシンプルです。doublefixed64 はワイヤータイプ I64 を持ち、これはパーサーに固定の8バイトのデータを期待するように伝えます。double レコードは 5: 25.4 と書くことで、または fixed64 レコードは 6: 200i64 と書くことで指定できます。どちらの場合も、明示的なワイヤータイプを省略すると I64 ワイヤータイプが暗示されます。

同様に、floatfixed32 はワイヤータイプ I32 を持ち、これは代わりに4バイトを期待するように伝えます。これらの構文は 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;
}

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

 1a 03 [08 96 01]

最後の3バイト([] 内)は、私たちの最初の例とまったく同じです。これらのバイトの前に LEN 型のタグと長さ3が付き、文字列がエンコードされるのとまったく同じ方法です。

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

欠落要素

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

繰り返し要素

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

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

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

そして、d"hello" に、e123 に設定した Test4 メッセージを構築すると、これは `3206038e029ea705` としてエンコードされる可能性があり、または Protoscope で書き出すと、次のようになります。

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

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

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

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

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

6: {3 270}
6: {86942}

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

Oneof

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

最後のものが優先される

通常、エンコードされたメッセージには、非repeatedフィールドのインスタンスが複数含まれることはありません。しかし、パーサーは複数含まれるケースを処理できる必要があります。数値型と文字列の場合、同じフィールドが複数回出現すると、パーサーは最後に見た値を受け入れます。埋め込みメッセージフィールドの場合、パーサーは同じフィールドの複数のインスタンスを、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 ファイル内で任意の順序で宣言できます。選択された順序は、メッセージのシリアライズ方法に影響しません。

メッセージがシリアライズされる際、既知のフィールドまたは不明なフィールドが書き込まれる順序は保証されません。シリアライズの順序は実装の詳細であり、特定の任意の実装の詳細は将来変更される可能性があります。したがって、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 のサイズ制限

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;
                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
タグは、最下位3ビットに格納される wire_type と、.proto ファイルで定義されたフィールド番号の組み合わせです。
value := wire_type == VARINT の場合は 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ビットバイトシーケンス
説明されているように、bytes は最大2GBのカスタムデータ型を格納できます。
packed := varint* | i32* | i64*
プロトコル定義で説明されている型の連続する値を格納する場合に、packed データ型を使用します。最初の値以降のタグは削除され、タグのコストが要素ごとではなくフィールドごとに1つに償却されます。