🍉

gRPCとProtocol Buffersとはなんなのか調べてみた

に公開

gRPCとProtocol Buffersについてあまりよくわかっていなかったので少し調べてみました。

間違っている箇所を発見したらマサカリを投げてください。🪓 (ひえ〜)

はじめに

gRPCはGoogleが開発したRPC(Remote Procedure Call)フレームワークで、通信にHTTP/2を使い、データのシリアライズ(直列化)にProtocol Buffersを採用しています。Protocol Buffersはデータの表現形式とシリアライズ機構を提供する仕組みで、JSONに似ていますがより高速・軽量で、各種プログラミング言語用に自動生成されるコード(クラスや構造体)を通じて利用できます。以下の記事内容では、Protocol Buffersのデータ構造とワイヤフォーマット、スキーマ設計とGoコードへの変換、gRPCの通信構造、バイナリ通信のメリット、そして「Protocol Buffers」という名前の由来までを順に調べた内容を記載します。

Protocol Buffersの概要

Protocol Buffers (protobuf) は、言語やプラットフォームに依存しない手法で構造化データをシリアル化(エンコード/デコード)する仕組みです。例えばJSONやXMLのようにデータを表現できますが、Protocol Buffersはバイナリフォーマットであるためデータサイズが小さく、高速に読書きできます。実際、数値データを多く含む場合などは、JSONと比べて小さいペイロードになり、パース(解析)もテキスト形式に比べてCPU負荷が低く済みます。(medium)Google社内ではあらゆるサービス間通信や長期データ保存にProtocol Buffersが利用されており、同社の主力データ形式となっています。Protocol Buffersはデータ定義言語(*.protoファイル)シリアライズ/デシリアライズのためのコード(各言語向けに自動生成されるクラス/構造体)、実行時のライブラリ、そしてバイナリのデータフォーマットから構成されます。一度*.protoでデータ構造を定義すれば、コード生成により様々な言語で同じデータ構造を扱うことが可能で、シリアライズされたバイナリデータを共有できます。

Protocol Buffersのワイヤフォーマット(TAGとVALUE)

Protocol Buffersのエンコーディング(ワイヤフォーマット)は、コンパクトかつ自己記述的になるよう設計されています。基本は「Tag-Length-Value (TLV)」と呼ばれる方式で、メッセージはフィールドごとのタグ(Tag)と値(Value)の組としてバイナリに並びます。各フィールドにはあらかじめ*.protoでフィールド番号 (field number) が割り振られており、このフィールド番号とデータ型に応じたワイヤタイプ (wire type) を組み合わせてタグ (Tag) が生成されます。タグはフィールド番号を左に3ビットシフトし、下位3ビットにワイヤタイプを埋め込むことで得られた整数値です。この整数タグを可変長整数(後述のVarint形式)でバイナリ表現したものがタグのバイト列になります。すなわちタグ1つにつき1バイト以上を占め、デコーダはタグを読み取ることで「フィールド番号」と「値のフォーマット(長さや型)」を把握し、続くバイナリデータの解釈方法を判断します。この仕組みにより、新しいフィールドが追加されても旧バージョンのパーサーで「未知のフィールド」としてスキップできるため、後述する後方互換性を実現しています。

Protocol Buffersで定義されるワイヤタイプは全部で6種類あり、それぞれ値データの長さや形式を示します。主要なワイヤタイプと用途は以下の通りです。

ワイヤタイプID 名称 主な用途(フィールド型の例)
0 VARINT 可変長整数(int32, int64, uint32, uint64, bool, enum など)
1 I64 64ビット固定長(fixed64, sfixed64, double)
2 LEN 長さ指定データ(string, bytes, 埋め込みメッセージ, packed配列)
3 SGROUP グループ開始(※廃止)
4 EGROUP グループ終了(※廃止)
5 I32 32ビット固定長(fixed32, sfixed32, float)

VARINT(wire type = 0)は特に頻出する形式で、可変長整数をエンコードします。Varint形式では、7ビットごとに区切った数値を1バイトに収め、各バイトの最上位ビット (MSB: most significant bit) を「後続バイトがあるかどうか」のフラグとして使います。具体的には、ある整数値を7ビットずつ分割し、各バイトのMSBを後続あり=1/なし=0として繋げます。小さい値ほど少ないバイト数で表現でき(MSBが0になる最初の1バイトで終了)、大きな値は最大10バイト(64ビット値の場合)まで拡張して表現されます。例えば、値1は1バイトで01と表現されますが、値150は2バイト必要で96 01となります。これは2バイト目までデータが続くため先頭バイト(0x96)のMSBが1にセットされており、残りの下位7ビット部分と次バイトを連結して数値を得ます。長さ指定(wire type = 2, LEN)のフィールドでは、まず値の長さそのものをVarintで書き、その後に実データをそのバイト数だけ続けます。例えばstring型フィールドに「testing」という7文字の値を入れると、タグに続いて長さ7を表すVarint 07、そしてUTF-8バイト列74657374696e67が出力される、といった具合です。このようにProtocol Buffersのバイナリ構造は、タグ(フィールド番号+型情報)が連続するシンプルな構成ですが、可変長エンコーディングや型情報埋め込みにより非常に効率的かつ拡張可能になっています。

さらに詳しく: タグ(Tag)の内部構造と計算方法

Protocol Buffersにおけるタグは単なる番号ではなく、field番号とwire typeをビット演算で合成した整数です。 構造は以下の通りで、下位3ビットにwire type、残りの上位ビットにfield番号が入ります。

key = (field番号 << 3) | wire_type

例えば:

  • field=1wire_type=2(length-delimited)の場合
1 << 3 = 0x08
0x08 | 0x02 = 0x0A
  • field=2wire_type=0(Varint)の場合
2 << 3 = 0x10
0x10 | 0x00 = 0x10

この「key」はVarintとしてエンコードされるため、field番号が大きい場合はkey自体も複数バイトになることがあります。

Varintの7ビット分割とMSBフラグ

Varint形式では、数値を下位から7ビットずつ分割して、それぞれのバイトの最上位ビット(MSB)で「まだ続きがあるか」を示します。

MSB=1は続きあり、MSB=0はこれで終わりです。

例:150 の場合

  1. 150(10) → 2進数 = 10010110
  2. 下位7ビット:0010110(22) → MSB=1 → 10010110(0x96)
  3. 残り:0000001(1) → MSB=0 → 00000001(0x01)

結果:

150 → Varint = 0x96 0x01

1バイト目の0x96はMSB=1なので「次のバイトあり」を示しています。

string型の値はUTF-8バイト列

string型のフィールド値はUTF-8でエンコードされます。
英数字のみであればASCIIと同じバイト列です。

例:"Hello"

  • UTF-8バイト列:48 65 6C 6C 6F
  • field=1(wire_type=2)の場合
key = 0x0A
length = 0x05
data = 48 65 6C 6C 6F

→ バイト列 = 0A 05 48 65 6C 6C 6F

日本語や絵文字はUTF-8で複数バイトになるため、length値もその分大きくなります。

protobufのスキーマ設計とGoコード生成

Protocol Buffersを利用する際は、まずスキーマ(構造)を定義するための.protoファイルを作成します。.protoファイルでは、メッセージ(データ構造)ごとにフィールド名と型、そして一意のフィールド番号を指定します。下記は簡単な例です。

syntax = "proto3";
package example;

message User {
  string user_id = 1;
  string name    = 2;
  int32  age     = 3;
}

上記ではUserというメッセージ型に3つのフィールド(user_id, name, age)を定義し、それぞれに1, 2, 3というフィールド番号を割り当てています。フィールド番号は後方互換性の要で、一度利用した番号は削除後も他の用途に再利用しないというルールがあります。(protobuf.dev)これは将来スキーマを拡張・変更した際に、古いデータを新バージョンが読めなくなったり、その逆が起きたりしないようにするためです。例えば、新しいバージョンでフィールドを追加する場合はこれまで使われていない番号を採番し、不要になったフィールドはフィールド番号ごと「予約済み」にしておくことで、古いバイナリデータとの互換性を保ちます。このルールに従えば、古いプログラムは将来追加されたフィールドを無視して処理を継続できますし、新しいプログラムは古いデータ中に存在しないフィールドをデフォルト値として扱うだけで問題なく動作します。このように設計時に互換性を意識できるのも、Protocol Buffersの強力な利点です。

定義した.protoファイルからは、コード生成ツール(プロトコルコンパイラ)を使って各言語向けのクラスや構造体を生成できます。生成されたコードには、.protoで定義したメッセージごとの構造体型getter/setterメソッドシリアライズ/デシリアライズ用の関数などが含まれます。たとえば上記のUserメッセージに対しては、Goコード中にtype User struct { ... }が定義され、GetUserId()GetName()といったメソッド、proto.Marshal()で直列化、proto.Unmarshal()で復元するといった処理が利用可能になります。コード生成により、開発者は直接バイナリ操作を意識することなく、通常の構造体を扱う感覚でデータの操作や送受信ができます。またコンパイル時に型チェックが効くため型安全性が確保され、誤ったデータ型をセットしようとすればコンパイルエラーで検出できる点も安心です。

Buf と呼ばれるツールチェーンもよく利用されます。Bufはprotocとプラグインの煩雑な組み合わせを解消し、チーム開発でのコード生成を効率化するプラットフォームです。Bufを使うことで、ビルド設定の一元管理やクラウド上のリモートプラグインの活用、組織内のスキーマレジストリ管理などが容易になります。またbuf generateコマンドは従来のprotocに比べて高速にコード生成を行い、buf.gen.yamlという設定ファイルで出力内容を明確に制御できるなどの利点があると記載されています。(buf.build

gRPCの概要

gRPC (Google Remote Procedure Call) は、Googleが開発した高性能なRPCフレームワークです。従来のREST APIがHTTP/1.x上でテキスト形式(JSONやXML)のメッセージをやりとりするのに対し、gRPCではHTTP/2をトランスポート層に使い、メッセージフォーマットに前述のProtocol Buffersを用いることで高速・省容量な通信を実現しています。開発者は.protoファイルにサービス(serviceブロック)とRPCメソッドを定義し、プロトコルコンパイラを使ってクライアント側とサーバ側のスタブコードを生成して利用します。gRPCは関数呼び出しのような感覚で呼び出しを行えるのが特徴で、各メソッドは引数となるメッセージ戻り値となるメッセージをprotobufで定義します。gRPCには1回のリクエスト・レスポンスで完結するUnary RPCだけでなく、データを塊ごとに送るサーバーストリーミング、クライアントから連続送信するクライアントストリーミング、双方向にストリームを張る双方向ストリーミングもサポートされています。これはHTTP/2の持つストリーム多重化機能によって、単一の接続上で複数メッセージの独立したやり取りが可能であることを活かしたものです。(medium)そのためリアルタイム通信や大容量データの逐次送信など、従来のRESTでは扱いにくかったパターンも効率良く実装できます。

gRPCの通信とHTTP/2の仕組み

gRPCは内部的にHTTP/2上に独自のプロトコルレイヤーを構築して動作します。そのリクエストはHTTP/2のPOSTメソッドを用いて送信され、パスは/<パッケージ名>.<サービス名>/<メソッド名>の形式になります。例えば先ほどのGreeterサービスのSayHelloメソッドはPOST /helloworld.Greeter/SayHello HTTP/2というリクエストとして表現され、ヘッダにはContent-Type: application/grpcが指定されます(+protoや+jsonなどのサフィックス可)。gRPCのメッセージボディ(リクエストやレスポンスのデータ部分)は、HTTP/2のDATAフレームに載せるために長さプレフィックス付きで転送されます。このメッセージフレーミングはLength-Prefixed Message Framingと呼ばれ、各メッセージ先頭に5バイトのヘッダを付与するのがポイントです。(medium)その内訳は、先頭1バイト圧縮フラグ(圧縮されていなければ0、圧縮されていれば1)で、続く4バイトメッセージ本体の長さ(バイト数)を表す32ビットの符号なし整数(ビッグエンディアン)です。圧縮フラグが1の場合、HTTPヘッダのgrpc-encodingで指定されたアルゴリズムで圧縮されていることを意味し、0なら非圧縮です。この4バイトの長さ情報により、受信側はメッセージがどこまでで一区切りかを正確に把握できます。5バイトヘッダの後に続くのが、直列化されたProtocol Buffersのメッセージ本体です。以上をまとめると、gRPCのデータフローは「HTTP/2ヘッダ (メソッド等のメタ情報) → 長さプレフィックス付きメッセージ(5バイトヘッダ + protobufシリアライズデータ)→ HTTP/2トレーラ(ステータス情報)」という構造になっています。

HTTP/2上では1つのgRPCメソッド呼び出しが一つのストリームとして扱われます。クライアントが新たなgRPCメソッドを呼ぶたびにHTTP/2の新規ストリームが開設され、先述のヘッダとデータフレームがそのストリーム上でやり取りされます。HTTP/2は単一のTCPコネクション上で複数のストリームを多重化できるため、並列した複数のRPC呼び出しも1本のコネクションで処理可能です。これにより従来のHTTP/1.xのように接続を都度開いていたオーバーヘッドを削減し、コネクションの再利用による低レイテンシーを実現しています。また、gRPCではHTTPステータスコードとしては通常は200を返し、本当の成功/失敗やエラー内容はレスポンスのトレーラ(HTTP/2のTrailersヘッダ)内のgrpc-statusgrpc-messageで通知する仕組みになっています。これはストリーミングRPCの途中では最終的なステータスが不明であるため、ストリーム終了時にまとめてステータスを送るという設計上の理由があります。このようにgRPCはHTTP/2の機能を最大限に活用することで、効率的で制御性の高い双方向通信を可能にしています。

バイナリ通信のメリット

最後に、gRPCおよびProtocol Buffersが採用するバイナリ通信の利点について整理します。テキスト形式のAPI(例:JSON/REST)と比べた場合、以下のようなメリットがあります。

  • 型安全性: Protocol Buffersではスキーマに基づいてコードが生成されるため、コンパイル時にデータ型の誤りが検出できます。受け渡しされるメッセージは言語のネイティブな型(Goでは構造体など)として扱われ、フィールドの存在や型が保証されます。これにより、文字列に本来数値を入れてしまうようなミスや、存在しないフィールドへのアクセスといったエラーを未然に防げます。

  • 高速性: バイナリフォーマットはパース処理が高速で、メモリ効率も高いです。Protocol Buffersのシリアライズ/デシリアライズ処理はC++で最適化されており、テキスト形式のJSONを文字列解析するのに比べ大幅に少ないCPUリソースで実行できます。

  • 帯域節約: Protocol Buffersのバイナリ形式は非常にコンパクトで、同じデータをJSONで送る場合と比べてデータサイズを大きく削減できます。フィールド名を含めないTLV構造や可変長エンコードにより無駄なバイトがなく、特に数値や真偽値を文字列として送らないため桁数によるサイズ増加もありません。トータルで見ると、モバイル環境や帯域の限られたネットワークでも効率よくデータ転送できるのがメリットです。

  • 後方互換性: 前述したように、Protocol Buffersはスキーマ設計とエンコード方式により、システムの進化に伴う互換性確保がしやすいです。新旧でフィールドが増減しても通信自体は破綻せず、古いクライアントが新しいデータを受け取っても未知のフィールドを無視して動作を継続できます。またデータ保存時にも、古いバイナリレコードを新しいスキーマで読み込んだり、その逆をしたりといったケースで互換性を維持できます。この柔軟な拡張性は、長期運用するサービスにとって大きな利点です。

  • バックプレッシャー制御: gRPCはHTTP/2上に構築されているため、HTTP/2のフロー制御 (flow control) をそのまま利用できます。HTTP/2では各コネクションやストリームに対してウィンドウサイズが設定され、受信側が処理可能なデータ量を制御できます。つまりgRPCにおいても、受信側(クライアント/サーバ)が読取りを一時停止すれば、送信側は自動的にウィンドウが一杯になることで送信を抑制され、バックプレッシャー(送りすぎ抑制)が働きます。これにより、例えばサーバーからストリーミングで大量のデータを送る場合でも、クライアントの処理能力を超えて無尽蔵に送り続けてしまう心配がありません。バイナリ通信+ストリーミング基盤であるgRPCだからこそ、こうした高負荷時の制御も比較的シンプルに実装できます。

以上のように、gRPCとProtocol Buffersによるバイナリ通信は、型安全かつ高速・効率的で、将来の拡張にも強い通信方式となっています。これらのメリットはマイクロサービス間のやり取りやモバイルアプリとサーバ間通信など、幅広い分野で評価されています。

「Protocol Buffers」という名前の由来

ところで、「Protocol Buffers」という名前はどのように生まれたのでしょうか? 実はこの名称、初期のプロトコルバッファ実装に由来しているようです。Protocol Buffersがまだ現在のようにコンパイラでコード自動生成を行う前、開発初期段階ではProtocolBufferという単一クラスが存在していました。(protobuf.dev)開発者はこのクラスのインスタンスに対してAddValue(tag, value)のようなメソッドを呼び出し、フィールドのタグと値を一つずつ追加してメッセージを組み立てていました。完成したバイナリメッセージは、そのクラス内部のバッファ領域に蓄えられた生のバイト列として書き出せるようになっていたため、まさにメッセージのバッファとして機能していたのです。このことから「プロトコル(通信仕様)のバッファ」という名前が付いたと言われています。しかし現在では、ほとんどの開発者は直接バッファ操作を意識する必要はなく、.proto定義から生成されたコードのメソッドを使ってメッセージオブジェクトを扱います。そのため名前の「Buffers(バッファ)」の部分は歴史的遺産となっていますが、Googleのエンジニアたちは愛着のあるこの名前をそのままプロダクト名として残したようです。「Protocol Buffers」という名称はこの技術のルーツを今に伝えるものと言えるでしょう。

おわりに

今回のgRPCとProtocol Buffersの調査を通じて、アプリケーション層においてもバイナリ通信を行うことが、高速性・軽量性・型安全性など多くのメリットをもたらすことを実感しました。Go関連の技術は面白いですね。今後も引き続き掘り下げていこうと思います。

追記: 実装することでさらに深掘りしてみました

https://zenn.dev/shimpei_takeda/articles/93be8b3072494a

参考文献・リンク

Discussion