FlowingDev

Protobufを解説:気難しいけど超絶効率的なバイナリフォーマット

Googleの爆速バイナリシリアライズフォーマット、Protocol Buffersを理解しよう。データを極小かつ型安全なパケットに圧縮する仕組みとは。

ツールを試す: Protobufデコーダー

ひとことで言うと

Protocol Buffers(Protobuf)は、構造化データをシリアライズするための言語非依存かつ信じられないほど高速なバイナリフォーマットだ。例えるなら、JSONをゴリゴリに鍛え上げて極限まで最適化したマッチョな親戚で、彼らはタイトに圧縮されたバイナリストリームだけで会話する。

どんな課題を解決するのか

2000年代初頭、Googleは巨大な問題を抱えていた。社内の大量のサービス同士が通信し合っていて、その「おしゃべり」の通信量が重すぎたのだ。当時のシステム間通信の業界標準といえばXMLだった。アングルブラケット(< >)を愛してやまない人には最高のフォーマットだけど、無駄が多くてパースが遅いことで悪名高い。サーバーがユーザーIDのような単純なデータを1つ送るだけでも、毎回 <userId>12345</userId> のようにテキストタグで囲んで送信し、受信側はその文字列をパースしてタグを照合し、「12345」をわざわざ整数に変換し直す必要があった。

そこにJSONが登場して少し身軽にはなったものの、根本的な非効率性は変わらなかった。JSONも生(raw)の文字列を送信するテキストフォーマットだからだ。もし "emailAddress" というフィールドを持つレコードを100万件送るなら、ネットワーク上に "emailAddress" という文字列を100万回も送信することになる。

Googleのエンジニアたちは、これが帯域幅とCPUサイクルの壮大な無駄遣いであることに気づいた。彼らが必要としていたのは、次のようなフォーマットだった:

  1. ネットワーク上で極小サイズになること: フィールド名なんて送らない。データだけを送る。
  2. 爆速でパースできること: コンピュータはテキスト文字をパースするより、ビットやバイトをそのまま読む方がお好みだ。
  3. 厳密な型付け (Strictly typed): こっちが整数を送ったら、文字列のパースが上手くいくように祈るんじゃなくて、そっちもちゃんと整数として受け取れるべき。
  4. 後方互換性: まだアップデートされていない古いクライアントを壊すことなく、新しいデータフォーマットにサービスを更新できること。

その解決策が Protocol Buffers だった。自己記述的な(データを読めば構造がわかる)テキストを送る代わりに、Protobufは事前に共有された「契約」(スキーマ)に依存する。送信側も受信側もすでにスキーマを知っているから、ネットワーク上に送られる実際のデータからはすべてのメタデータが剥ぎ取られ、極限まで圧縮された生のバイナリ値だけが残るんだ。

内部の仕組み(Under the hood)

Protobufを理解するには、契約(スキーマ)、コンパイラ、そしてワイヤーフォーマットという3つのフェーズに分けて考える必要がある。

スキーマ(.proto)

すべては .proto ファイルから始まる。これはデータの正確な形を定義する、言語に依存しない「契約書」だ。

syntax = "proto3";

message Person {
  string name = 1;
  int32 id = 2;
  bool is_active = 3;
}

この数字(= 1、= 2)にお気づきだろうか? これが フィールドタグ(Field Tags) だ。これがProtobufの秘伝のタレと言っていい。JSONでは "name" というキーがフィールドを識別するが、Protobufでは 1 という数字がフィールドを識別するんだ。

コンパイル処理

Protobufをパースするコードを手書きする必要はない。.proto ファイルに対してコンパイラツール(protoc)を走らせて、出力したい言語を指定するだけ。すると、C#、TypeScript、Python、Goなど、君が使っている言語のネイティブクラスを吐き出してくれる。

アプリケーションが Person を送りたいときは、生成されたクラスをインスタンス化してデータを詰め込み、Serialize() メソッドを呼ぶだけでいい。あの厄介なバイナリへのパッキング処理は、すべて生成されたコードがやってくれる。

ワイヤーフォーマット(バイナリの魔法)

その Person オブジェクトをシリアライズしたとき、実際にネットワーク上を流れるのは何だろうか?

Protobufは [Key] [Value] のアプローチを使うが、それはバイトレベルでの話だ。この Key は文字列ではなく、フィールドタグとデータの**ワイヤータイプ(Wire Type)**を組み合わせた1バイト(または数バイト)から成る。

ワイヤータイプは、受信側に「これから来る値の長さがどれくらいか」を教える役割を持つ。主要なワイヤータイプは以下の通り:

ワイヤータイプ 意味 用途
0 Varint int32, int64, uint32, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited(長さを区切りとするデータ) string, bytes, 埋め込みメッセージ
5 32-bit fixed32, sfixed32, float

たとえば、is_active = true(フィールド3、ワイヤータイプ0)をセットした場合、Protobufはキー(3と0の組み合わせ)と値(trueを表す 1)をエンコードして、たったの2バイトに収める。JSONなら "is_active": true で17バイトも消費するところを、だ。

Varint(可変長整数)のトリック

Protobufは数値を圧縮するために「Base-128 Varints(可変長整数)」という仕組みを使う。普通の32ビット整数は、たとえ中身が単なる 5 だったとしても、メモリ上では常に4バイトを占有する。Protobufは「なんで中身がゼロの3バイトをわざわざ無駄にするんだ?」と考えるわけだ。

Varintを使うと、各バイトの1ビット(最上位ビット、MSB)が「まだ次のバイトが続くか?」を示すフラグとして予約される。小さな数字ならMSBは 0 になり、その数字はぴったり1バイトに収まる。つまり、小さな数字であればあるほど、ネットワーク上で消費するスペースが劇的に小さくなるというトリックだ。

スキーマなしでのデコード

ワイヤーフォーマットにはワイヤータイプが含まれているため、パーサーは生のProtobufのバイトストリームを舐めるだけで、.proto ファイルがなくてもその構造を推測できる。フィールド1の名前が「name」であることは分からなくても、「フィールド1は4バイトのLength-delimitedなデータで、中身は 'John' だ」ということは分かるんだ。生の16進数やBase64形式のProtobufデータをデコーダに突っ込んでも、JSONライクなタグと値のツリーがちゃんと復元できるのはこのためだ。

現場のストーリー

息切れしたIoTフリート

あるスタートアップが、何百マイルにも及ぶ農地の土壌水分を測定するスマート農業センサーのフリート(群)を構築した。当初、彼らはセルラーネットワークを使い、JSONのペイロードで毎分センサーから中央サーバーへ通信(Ping)させていた。1ヶ月以内に2つの出来事が起きた。3G回線のデータ通信料が跳ね上がり、数ヶ月は持つはずだったセンサーのバッテリーが数週間で切れてしまったのだ。

彼らはセンサーのファームウェアを書き換え、データをProtocol Buffersでシリアライズするようにした。送信していたのは小さな数値や真偽値ばかりだったため、{"sensorId": 14, "moistureLevel": 42} のようなJSONの文字列をすべて削ぎ落とした結果、ペイロードのサイズは80%以上も激減した。 ここから得られる教訓: 帯域幅が高価でバッテリー寿命が限られている環境において、生の文字列を送信するなんていうのは許されない贅沢だ。バイナリシリアライズは物理的なリソースを救ってくれる。

2018年、マイクロサービス大渋滞事件

ある巨大EC企業が、モノリス(一枚岩)なバックエンドを50個のマイクロサービスに分割することにした。彼らは通信手段としてJSONを用いたREST APIを選択した。すると突然、全体のレイテンシが跳ね上がった。サーバーのプロファイリングをしてみると、衝撃の事実が判明した。データが認証サービスからカートサービス、そして在庫サービスへと飛び交う中で、バックエンドサービスは単にJSONを文字列化(stringify)してパースするためだけに、CPUサイクルの30%を浪費していたのだ。

彼らは内部通信を gRPC(Protobufをベースに構築されたフレームワーク)に移行した。Protobufのパースは基本的にメモリオフセットを読み取ってビットシフトするだけの処理なので、CPU使用率は急降下。データはサービス間を一瞬で飛び交うようになった。 ここから得られる教訓: 高スループットが求められるバックエンドの内部システムにおいて、テキストパースは「見えない税金」だ。バイナリシリアライズは合法的な節税対策(タックス・ループホール)と言える。

ネットワークダンプに潜む謎のペイロード

あるセキュリティ研究者が、モバイルアプリがどんなデータを収集しているか調べるため、非公開のAPIをリバースエンジニアリングしようとしていた。彼女はプロキシを通してアプリのトラフィックを傍受し、読みやすいきれいなJSONが来ることを期待した。しかし、代わりに得られたのは文字化けした意味不明なデータ。HTTPヘッダーを見ると、彼女の嫌な予感は的中した。Content-Type: application/x-protobuf と書いてあったのだ。

彼女は .proto スキーマを持っていなかった(アプリの開発者が自分たちのサーバーに隠し持っているからだ)。しかし彼女はBase64化されたペイロードを取得し、生のProtobufデコーダに突っ込んだ。すると、構造化されたツリーが出てきたのだ。フィールド1: "johndoe"、フィールド2: "user@example.com"、フィールド3: 40.7128、フィールド4: -74.0060。彼女は一瞬で、フィールド3と4がGPS座標だと見抜いた。 ここから得られる教訓: Protobufは暗号化(encrypted)されているわけではなく、ただエンコード(encoded)されているだけだ。地図(スキーマ)がなくても、ワイヤータイプが残した構造のパンくずを辿れば、データの骨格をリバースエンジニアリングできてしまう。

よくある間違いと罠

  • フィールドタグを再利用してしまう: タグの数字は絶対的な真実の情報源(source of truth)だ。もしフィールドを削除したなら(例えば string name = 1;)、その 1 という数字を int32 age = 1; のように新しいフィールドで絶対に再利用してはいけない。もし古いクライアントが文字列を送り、新しいサーバーがそれを整数として読み取ろうとしたら、すべてが爆発炎上する。
  • 削除したフィールドを reserved にし忘れる: 上のミスを防ぐため、Protobufには reserved キーワードが用意されている。もしフィールド 2 を削除したら、スキーマに明示的に reserved 2; と書くべきだ。こうしておけば、将来どの開発者が再利用しようとしても、コンパイラが怒鳴って止めてくれる。
  • 無謀なデータ型の変更: 一部の型は安全に変更できる(int32 を int64 にするなど。どちらも同じVarintワイヤータイプを使うからだ)。しかし、string を int32 に変えるような変更は破壊的変更(breaking change)になる。ワイヤータイプが根本的に互換性のないものだからだ。
  • Protobufはセキュアだと勘違いする: これはシリアライズのフォーマットであって、暗号化ではない。TLS/SSLを使わずにHTTP越しにProtobufで機密性の高いユーザーデータを送れば、通信を傍受した誰かが生の値を簡単にデコードできてしまう。
  • ブラウザで盲目的に使う: gRPC-webのような技術もあるが、Webブラウザの母国語はJSONだ。フロントエンドのWebアプリにProtobufを持ち込むには、巨大なパース用ライブラリをJavaScriptのバンドルに含めなければならず、結果的にバイナリフォーマットによる帯域幅の節約効果を台無しにしてしまうこともある。

なぜProtobufを知っておくべきなのか

公開向けのパブリックAPIにProtobufを使うべきではない。そこでは未だにJSONが絶対王者だ。人間に読みやすく、柔軟で、地球上のあらゆるWebブラウザがネイティブに理解できるからね。

しかし、通信のパイプの両端を自分がコントロールしている場合、すぐさまProtobufに手を伸ばすべきだ。マイクロサービス間の通信、バックエンドと通信するモバイルアプリ、リアルタイムのマルチプレイヤーゲーム、あるいはIoTデバイスを作っているなら、Protobufはスーパーパワーになる。チーム間で厳密なデータ契約を強制し(全員が同じ .proto ファイルからコンパイルするからだ)、コンパイル時にデータ型のエラーをキャッチし、ネットワークインフラのパフォーマンスを最後の一滴まで絞り出してくれる。

さらに深掘りする

理論はOK。さあ手を動かそう — 100%ブラウザ内で。

ツールを試す: Protobufデコーダー