エンコーディング

Protocol Buffers がデータをファイルまたはネットワーク配線にエンコードする方法について説明します。

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

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

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 つのワイヤタイプがあります: VARINTI64LENSGROUPEGROUP、および I32

ID名前使用目的
0VARINTint32、int64、uint32、uint64、sint32、sint64、bool、enum
1I64fixed64、sfixed64、double
2LENstring、bytes、埋め込みメッセージ、packed repeated フィールド
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 を推測するなどです。

その他の整数型

Bool 値と Enum

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

符号付き整数

前のセクションで見たように、ワイヤタイプ 0 に関連付けられたすべてのプロトコル バッファ型は、Varint としてエンコードされます。ただし、Varint は符号なしであるため、異なる符号付き型、sint32sint64int32 または 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」エンコーディングを使用します。正の整数 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 バイトのデータ塊を予期するように指示します。5: 25.4 と記述して double レコードを指定するか、6: 200i64 を使用して fixed64 レコードを指定できます。どちらの場合も、明示的なワイヤタイプを省略すると、I64 ワイヤタイプが暗黙的に指定されます。

同様に、floatfixed32 はワイヤタイプ 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;
}

Test1a フィールド (つまり、Test3c.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" に、e12、および 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」として宣言できます。これらは、通常は VARINTI32、または 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())
  • 論理的に同等のプロトコル バッファ メッセージ foobar が異なるバイト出力にシリアライズされる可能性のあるシナリオの例を次に示します
    • bar は、一部のフィールドを不明として扱う古いサーバーによってシリアライズされます。
    • bar は、異なるプログラミング言語で実装され、フィールドを異なる順序でシリアライズするサーバーによってシリアライズされます。
    • bar には、非決定的な方法でシリアライズするフィールドがあります。
    • bar には、異なる方法でシリアライズされるプロトコル バッファ メッセージのシリアライズされたバイト出力を格納するフィールドがあります。
    • 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 データ型を使用します。タグは最初の値の後の値に対して削除され、タグのコストを要素ごとではなくフィールドごとに償却します。