スキーマと RPC
読了目安 50 分
- proto のバイト列を読める
- なぜ JSON ではなく proto を使うのかを説明できる
- 後方互換性を壊す変更を見分けられる
サービス同士が通信するとき、「何をやり取りするか」を先に決めておく方法があります。 それが protobuf(Protocol Buffers)と gRPC です。
JSON + REST に慣れていると、最初は面倒に感じます。
.proto ファイルを書いて、コードを生成して、やっと呼べる。
その手間を払う理由から始めます。
JSON の何が問題なのか
{ "userId": "42", "amount": 1200 }一見問題ありません。しかし、サービスが増えると次の問題が出ます。
| 問題 | 具体的に |
|---|---|
| 型が保証されない | amount が数値か文字列か、実際に受け取るまで分からない |
| 仕様がどこにあるか分からない | ドキュメントとコードがすぐ食い違う |
| 名前がキーごとに繰り返される | "userId" という文字列を毎回送っている |
| 変更が伝わらない | フィールド名を変えても、呼ぶ側は動かしてみるまで気づかない |
.proto は、この4つをまとめて解決します。
.proto を書く
syntax = "proto3";
message User {
string id = 1;
string name = 2;
int32 age = 3;
}= 1 = 2 はフィールド番号です。これが protobuf の心臓部です。
- 送信されるバイト列には、名前ではなくこの番号が入る
- だから小さい
- そして番号さえ合っていれば、名前は自由に変えられる
ワイヤフォーマットを読む
実際に何が送られているのかを見てみます。
message User {
string name = 1;
int32 age = 2;
}{ name: "Alice", age: 30 } を protobuf でエンコードすると、こうなります。
0A 05 41 6C 69 63 65 10 1E
分解します。
0A → フィールド番号 1、型は「長さ付き」(0x0A = 00001 010)
05 → 長さ 5 バイト
41 6C 69 63 65 → "Alice"
10 → フィールド番号 2、型は「可変長整数」(0x10 = 00010 000)
1E → 30
9バイトです。同じ内容の JSON は {"name":"Alice","age":30} で 25バイト。
先頭のバイトは (フィールド番号 << 3) | 型 という構造になっています。
つまりキー名の文字列はどこにも入っていません。
9バイト対25バイトという差も重要ですが、それ以上に大きいのは スキーマがコードとして存在することです。
.proto からサーバー用とクライアント用のコードが自動生成されるので、
「ドキュメントが古い」という問題が構造的に起きません。
後方互換性 — 実務事故の第1位
この節がこの章で最も重要です。
マイクロサービスでは、すべてのサービスを同時にデプロイできません。 新しいバージョンと古いバージョンが必ず同時に動く時間があります。
その間に壊れない変更が「後方互換な変更」です。
安全な変更
| 変更 | なぜ安全か |
|---|---|
| 新しいフィールドを追加(新しい番号で) | 古いコードは知らない番号を無視する |
| フィールド名を変える | 送られるのは番号なので影響しない |
フィールドを reserved にして削除 | 番号が再利用されない |
危険な変更
| 変更 | 何が起きるか |
|---|---|
| フィールド番号を変える | 別のフィールドとして解釈される。データが壊れる |
型を変える(string → int32) | デコードに失敗する、または無意味な値になる |
| 番号を再利用する | 古いクライアントが送った値が、新しい意味で解釈される |
削除する時は reserved
message User {
reserved 3; // この番号は二度と使わない
reserved "old_email"; // この名前も
string id = 1;
string name = 2;
string email = 4; // 3 は飛ばして 4 を使う
}reserved を書いておくと、誰かが誤って番号を再利用した時にコンパイルエラーになります。
フィールド 3 が int32 age だったとします。これを削除して、
後から誰かが string nickname = 3; を追加したらどうなるでしょうか。
古いクライアントが送った age = 30 を、新しいサーバーが
nickname として解釈します。データが静かに壊れ、
エラーも出ません。気づくのは数週間後です。
だから reserved を書きます。1行で防げる事故です。
proto でフィールド名を user_id から account_id に変更しました。既存のクライアントに影響はありますか。
gRPC
.proto にサービスの定義も書けます。
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
}ここからサーバーとクライアントのコードが自動生成され、 関数を呼ぶ感覚でネットワーク越しの処理を呼べます。
// Go のクライアント側
user, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "42"})// TypeScript のクライアント側
const user = await client.getUser({ id: '42' });同じ .proto から両方が生成されるので、食い違いが起きません。
4つの通信方式
| 方式 | 形 | 使いどころ |
|---|---|---|
| Unary | 1リクエスト → 1レスポンス | 通常の API |
| Server streaming | 1 → 複数 | 大量データを少しずつ返す |
| Client streaming | 複数 → 1 | ログやデータのアップロード |
| Bidirectional | 複数 ↔ 複数 | チャット、リアルタイム同期 |
普段使うのはほぼ Unary です。ストリーミングは「必要になったら思い出す」で十分です。
なぜ HTTP/2 の上にあるのか
第8章で扱った HTTP/2 の多重化が土台です。
1本の TCP 接続で複数のやり取りを同時に流せるため、 ストリーミングや大量の並行リクエストが現実的な性能で動きます。
HTTP/1.1 では1接続1リクエストなので、これは不可能でした。
ブラウザは HTTP/2 の低レベルな制御ができないため、gRPC をそのまま呼べません。
そのため実務では、次のどちらかになります。
- gRPC-Web — プロキシを挟んで変換する
- フロント向けには REST / GraphQL、サービス間は gRPC
後者が一般的です。「社内は gRPC、外向きは REST」という構成をよく見ます。
コード生成の流れ
user.proto
│
│ protoc(+ 各言語のプラグイン)
▼
┌────────────┬─────────────┐
│ user.pb.go │ user_pb.ts │ ← 生成されたコード
└────────────┴─────────────┘
.pb.go や _pb.ts は生成物です。手で直しても、次の生成で消えます。
直したいことがあるなら、直すのは .proto の方です。
生成物は Git にコミットするチームと、CI で毎回生成するチームがあります。
どちらの運用かを最初に確認してください。
エラーの扱い
gRPC には HTTP のステータスコードとは別の、専用のコードがあります。
| gRPC コード | 意味 | HTTP で言えば |
|---|---|---|
OK | 成功 | 200 |
INVALID_ARGUMENT | 引数がおかしい | 400 |
UNAUTHENTICATED | 認証されていない | 401 |
PERMISSION_DENIED | 権限がない | 403 |
NOT_FOUND | 見つからない | 404 |
ALREADY_EXISTS | 既にある | 409 |
DEADLINE_EXCEEDED | 時間切れ | 504 |
UNAVAILABLE | 一時的に使えない | 503 |
INTERNAL | サーバー内部エラー | 500 |
UNAVAILABLE と DEADLINE_EXCEEDED はリトライしてよい、
INVALID_ARGUMENT はリトライしても無駄、というように、
コードによってリトライの判断ができます。これは第16章に繋がります。
proto3 の注意点
デフォルト値と「未設定」の区別がない
proto3 では、フィールドを送らないのと「ゼロ値を送る」のが区別できません。
message UpdateUserRequest {
string id = 1;
int32 age = 2; // 0 が来た時、「0歳」なのか「未指定」なのか分からない
}「未指定」を表したい場合は optional を付けるか、ラッパー型を使います。
message UpdateUserRequest {
string id = 1;
optional int32 age = 2; // 設定されたかどうかを判定できる
}「指定されたフィールドだけ更新する」API を作る時、この区別がないと 「0 を送った」のか「送らなかった」のかが分からず、意図しない上書きが起きます。
optional を付けるか、google.protobuf.FieldMask で
「どのフィールドを更新するか」を明示的に渡す設計にします。
実務の落とし穴まとめ
- フィールド番号の変更・再利用 — データが静かに壊れる。
reservedを書く - 型の変更 — デコードが壊れる。新しい番号で追加する
- 生成コードを手で直す — 次の生成で消える
- proto3 のゼロ値問題 — 部分更新で意図しない上書き
- ブラウザから直接呼ぼうとする — gRPC-Web かゲートウェイが必要
まとめ
.protoはスキーマをコードとして持つ仕組み。ドキュメントが腐らない- ワイヤフォーマットにはフィールド番号だけが入る。だから小さく、名前は変更自由
- 番号と型は絶対に変えない。削除したら
reserved - gRPC は
.protoからサーバーとクライアントを生成する - HTTP/2 の多重化が土台。だからストリーミングが実用的
- gRPC のエラーコードはリトライしてよいかの判断に使える
- proto3 のゼロ値と未設定の区別に注意。部分更新では
optional
章末問題
使わなくなったフィールド(番号 3)を .proto から削除します。正しい対応は?
gRPC の呼び出しが UNAVAILABLE を返しました。どう扱うべきですか。
次の章では、これらのサービスがデータを保存する先——データベースを扱います。