プログラマのための IT 教科書
第5部 サービスをつくる

スキーマと 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 にして削除番号が再利用されない

危険な変更

変更何が起きるか
フィールド番号を変える別のフィールドとして解釈される。データが壊れる
型を変えるstringint32デコードに失敗する、または無意味な値になる
番号を再利用する古いクライアントが送った値が、新しい意味で解釈される

削除する時は 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つの通信方式

方式使いどころ
Unary1リクエスト → 1レスポンス通常の API
Server streaming1 → 複数大量データを少しずつ返す
Client streaming複数 → 1ログやデータのアップロード
Bidirectional複数 ↔ 複数チャット、リアルタイム同期

普段使うのはほぼ Unary です。ストリーミングは「必要になったら思い出す」で十分です。

なぜ HTTP/2 の上にあるのか

第8章で扱った HTTP/2 の多重化が土台です。

1本の TCP 接続で複数のやり取りを同時に流せるため、 ストリーミングや大量の並行リクエストが現実的な性能で動きます。

HTTP/1.1 では1接続1リクエストなので、これは不可能でした。

ブラウザから gRPC は直接呼べない

ブラウザは 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

UNAVAILABLEDEADLINE_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 で必ず踏む

「指定されたフィールドだけ更新する」API を作る時、この区別がないと 「0 を送った」のか「送らなかった」のかが分からず、意図しない上書きが起きます。

optional を付けるか、google.protobuf.FieldMask で 「どのフィールドを更新するか」を明示的に渡す設計にします。

実務の落とし穴まとめ

  1. フィールド番号の変更・再利用 — データが静かに壊れる。reserved を書く
  2. 型の変更 — デコードが壊れる。新しい番号で追加する
  3. 生成コードを手で直す — 次の生成で消える
  4. proto3 のゼロ値問題 — 部分更新で意図しない上書き
  5. ブラウザから直接呼ぼうとする — gRPC-Web かゲートウェイが必要

まとめ

  • .protoスキーマをコードとして持つ仕組み。ドキュメントが腐らない
  • ワイヤフォーマットにはフィールド番号だけが入る。だから小さく、名前は変更自由
  • 番号と型は絶対に変えない。削除したら reserved
  • gRPC は .proto からサーバーとクライアントを生成する
  • HTTP/2 の多重化が土台。だからストリーミングが実用的
  • gRPC のエラーコードはリトライしてよいかの判断に使える
  • proto3 のゼロ値と未設定の区別に注意。部分更新では optional

章末問題

使わなくなったフィールド(番号 3)を .proto から削除します。正しい対応は?

gRPC の呼び出しが UNAVAILABLE を返しました。どう扱うべきですか。

次の章では、これらのサービスがデータを保存する先——データベースを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。