エンコーディング
このドキュメントでは、プロトコル バッファのワイヤ形式について説明します。これは、メッセージがネットワーク配線でどのように送信されるか、およびディスク上でどれだけのスペースを消費するかを詳細に定義するものです。アプリケーションでプロトコル バッファを使用するためにこれを理解する必要はおそらくありませんが、最適化を行うのに役立つ情報です。
すでに概念を理解していて、リファレンスが必要な場合は、「要約リファレンスカード」セクションにスキップしてください。
Protoscope は、低レベルのワイヤ形式のスニペットを記述するための非常にシンプルな言語であり、さまざまなメッセージのエンコーディングの視覚的なリファレンスを提供するために使用します。Protoscope の構文は、それぞれが特定のバイトシーケンスにエンコードされるトークンのシーケンスで構成されています。
たとえば、バッククォートは、`70726f746f6275660a`
のような生の 16 進リテラルを示します。これは、リテラルで 16 進数として示される正確なバイトにエンコードされます。引用符は、"Hello, Protobuf!"
のような UTF-8 文字列を示します。このリテラルは、`48656c6c6f2c2050726f746f62756621`
(注意深く観察すると、ASCII バイトで構成されています)と同義です。ワイヤ形式の側面について説明する際に、Protoscope 言語の詳細を紹介します。
Protoscope ツールは、エンコードされたプロトコル バッファをテキストとしてダンプすることもできます。例については、https://github.com/protocolbuffers/protoscope/tree/main/testdata を参照してください。
シンプルなメッセージ
次の非常にシンプルなメッセージ定義があるとしましょう
message Test1 {
optional 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 はプロトコル バッファにとって非常に重要であるため、Protoscope 構文では、Varint をプレーン整数として参照します。150
は `9601`
と同じです。
メッセージ構造
プロトコル バッファ メッセージは、キーと値のペアのシリーズです。メッセージのバイナリ バージョンでは、フィールドの数値をキーとしてのみ使用します。各フィールドの名前と宣言された型は、メッセージ型の定義 (つまり、.proto
ファイル) を参照することによってのみ、デコード側で決定できます。Protoscope はこの情報にアクセスできないため、フィールド番号のみを提供できます。
メッセージがエンコードされると、各キーと値のペアは、フィールド番号、ワイヤタイプ、およびペイロードで構成されるレコードに変換されます。ワイヤタイプは、パーサーにその後のペイロードのサイズを通知します。これにより、古いパーサーは、理解できない新しいフィールドをスキップできます。このタイプのスキームは、タグ-長さ-値、または 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、埋め込みメッセージ、packed repeated フィールド |
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
を推測するなどです。
その他の整数型
Bool 値と Enum
Bool 値と Enum はどちらも int32
であるかのようにエンコードされます。特に Bool 値は、常に `00`
または `01`
としてエンコードされます。Protoscope では、false
と true
はこれらのバイト列のエイリアスです。
符号付き整数
前のセクションで見たように、ワイヤタイプ 0 に関連付けられたすべてのプロトコル バッファ型は、Varint としてエンコードされます。ただし、Varint は符号なしであるため、異なる符号付き型、sint32
と sint64
対 int32
または int64
は、負の整数を異なる方法でエンコードします。
intN
型は、負の数値を 2 の補数としてエンコードします。つまり、符号なし 64 ビット整数として、最上位ビットが設定されています。その結果、これは10 バイトすべてを使用する必要があることを意味します。たとえば、-2
は protoscope によって次のように変換されます
11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001
これは、符号なし算術で ~0 - 2 + 1
として定義される 2 の補数です。ここで、~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
が解析されると、その値は元の符号付きバージョンにデコードされます。
Protoscope では、整数に z
をサフィックスとして付けると、ZigZag としてエンコードされます。たとえば、-500z
は Varint 999
と同じです。
非 Varint 数値
非 Varint 数値型はシンプルです。double
と fixed64
はワイヤタイプ I64
を持ち、パーサーに固定の 8 バイトのデータ塊を予期するように指示します。5: 25.4
と記述して double
レコードを指定するか、6: 200i64
を使用して fixed64
レコードを指定できます。どちらの場合も、明示的なワイヤタイプを省略すると、I64
ワイヤタイプが暗黙的に指定されます。
同様に、float
と fixed32
はワイヤタイプ I32
を持ち、代わりに 4 バイトを予期するように指示します。これらの構文は、i32
サフィックスを追加することで構成されます。25.4i32
は 4 バイトを出力し、200i32
も同様です。タグタイプは I32
として推測されます。
長さ区切りレコード
長さプレフィックスは、ワイヤ形式のもう 1 つの主要な概念です。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 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 {
optional Test1 c = 3;
}
Test1
の a
フィールド (つまり、Test3
の c.a
フィールド) が 150 に設定されている場合、``1a03089601``
が得られます。分解すると
1a 03 [08 96 01]
最後の 3 バイト ([]) は、最初の例とまったく同じものです。これらのバイトの前には、文字列がエンコードされるのと同じ方法で、LEN
型のタグと長さ 3 が付いています。
Protoscope では、サブメッセージは非常に簡潔です。``1a03089601``
は 3: {1: 150}
として記述できます。
Optional 要素と Repeated 要素
欠落している optional
フィールドのエンコードは簡単です。存在しない場合は、レコードを省略するだけです。これは、設定されたフィールドがわずかしかない「巨大な」proto が非常に疎であることを意味します。
repeated
フィールドは少し複雑です。通常の (packed ではない) repeated フィールドは、フィールドの各要素に対して 1 つのレコードを出力します。したがって、次の場合、
message Test4 {
optional string d = 4;
repeated int32 e = 5;
}
Test4
メッセージを構築し、d
を "hello"
に、e
を 1
、2
、および 3
に設定すると、これは `220568656c6c6f280128022803`
としてエンコードされる可能性があります。または Protoscope として書き出すと、
4: {"hello"}
5: 1
5: 2
5: 3
ただし、e
のレコードは連続して表示される必要はなく、他のフィールドとインターリーブできます。互いに対する同じフィールドのレコードの順序のみが保持されます。したがって、これも次のようにエンコードできた可能性があります
5: 1
5: 2
4: {"hello"}
5: 3
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 つのメッセージを (連結によって) マージできるため、時々役立ちます。
Packed Repeated フィールド
v2.1.0 以降、プリミティブ型 (スカラー型 (string
または bytes
ではない)) の repeated
フィールドは、「packed」として宣言できます。proto2 では、これはフィールド オプション [packed=true]
を使用して行われます。proto3 では、これがデフォルトです。
エントリごとに 1 つのレコードとしてエンコードされる代わりに、各要素が連結された単一の LEN
レコードとしてエンコードされます。デコードするには、ペイロードが使い果たされるまで、LEN
レコードから要素が 1 つずつデコードされます。次の要素の開始は、前の要素の長さによって決定されます。長さ自体は、フィールドの型に依存します。
たとえば、次のメッセージ型があると想像してください
message Test5 {
repeated int32 f = 6 [packed=true];
}
ここで、repeated フィールド f
に値 3、270、および 86942 を指定して Test5
を構築するとします。エンコードすると、`3206038e029ea705`
、または Protoscope テキストとして、次のようになります
6: {3 270 86942}
プリミティブ数値型の repeated フィールドのみを「packed」として宣言できます。これらは、通常は VARINT
、I32
、または I64
ワイヤタイプを使用する型です。
通常、packed repeated フィールドに対して複数のキーと値のペアをエンコードする理由はありませんが、パーサーは複数のキーと値のペアを受け入れる準備をする必要があります。この場合、ペイロードは連結する必要があります。各ペアには、要素の整数が含まれている必要があります。パーサーが受け入れる必要がある上記の同じメッセージの有効なエンコーディングを次に示します
6: {3 270}
6: {86942}
プロトコル バッファ パーサーは、packed
としてコンパイルされた repeated フィールドを、packed ではないかのように、またその逆も解析できる必要があります。これにより、[packed=true]
を既存のフィールドに前方互換性と後方互換性のある方法で追加できます。
マップ
マップ フィールドは、特別な種類の repeated フィールドの単なる省略形です。次の場合、
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
メッセージ フィールドとまったく同じようにエンコードされます。つまり、それぞれ 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;
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
データ型を使用します。タグは最初の値の後の値に対して削除され、タグのコストを要素ごとではなくフィールドごとに償却します。