Protobufの破壊的変更:ワイヤー上で生き残るもの
1分で読めます
REST APIはJSONの形が変わるときに変わり、その形の大半はブラウザで読める応答の中に見えている。
gRPC APIは.protoファイルが変わるときに変わり、Protocol Buffersのバイナリワイヤーフォーマット
には、フィールド名が何を語ろうとクライアントが耐えられるものについて独自のルールがある。diffの
中では同じくらい些細に見える二つの変更、フィールドの番号を振り直すことと新しいフィールドを
追加することは、breaking changesが一般に引く線の反対側に落ちる。
一方は既存のすべてのクライアントに見えず、もう一方はそれらすべてを一度に壊す。protobufの破壊的
変更を安全な変更と見分けるには、.protoのdiffの見た目から推測するのではなく、ワイヤーフォー
マット自身のルールを読む必要がある。
Protobufでフィールド名よりも番号のほうが重要なのはなぜか
ワイヤーフォーマットが名前ではなく番号でフィールドをエンコードするからだ。各言語の生成コード
はこの番号を読み書きする。.protoファイルのemailというフィールド名は人間のための便宜に
すぎず、ネットワーク上を流れるバイナリのバイト列には一切触れない。フィールドの名前を変える、
emailをemail_addressにする、ことは番号が同じままである限りバイナリのワイヤー上では安全であり、
これはリネームされたJSONキーがまさにクライアントを壊すタイプの変更であるRESTに慣れたエンジニア
を驚かせる。例外はそのRESTと同じケースだ。ProtoJSONとテキスト形式
は名前をシリアライズするので、リネームはJSONトランスコーディング(たとえばgrpc-gateway)、
テキスト形式のファイル、フィールドマスクを壊す。同じフィールドの番号を振り直す、名前は保ったまま1を7に変える、ことはちょうど
逆で、名前だけを見るコードレビューでは見えず、その瞬間からクライアントが送受信するすべての
メッセージを壊す。
| 変更 | ワイヤー上で安全か | 理由 |
|---|---|---|
| フィールド名を変える、番号は保つ | バイナリははい、JSONとテキストはいいえ | バイナリのエンコードは番号を使う。ProtoJSONとテキスト形式は名前を使う |
| フィールド番号を変える | いいえ | すべての既存メッセージが今や別のフィールドとして読まれる |
| 新しい番号で新しいフィールドを追加する | はい | 古いクライアントは知らないフィールドを無視する |
| フィールドを削除し、その古い番号を別の用途に再利用する | いいえ | 古いデータが誤った新フィールドにデコードされる |
フィールドの型を非互換に変える(例: int32をstringへ) | いいえ | ワイヤーエンコーディングは型ごとに異なる |
フィールドの削除がREST JSONの応答での同じことと違うのはなぜか
番号が放射性を帯びるからだ。Protobuf自身のガイダンスは、削除されたフィールドの番号を
reservedとして印を付け、再利用を許さないことを推奨している。実際の被害が起きるのはまさに
そこだからだ。何ヶ月も前の生成コードで動き続けているクライアントが、古いフィールド番号を古い
値のために送信すると、その番号が今は別のものを意味すると期待しているサーバーは、データを直接
拒絶する代わりに静かに誤解釈してしまう。RESTにはこれに相当する罠がない。削除されたJSONキーは
単に届かなくなるだけであり、古いクライアントのリクエストが静かに別物として再解釈される方法は
存在しない。メッセージの冒頭にreserved 4, 9, 12;を持つ.protoファイルは永続的な傷跡であり、
それこそがポイントだ。その番号が、履歴を知らない誰かによって新しいフィールドに渡されるのを
防ぐ。
message Invoice {
reserved 4; // かつて`legacy_customer_id`、2026-06-01に削除
reserved "legacy_customer_id"; // JSON/テキスト形式のため名前も予約
string customer_id = 5;
string status = 6;
}
フィールドの追加はそもそもチェンジログの項目を必要とするか
通常はbreaking changeの項目としてではないが、しばしば通常の項目として必要になる。「ワイヤー上 で安全」と「気にする読者にとって見える」は別の主張だからだ。応答メッセージへのフィールド追加は 構造的には無料であり、古いクライアントはメッセージをデコードして新しいフィールドを自動的に 無視する。しかしこのサービスに対して新しい統合を構築している人には、誰かが教えない限りその フィールドの存在を知る方法がない。成功したビルドや通過したテストの中には、新しいオプション フィールドを可視化するものは何もないからだ。チェンジログAPIは追加 的な項目が読者に負っているものを一般に扱っている。それでもgRPC特有の理由でそれを書くべきなの は、デバッガーでREST応答を眺めて新しいキーが現れたことに気づく、という相当物が存在しないから だ。
GraphQLの呼び出し元が直面するものとどう違うのか
追加に関するルールは同じだが、影響の受け方が異なる。GraphQLスキーマの非推奨化 は、クライアントが明示的にリクエストしたフィールドしか受け取らないモデルを扱っており、これは 追加的な変更を本質的にリスクフリーにし、削除だけが本当の危険にする。対照的にgRPCクライアント はサーバーが送るものすべてを受け取り、自分自身のコンパイル済みスキーマのコピーに対してすべて をデコードする。クライアントの露出は、リクエストしたものではなく、その生成コードが読める ものだけに制限される。この違いはチェンジログを書く上で重要だ。GraphQLの項目は、クライアントが リクエストしなかったフィールドから保護されていると合理的に仮定できるが、gRPCの項目はまったく それを仮定できない。
gRPCサービスのバージョン管理はRESTの/v1/、/v2/と同じように機能するか
意図は同じでも仕組みは異なる。RESTのAPIにおけるv1とv2とは何か
は、バージョン管理を異なる契約を提供する並行URLパスとして扱っている。gRPCサービスは通常
.protoファイル自体の中のパッケージ名を通じてバージョン管理され、payments.v1.InvoiceService
はpayments.v2.InvoiceServiceになる。これはクライアントがリクエストするURLのセグメントでは
なく、クライアントがダイヤルする完全修飾サービス名を変える。どちらのアプローチも同じ問題を
解決している。新しい契約が存在する間、古い契約を動かし続けることだ。しかしRESTの背景を持つ
チームはしばしば間違った場所にバージョン番号を探し、その仕事をパッケージ宣言がしていること
を見落とす。
gRPCのチェンジログ項目は実際には何を名指すべきか
メッセージ、フィールド番号、そしてその変更が追加的なのか移行を要する削除なのか、これが行動
するかどうかを決める読者にとっての重要度の順序だ。「Orderにshipping_address(フィールド
8)を追加」は、統合者に生成コードを更新して使い始めるために必要なすべてを伝える。「Invoice
のフィールド4を予約、legacy_customer_idは消滅」は、自分のコードベースの何かがまだそのフィー
ルドを読んでいないか確認するよう伝える。これはRESTスタイルの「応答からフィールドを削除」という
注記が同じ緊急性で伝えないものだ。REST削除は単に少ないデータを返すだけだが、Protobufのフィー
ルド再利用はそれを積極的に壊すからだ。
FAQ
フィールドの型はワイヤーフォーマットを壊さずに変更できることがあるか?
Protobufが文書化する特定の互換グループの範囲内でのみ、たとえば場合によってはint32を
int64に拡張することなどだ。Protobuf自身の互換性テーブルに照らして確認しない限り、どんな
型変更もbreakingとして扱うこと。言語の型システムとの類推で互換性を仮定することが、うまくいか
なくなる原因だ。
Protobufのフィールド非推奨化はGraphQLの@deprecatedディレクティブのように機能するか?
似ている。Protobufはツールが表示できるフィールドオプション[deprecated = true]をサポート
する。どちらも強制されない。GraphQLサーバーは非推奨のフィールドへのクエリにも応答し続け、
protobufクライアントもそれをエンコードし続ける。どちらも助言的であり、同じチェンジログの
サポートを必要とする。
すべてのクライアントを制御していれば番号の振り直しは安全か? 完全に閉じたシステムでは原則としてそうだが、それはフィールド番号が存在する理由である安全性 の特性全体を取り除いてしまう。「すべてのクライアントを制御している」は、ビルドがキャッシュ されたり、デプロイが遅延したり、誰も覚えていなかったクライアントが追加されたりした瞬間に 真でなくなる主張だ。社内であっても、番号を再利用する代わりに予約すること。
gRPCサービスは公開REST APIと同じようにチェンジログのページを必要とするか?
.protoのdiffを直接読まない外部チームがそれを利用する場合のみで、これは内部API
チェンジログが一般に適用する「相手側は誰か」というテスト
と同じだ。同じチームの他のサービスだけが利用するgRPCサービスは、正式なチェンジログなしで
コミット履歴に頼ることがしばしば可能だ。それを読む人は誰でもすでにスキーマを開いているから
だ。
この記事の技術的な記述は第三者による確認を受けていません。誤りがあればお知らせください。修正します。