Protobufがデータフォーマットエコシステムを支配すべき理由
James Reed
Infrastructure Engineer · Leapcell

Protobufの詳細な理解
Protobufとは
Protobuf(Google Protocol Buffers)は、公式ドキュメントで定義されているように、言語に依存せず、プラットフォームに依存せず、拡張可能な構造化データをシリアライズするための方法であり、データ通信プロトコルやデータストレージなどのシナリオで広く適用できます。これは、Googleが提供するツールライブラリであり、柔軟性、効率性、自動化された構造化データシリアライゼーションメカニズムの特性を備えた効率的なプロトコルデータ交換形式を備えています。
XMLと比較して、Protobufによってエンコードされたデータのサイズは小さく、エンコードとデコードの速度は高速です。JSONと比較して、Protobufは変換効率に優れており、時間効率と空間効率の両方がJSONの3〜5倍に達します。
公式の説明にあるように、「プロトコルバッファは、Googleの言語中立、プラットフォーム中立、拡張可能な構造化データをシリアライズするためのメカニズムです。XMLよりも小さく、高速で、シンプルであると考えてください。データを一度構造化する方法を定義すると、特別な生成されたソースコードを使用して、さまざまなデータストリームとの間で構造化データを簡単に書き込んだり読み取ったりでき、さまざまな言語を使用できます。」
データ形式の比較
personオブジェクトがあると仮定し、それぞれJSON、XML、およびProtobufで表し、それらの違いを見てみましょう。
XML形式
<person> <name>John</name> <age>24</age> </person>
JSON形式
{ "name":"John", "age":24 }
Protobuf形式
Protobufは、データをバイナリ形式で直接表現するため、XMLやJSON形式ほど直観的ではありません。例:
[10 6 69 108 108 122 111 116 16 24]
Protobufの利点
優れたパフォーマンス/高効率
- 時間的オーバーヘッド:XML形式設定(シリアライゼーション)のオーバーヘッドは許容範囲内ですが、XML解析(デシリアライゼーション)のオーバーヘッドは比較的大きいです。Protobufはこの側面を最適化し、シリアライゼーションとデシリアライゼーションの時間的オーバーヘッドを大幅に削減できます。
- 空間的オーバーヘッド:Protobufはまた、空間占有を大幅に削減します。
コード生成メカニズム
たとえば、構造体のような次のコンテンツを記述します。
message testA { required int32 m_testA = 1; }
Protobufは、対応する.hファイルと.cppファイルを自動的に生成し、構造体testAに対する操作をクラスにカプセル化できます。
下位互換性と上位互換性のサポート
クライアントとサーバーが同時にプロトコルを使用する場合、クライアントがプロトコルにバイトを追加しても、クライアントの通常の使用には影響しません。
複数のプログラミング言語のサポート
Googleによって正式にリリースされたソースコードには、次のような複数のプログラミング言語のサポートが含まれています。
- C++
- C#
- Dart
- Go
- Java
- Kotlin
- Python
Protobufの欠点
バイナリ形式による可読性の低さ
パフォーマンスを向上させるために、Protobufはエンコードにバイナリ形式を使用しています。これにより、データの可読性が低下し、開発およびテスト段階での効率に影響を与えます。ただし、通常の状態では、Protobufは非常に信頼性が高く動作し、通常、深刻な問題は発生しません。
自己記述の欠如
一般的に、XMLは自己記述的ですが、Protobuf形式はそうではありません。これはバイナリ形式のプロトコルコンテンツであり、事前に作成された構造と照合しないとその機能を把握することは困難です。
汎用性の低さ
Protobufは複数の言語でのシリアライゼーションとデシリアライゼーションをサポートしていますが、プラットフォームや言語を超えた普遍的な伝送標準ではありません。マルチプラットフォームメッセージパッシングのシナリオでは、他のプロジェクトとの互換性は高くなく、対応する適応および変換作業が必要になることがよくあります。jsonやXMLと比較して、その普遍性はわずかに不十分です。
使い方ガイド
メッセージタイプの定義
Protoメッセージタイプファイルは通常、.protoで終わります。.protoファイルでは、1つ以上のメッセージタイプを定義できます。
以下は、検索クエリのメッセージタイプを定義する例です。ファイルの先頭にあるsyntaxは、バージョン情報を記述するために使用されます。現在、proto2とproto3の2つのバージョンのprotoがあります。
syntax="proto3";
構文形式を明示的にproto3に設定します。syntaxが設定されていない場合、デフォルトはproto2になります。queryはクエリされるコンテンツを表し、page_numberはクエリのページ番号を表し、result_per_pageはページあたりのアイテム数を表します。syntax = "proto3"は、コメントと空白行を除く.protoファイルの最初の行に配置する必要があります。
次のメッセージには、3つのフィールド(query、page_number、result_per_page)が含まれており、各フィールドには対応するタイプ、フィールド名、およびフィールド番号があります。フィールドタイプは、string、int32、enum、または複合タイプにすることができます。
syntax = "proto3"; message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; }
フィールド番号
メッセージタイプの各フィールドは、一意の番号で定義する必要があります。この番号は、バイナリデータ内のフィールドを識別するために使用されます。範囲[1,15]の番号は、1バイトでエンコードおよび表現できます。範囲[16,2047]では、2バイトでエンコードおよび表現する必要があります。したがって、頻繁に発生するフィールドに15以内の番号を残すと、スペースを節約できます。番号の最小値は1で、最大値は2^29 - 1 = 536870911です。範囲[19000, 19999]の番号は、protoコンパイラによって内部的に使用されるため、使用できません。同様に、他の事前に予約された番号も使用できません。
フィールドルール
各フィールドは、singularまたはrepeatedで変更できます。proto3構文では、変更タイプが指定されていない場合、デフォルト値はsingularです。
singular:変更されたフィールドが最大で1回出現することを意味します。つまり、0回または1回出現します。repeated:変更されたフィールドが任意の回数(0回を含む)出現できることを意味します。proto3構文では、repeatedで変更されたフィールドは、デフォルトでpackedエンコーディングを使用します。
コメント
.protoファイルにコメントを追加できます。コメント構文は、C/C++スタイルと同じで、//または/* ... */を使用します。
/* SearchRequest represents a search query, with pagination options to * indicate which results to include in the response. */ message SearchRequest { string query = 1; int32 page_number = 2; // Which page number do we want? int32 result_per_page = 3; // Number of results to return per page. }
予約フィールド
message内のフィールドを削除またはコメントアウトするときは、将来他の開発者がmessage定義を更新するときに以前のフィールド番号を再利用する可能性があります。誤って古いバージョンの.protoファイルをロードすると、データ破損などの深刻な問題が発生する可能性があります。このような問題を回避するには、予約済みのフィールド番号とフィールド名を指定できます。誰かが将来これらのフィールド番号を使用すると、protoのコンパイル時にエラーが生成され、protoに問題があることを通知します。
注:同じフィールドのフィールド名とフィールド番号の使用を混同しないでください。
message Foo { reserved 2, 15, 9 to 11; reserved "foo", "bar"; }
フィールドタイプと言語タイプのマッピング
定義された.protoファイルは、ジェネレーターを介してGo言語コードを生成できます。たとえば、a.protoファイルから生成されたGoファイルは、a.pb.goファイルです。
protoの基本タイプとGo言語タイプの間のマッピングを次の表に示します(ここではGoとC/C++の間のタイプマッピングのみがリストされ、他の言語については、https://developers.google.com/protocol-buffers/docs/proto3を参照してください)。
| .proto Type | Go Type | C++ Type |
|---|---|---|
| double | float64 | double |
| float | float32 | float |
| int32 | int32 | int32 |
| int64 | int64 | int64 |
| uint32 | uint32 | uint32 |
| uint64 | uint64 | uint64 |
| sint32 | int32 | int32 |
| sint64 | int64 | int64 |
| fixed32 | uint32 | uint32 |
| fixed64 | uint64 | uint64 |
| sfixed32 | int32 | int32 |
| sfixed64 | int64 | int64 |
| bool | bool | bool |
| string | string | string |
| bytes | []byte | string |
デフォルト値
| .proto Type | default value |
|---|---|
| string | "" |
| bytes | []byte |
| bool | false |
| numeric types | 0 |
| enums | first defined enum value |
列挙型
メッセージを定義するときに、フィールドの値が予想される値の1つだけになるようにする場合は、列挙型を使用できます。
たとえば、corpusフィールドをSearchRequestに追加し、その値をUNIVERSAL、WEB、IMAGES、LOCAL、NEWS、PRODUCTS、およびVIDEOのいずれかにする必要があるとします。これは、メッセージ定義に列挙型を追加し、可能な各列挙値に定数を追加することで実現できます。
message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; enum Corpus { UNIVERSAL = 0; WEB = 1; IMAGES = 2; LOCAL = 3; NEWS = 4; PRODUCTS = 5; VIDEO = 6; } Corpus corpus = 4; }
Corpus列挙型の最初の定数は0にマッピングする必要があり、すべての列挙定義には0にマッピングされた定数を含める必要があります。この値は列挙定義の最初の行の内容です。これは、0が列挙型のデフォルト値として使用されるためです。proto2構文では、最初の行の列挙値が常にデフォルト値です。互換性を保つために、値0は定義の最初の行である必要があります。
他のProtoのインポート
他の.protoファイルを.protoファイルにインポートして、インポートされたファイルで定義されたメッセージタイプを使用できます。
import "myproject/other_protos.proto";
デフォルトでは、直接インポートされた.protoファイルで定義されたメッセージタイプのみを使用できます。ただし、.protoファイルを新しい場所に移動する必要がある場合があります。このとき、仮想.protoファイルを古い場所に配置し、import public構文を使用して、すべてのインポートを新しい場所に転送できます。.protoファイルを直接移動して、すべての呼び出しポイントを一度に更新する代わりに、import publicステートメントを含むprotoファイルをインポートする場所は、インポートされた依存関係のパブリックな依存関係を渡すことができます。
たとえば、現在のフォルダーにa.protoファイルとb.protoファイルがあり、b.protoがa.protoファイルにインポートされているとします。つまり、a.protoファイルには次のコンテンツがあります。
import "b.proto";
b.protoのメッセージをcommon/com.protoファイルに入れて、他の場所で使用できるようにするとします。b.protoを変更し、その中にcom.protoをインポートできます。単一のimportはb.protoで定義されたメッセージのみを使用でき、b.protoにインポートされたprotoファイルのメッセージタイプを使用できないため、import publicを使用する必要があることに注意してください。
// b.proto file, move the message definitions inside to the common/com.proto file, // add the following import statement inside import public "common/com.proto"
コンパイルにprotocを使用する場合、インポートされたファイルを見つける場所をprotocに通知するために、オプション-Iまたは--proto_pathを使用する必要があります。検索パスが指定されていない場合、protocは現在のディレクトリ(protocが呼び出されたパス)で検索します。
proto2バージョンのメッセージタイプをproto3ファイルにインポートして使用でき、proto3バージョンのメッセージタイプをproto2ファイルにインポートすることもできます。ただし、proto2の列挙型はproto3構文に直接適用できません。
ネストされたメッセージ
メッセージタイプは、別のメッセージタイプ内で定義できます。つまり、ネストされた定義です。たとえば、ResultタイプはSearchResponse内で定義され、複数のレベルのネストをサポートします。
message SearchResponse { message Result { string url = 1; string title = 2; repeated string snippets = 3; } repeated Result results = 1; }
外部メッセージタイプが別のメッセージ内のメッセージを使用する場合(たとえば、SomeOtherMessageタイプがResultを使用する場合)、SearchResponse.Resultを使用できます。
message SomeOtherMessage { SearchResponse.Result result = 1; }
不明なフィールド
不明なフィールドとは、protoコンパイラが認識できないフィールドです。たとえば、古いバイナリファイルが新しいフィールドを持つ新しいバイナリファイルから送信されたデータを解析する場合、これらの新しいフィールドは古いバイナリファイルでは不明なフィールドになります。proto3の初期バージョンでは、メッセージの解析時に不明なフィールドは破棄されましたが、バージョン3.5では、不明なフィールドの保持が再導入されました。不明なフィールドは解析中に保持され、シリアライズされた出力に含まれます。
エンコードの原則
TLVエンコード形式
Protobufの高い効率の鍵は、そのTLV(tag-length-value)エンコード形式にあります。各フィールドには、識別子としての一意のtag値、valueデータの長さを表すlength(固定長のvalueの場合、lengthはありません)、およびvalueはデータ自体のコンテンツです。
tag値の場合、field_numberとwire_typeの2つの部分で構成されています。field_numberは、以前にmessage内の各フィールドに与えられた番号であり、wire_typeはタイプ(固定長または可変長)を表します。wire_typeには現在0から5までの6つの値があり、これらの6つの値は3ビットで表現できます。
wire_typeの値は次の表に示されています。3と4は非推奨になっており、残りの4つのタイプに注意を払う必要があります。Varintでエンコードされたデータの場合、バイト長lengthを格納する必要はなく、このとき、TLVエンコード形式はTVエンコードに退化します。64ビットおよび32ビットのデータの場合も、type値はすでに長さが8バイトか4バイトかを示しているため、lengthは必要ありません。
| wire_type | Encoding Method | Encoding Length | Storage Method | Data Type |
|---|---|---|---|---|
| 0 | Varint | Variable length | T - V | int32 int64 uint32 uint64 bool enum |
| 0 | Zigzag + Varint | Variable length | T - V | sint32 sint64 |
| 1 | 64-bit | Fixed 8 bytes | T - V | fixed64 sfixed64 double |
| 2 | length-delimi | Variable length | T - L - V | string bytes packed repeated fields embedded |
| 3 | start group | Deprecated | Deprecated | |
| 4 | end group | Deprecated | Deprecated | |
| 5 | 32-bit | Fixed 4 bytes | T - V | fixed32 sfixed32 float |
Varintエンコードの原則
Varintは可変長のintであり、可変長のエンコード方法です。これにより、より小さな数値をより少ないバイト数で表現でき、数値を表現するために使用されるバイト数を減らすことで、データの圧縮を実現します。int32タイプの数値の場合、通常は4バイト必要ですが、Varintエンコードを使用すると、128未満のint32タイプの数値は1バイトで表現できます。より大きな数値の場合、5バイト必要になる場合がありますが、ほとんどのメッセージでは、非常に大きな数値は通常表示されないため、Varintエンコードを使用すると、数値を表現するために使用するバイト数を減らすことができます。
Varintは可変長のエンコードであり、各バイトの最上位ビットを介して各フィールドを区別します。バイトの最上位ビットが1の場合、後続のバイトも数値の一部であることを意味します。0の場合、これが最後のバイトであることを意味し、残りの7ビットはすべて数値を表すために使用されます。各バイトは1ビットのスペース(つまり、1/8 = 12.5%の無駄)を無駄にしますが、4バイトとして固定する必要がない数値がたくさんある場合、大量のスペースを節約できます。
たとえば、int32タイプの番号65の場合、そのVarintエンコードプロセスは次のとおりです。元々4バイトを占有していた65は、エンコード後に1バイトのみを占有します。
int32タイプの番号128の場合、エンコード後に2バイトを占有します。
Varintデコードはエンコードの逆のプロセスであり、比較的簡単であり、ここでは例は示されていません。
Zigzagエンコード
数値を符号なしの数値に変換し、次にVarintエンコードを使用して、エンコード後のバイト数を減らします。
Zigzagは符号なしの数値を使用して符号付きの数値を表し、絶対値が小さい数値をより少ないバイト数で表現できるようにします。Zigzagエンコードを理解する前に、いくつかの概念を理解しましょう。
- 元のコード:最上位ビットは符号ビットであり、残りのビットは絶対値を表します。
- 1の補数:符号ビットを除いて、元のコードの残りのビットを1つずつ反転します。
- 2の補数:正の数の場合、2の補数はそれ自体です。負の数の場合、符号ビットを除いて、元のコードの残りのビットを1つずつ反転してから、1を追加します。
int32タイプの番号-2を例にとると、そのエンコードプロセスは次のとおりです。
要約すると、負の数の場合、2の補数で算術演算を実行します。数値nの場合、それがsint32タイプの場合は、操作(n<<1) ^ (n>>31)を実行します。sint64タイプの場合は、操作(n<<1) ^ (n>>63)を実行します。この操作を通じて、負の数は正の数に変更され、このプロセスはZigzagエンコードです。最後に、Varintエンコードを使用します。
VarintおよびZigzagエンコードはコンテンツの長さを自己解析できるため、長さの項目を省略でき、TLVストレージはTVストレージに簡略化され、length項目は必要ありません。
tagおよびvalue値の計算方法
tag
tagは、フィールドの識別情報とデータ型情報を格納します。つまり、tag = wire_type(フィールドデータタイプ)+ field_number(識別番号)です。フィールド番号は、tagを介して取得できます。これは、定義されたメッセージフィールドに対応します。計算式はtag = field_number<<3 | wire_typeであり、次にVarintエンコードを実行します。
value
valueは、VarintおよびZigzagエンコード後のメッセージフィールドの値です。
stringエンコード(続き)
フィールドタイプがstringタイプの場合、フィールド値はUTF-8でエンコードされます。たとえば、次のメッセージ定義があるとします。
message stringEncodeTest { string test = 1; }
Go言語では、このメッセージをエンコードするためのサンプルコードは次のとおりです。
func stringEncodeTest(){ vs:=&api.StringEncodeTest{ Test:"English", } data,err:=proto.Marshal(vs) if err!=nil{ fmt.Println(err) return } fmt.Printf("%v\n",data) }
エンコード後のバイナリコンテンツは次のとおりです。
[10 14 67 104 105 110 97 228 184 173 144 155 189 228 120 186]
ネストされたタイプのエンコード
ネストされたメッセージとは、valueが別のフィールドメッセージであることを意味します。外部メッセージは、TLVストレージを使用して格納され、そのvalueもTLVストレージ構造です。エンコード構造全体の概略図は次のとおりです(ツリー構造として想像できます。外部メッセージはルートノードであり、その内部のネストされたメッセージは子ノードとして使用され、各ノードはTLVエンコードルールに従います)。
- 最も外側のメッセージには、対応する
tag、length(該当する場合)、およびvalueがあります。 valueがネストされたメッセージの場合、このネストされたメッセージには、独自の独立したtag、length(該当する場合)、およびvalueがあります。- 同様に、ネストされたメッセージ内にネストされたメッセージがある場合は、TLVルールに従ってエンコードを続行します。
packedを使用したrepeatedフィールド
repeatedで変更されたフィールドは、packedを使用することも使用しないこともできます。同じrepeatedフィールドの複数のフィールド値の場合、それらのtag値はすべて同じです。つまり、データ型とフィールドシーケンス番号は同じです。複数のTVストレージを使用する場合、tagの冗長性が発生します。
packed = trueが設定されている場合、repeatedフィールドのストレージ方法が最適化されます。つまり、同じtagは1回だけ格納され、次にrepeatedフィールドのすべての値の合計長lengthを追加して、TLVV...ストレージ構造を形成します。この方法は、シリアル化されたデータの長さを効果的に圧縮し、伝送のオーバーヘッドを節約できます。例えば:
message repeatedEncodeTest{ // Method 1, without packed repeated int32 cat = 1; // Method 2, with packed repeated int32 dog = 2 [packed=true]; }
上記の例では、catフィールドはpackedを使用していません。各cat値には、独立したtagおよびvalueストレージがあります。一方、dogフィールドはpackedを使用しており、tagは1回だけ格納され、その後にすべてのdog値の合計長lengthが続き、次にすべてのdog値が順番に配置されます。このようにして、データ量が多い場合、packedを使用するrepeatedフィールドは、データが占有するスペースと伝送中の帯域幅消費を大幅に削減できます。
結論
効率(サイズに関して)とプロフェッショナリズム(プロフェッショナルタイプ)により、Protobufは今後のデータ伝送分野でより高いカバレッジを持つ必要があります。
Leapcell:Webホスティング、非同期タスク、およびRedis向けの次世代サーバーレスプラットフォーム
最後に、サービスのデプロイに最適なプラットフォームをご紹介します:Leapcell

1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無料で無制限のプロジェクトをデプロイ
- 使用量に対してのみ支払い、リクエストや料金はかかりません。
3. 比類のないコスト効率
- アイドル料金なしの従量課金制。
- 例:$25で、平均応答時間60msで694万件のリクエストをサポートします。
4. 合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察のためのリアルタイムのメトリックとロギング。
5. 簡単なスケーラビリティと高いパフォーマンス
- 高い同時実行性を簡単に処理するための自動スケーリング。
- 運用オーバーヘッドなし。構築に集中するだけです。

Leapcell Twitter: https://x.com/LeapcellHQ

