エンコーディング
このドキュメントでは、プロトコルバッファのワイヤフォーマットについて説明します。これは、メッセージがワイヤ上でどのように送信され、ディスク上でどれくらいのスペースを消費するかの詳細を定義します。アプリケーションでプロトコルバッファを使用するためにこれを理解する必要はおそらくありませんが、最適化を行う上で役立つ情報です。
概念はすでに知っているがリファレンスが必要な場合は、簡易リファレンスカードのセクションにスキップしてください。
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`のようなものが得られます。これがメッセージの内容だとどのようにわかるのでしょうか?
Base 128 Varints
可変幅整数、または varint は、ワイヤフォーマットの核心です。これらは符号なし64ビット整数を1〜10バイトでエンコードでき、小さな値はより少ないバイトを使用します。
varint の各バイトには、その後に続くバイトが varint の一部であるかどうかを示す 継続ビット があります。これはバイトの 最上位ビット (MSB) です (符号ビット とも呼ばれます)。下位7ビットはペイロードです。結果の整数は、構成要素バイトの7ビットペイロードを連結することによって構築されます。
たとえば、1は`01`としてエンコードされます。これは1バイトなので、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 はプロトコルバッファにとって非常に重要であるため、プロトスコープ構文では、それらをプレーンな整数として参照します。`150`は`9601`と同じです。
メッセージ構造
プロトコルバッファメッセージは、キーと値のペアのシーケンスです。メッセージのバイナリバージョンでは、フィールドの番号をキーとして使用するだけで、各フィールドの名前と宣言された型は、デコード側でメッセージ型の定義(つまり、`.proto`ファイル)を参照することによってのみ決定できます。Protoscopeはこの情報にアクセスできないため、フィールド番号のみを提供できます。
メッセージがエンコードされるとき、各キーと値のペアは、フィールド番号、ワイヤタイプ、およびペイロードで構成される レコード に変換されます。ワイヤタイプは、その後のペイロードのサイズをパーサーに伝えます。これにより、古いパーサーは理解できない新しいフィールドをスキップできます。この種のスキームは、Tag-Length-Value (TLV) と呼ばれることもあります。
6つのワイヤタイプがあります: `VARINT`、`I64`、`LEN`、`SGROUP`、`EGROUP`、および`I32`。
| ID | 名前 | 用途 |
|---|---|---|
| 0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | I64 | fixed64, sfixed64, double |
| 2 | LEN | string, bytes, embedded messages, packed repeated fields |
| 3 | SGROUP | グループ開始 (非推奨) |
| 4 | EGROUP | グループ終了 (非推奨) |
| 5 | I32 | fixed32, 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`と推測します。
その他の整数型
Bools と Enums
Boolsとenumsは両方とも`int32`としてエンコードされます。特にBoolsは、常に`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` (奇数) としてエンコードされます。したがって、エンコードは正と負の数を「ジグザグ」に移動します。例:
| 元の符号 | エンコードされた形式 |
|---|---|
| 0 | 0 |
| -1 | 1 |
| 1 | 2 |
| -2 | 3 |
| … | … |
| 0x7fffffff | 0xfffffffe |
| -0x80000000 | 0xffffffff |
つまり、各値 `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倍精度形式でエンコードされます。`5: 25.4`と記述して`double`レコードを指定したり、`6: 200i64`と記述して`fixed64`レコードを指定したりできます。
同様に、`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;
}
`d`を`"hello"`に、`e`を`1`、`2`、`3`に設定した`Test4`メッセージを構築すると、これは`3206038e029ea705`としてエンコードされるか、Protoscopeとして記述されます。
4: {"hello"}
6: {3 270 86942}
ただし、repeated フィールドが拡張状態 (デフォルトのパック状態を上書き) に設定されているか、パックできない (文字列とメッセージ) 場合、各個々の値のエントリがエンコードされます。また、`e` のレコードは連続して表示される必要はなく、他のフィールドと混在することができます。同じフィールドのレコードの順序のみが相互に保持されます。したがって、次のようになります。
6: 1
6: 2
4: {"hello"}
6: 3
プリミティブな数値型の繰り返しフィールドのみが「パック」として宣言できます。これらは通常、`VARINT`、`I32`、または`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`ファイル内で任意の順序で宣言できます。選択された順序は、メッセージがどのようにシリアル化されるかに影響しません。
メッセージがシリアル化される際、既知のフィールドや不明なフィールドが書き込まれる順序は保証されません。シリアル化順序は実装の詳細であり、特定の実装の詳細は将来変更される可能性があります。したがって、プロトコルバッファパーサーは、フィールドを任意の順序でパースできる必要があります。
影響
- シリアル化されたメッセージのバイト出力が安定していると仮定しないでください。これは、他のシリアル化されたプロトコルバッファメッセージを表す推移的なバイトフィールドを持つメッセージに特に当てはまります。
- デフォルトでは、同じプロトコルバッファメッセージインスタンスに対してシリアル化メソッドを繰り返し呼び出しても、同じバイト出力が生成されない場合があります。つまり、デフォルトのシリアル化は非決定的です。
- 決定的シリアル化は、特定のバイナリに対してのみ同じバイト出力を保証します。バイト出力は、バイナリの異なるバージョン間で変更される可能性があります。
- プロトコルバッファメッセージインスタンス`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 サイズの制限
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言語リファレンスも参照してください。
キー
メッセージ := (タグ値)*- メッセージは、0個以上のタグと値のペアのシーケンスとしてエンコードされます。
タグ := (フィールド << 3) ビット演算子 wire_type- タグは、最下位3ビットに格納されている`wire_type`と、`.proto`ファイルで定義されているフィールド番号の組み合わせです。
値 := 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としてエンコード)として格納され、その後にリストされたデータ型のいずれかが続きます。
文字列 := 有効なUTF-8文字列 (例: ASCII)- 説明にあるように、文字列はUTF-8文字エンコードを使用する必要があります。文字列は2GBを超えることはできません。
バイト := 8ビットバイトの任意のシーケンス- 説明にあるように、バイトは最大2GBのカスタムデータ型を格納できます。
packed := varint* | i32* | i64*- プロトコル定義で説明されている型の連続する値を格納する場合は、`packed`データ型を使用します。最初の値以降の値ではタグが省略され、これによりタグのコストが要素ごとではなくフィールドごとに償却されます。