Protocol Buffersを極める:多言語プロジェクトのためのスキーマファーストAPI設計

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

昨年、私たちのチームはGo、Python、TypeScriptフロントエンドの3つのマイクロサービスを保守していました。すべてREST JSON APIで互いに通信していましたが、フィールド名を変えるたびに誰かのサービスが午前2時に黙って壊れてしまいます。そんな経験からProtocol Buffersを真剣に使い始めました。

ProtobufはGoogleのバイナリシリアライズ形式ですが、それ以上に重要なのはサービス間のスキーマファーストなコントラクトです。データ構造を.protoファイルに一度定義すれば、どの言語でも型安全なクライアント/サーバーコードを生成できます。「あのフィールド名、何でしたっけ?」という場面とはおさらばです。

クイックスタート:5分で最初の.protoファイルを作成

まずツールチェーンをインストールします。ほとんどのシステムで1分もかかりません。

protocのインストール

# macOS
brew install protobuf

# Ubuntu/Debian
sudo apt install -y protobuf-compiler

# バージョン確認
protoc --version  # libprotoc 25.x

次に言語別プラグインをインストールします。私が日常的によく使うGoとPythonについて説明します。

# Goプラグイン
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# Python
pip install grpcio-tools

最初のスキーマを書く

user.protoを作成します:

syntax = "proto3";

package user.v1;

option go_package = "github.com/yourorg/protos/user/v1;userv1";

message User {
  string id = 1;
  string email = 2;
  string display_name = 3;
  int64 created_at = 4;
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(User) returns (User);
}

両言語向けのコードを生成する

# Goのコードを生成
protoc \
  --go_out=./gen/go \
  --go_opt=paths=source_relative \
  --go-grpc_out=./gen/go \
  --go-grpc_opt=paths=source_relative \
  user.proto

# Pythonのコードを生成
python -m grpc_tools.protoc \
  -I. \
  --python_out=./gen/python \
  --grpc_python_out=./gen/python \
  user.proto

完了です。これで1つの真実の源泉から、両言語で型安全なデータクラスとgRPCスタブが生成されました。その単一の.protoファイルがAPIコントラクトになります。

深掘り:Protobufの実際の仕組み

フィールド番号は神聖なもの

Protobufを扱う上で最も重要なコンセプトです。各フィールドには固有の整数(1、2、3…)があります。ワイヤー上でシリアライズされるのはフィールド名ではなく、この番号です。一度割り当てたら、番号を変更したり再利用したりしてはいけません。

message Order {
  string order_id = 1;
  repeated string item_ids = 2;
  // NG: リリース後にフィールド番号2を再割り当てしないこと
  // string customer_id = 2;  // item_idsと競合する!
  string customer_id = 3;     // 常に新しい番号を使うこと
}

フィールドを安全に削除する

reservedとしてマークせずにフィールドを削除するのは静かな罠です。古いクライアントは引き続きフィールド2を送信し続け、新しいコードが誤って別の目的でその番号を再利用してしまう可能性があります。

message Order {
  string order_id = 1;
  reserved 2;           // フィールド番号を永久に予約
  reserved "item_ids";  // フィールド名を永久に予約
  string customer_id = 3;
}

実務経験から言えば、これは習得すべき本質的なスキルの1つです。フィールド番号とreservedに関する規律こそが、スムーズなアップグレードをリリースできるチームと、深夜に本番環境の障害をデバッグするはめになるチームを分けるものです。

型クイックリファレンス

  • string — UTF-8テキスト
  • int32 / int64 — 符号付き整数(Unixタイムスタンプにはint64を使用)
  • bool — true/false
  • bytes — 生のバイナリデータ
  • float / double — 浮動小数点数
  • repeated FieldType — リスト/配列に相当
  • map<KeyType, ValueType> — キーと値のペア
  • optional FieldType — 未設定とゼロ値を区別する

高度な使い方:実プロジェクトでのバージョン管理

最初からパッケージパスにバージョンを埋め込む

私が実践してきた中で最善の構造的判断は、パッケージパスに直接バージョンを埋め込むことです。

protos/
├── user/
│   ├── v1/
│   │   └── user.proto      # package user.v1
│   └── v2/
│       └── user.proto      # package user.v2 — 破壊的変更はここに
├── order/
│   └── v1/
│       └── order.proto
└── buf.yaml

破壊的変更が必要な場合(フィールドの削除、型の変更)は、v2を作成し、すべてのコンシューマーが移行するまでv1を維持します。型システムが境界を強制してくれるため、REST形式の/api/v2/usersよりずっとクリーンです。

protocをbufに置き換える

生のprotocコマンドは、特にCIでは管理が面倒になりがちです。bufはProtobufのワークフロー全体を最新化します。リンティング、破壊的変更の検出、単一の設定ファイルからのコード生成が可能です。

# bufのインストール
brew install bufbuild/buf/buf

# protosディレクトリで初期化
buf mod init

プロジェクトルートにbuf.gen.yamlを作成します:

version: v1
plugins:
  - plugin: go
    out: gen/go
    opt: paths=source_relative
  - plugin: go-grpc
    out: gen/go
    opt: paths=source_relative
  - plugin: python
    out: gen/python
  - plugin: grpc-python
    out: gen/python

これでコード生成はコマンド1つで完了します:

buf generate

CIで破壊的変更を検出する

これこそがbufがパイプラインに欠かせない理由です。CIワークフローにこのチェックを追加してください:

# mainブランチとの差分で破壊的変更を検出
buf breaking --against '.git#branch=main'

# または公開済みのBuf Schema Registryモジュールと比較
buf breaking --against buf.build/yourorg/protos

開発者がフィールド番号を変更したり、reservedを使わずにフィールドを削除したりすると、ビルドが失敗します。破壊的変更の検出が、手動レビューに依存するのではなく自動化されます。

部分的な更新のためのオプショナルフィールド

proto3ではrequiredキーワードが廃止されましたが、「未指定」と「空に設定」を区別したい場面もあります。optionalを使いましょう:

message UpdateUserRequest {
  string id = 1;
  optional string display_name = 2;  // nil = スキップ、"" = フィールドをクリア
  optional string email = 3;
}

現場で学んだ実践的なヒント

生成コードをコミットする

チーム内で意見が分かれる話題です。私の考えはコミットすることです。コードレビューが明確になり(レビュアーは変更点を正確に確認できます)、protoに詳しくない開発者がビルドツールチェーンを用意する必要もなく、デプロイの再現性が保たれます。

生成コードが最新状態に保たれているか確認するCIチェックを追加しましょう:

buf generate
git diff --exit-code gen/   # 生成コードが古ければ失敗

Pythonで素早くサニティチェック

from gen.python import user_pb2

u = user_pb2.User()
u.id = "abc-123"
u.email = "[email protected]"
u.display_name = "Alice"

data = u.SerializeToString()
print(f"シリアライズ後: {len(data)} バイト")  # Protobufはコンパクト

u2 = user_pb2.User()
u2.ParseFromString(data)
print(u2.email)  # [email protected]

Protobufを使わない場面

Protobufは内部サービス間通信や高スループットのデータパイプラインで真価を発揮します。外部開発者が利用する公開REST APIでは、JSONの方がとっつきやすいです。バイナリ形式は追加ツールなしではデバッグが難しくなります。最もうまく機能するのは自分たちのサービス間の内部gRPCで、型安全性とパフォーマンスの向上が最も重要な場面です。

バイナリペイロードのデバッグ

バイナリワイヤーフォーマットは人間が読めないため、最初は戸惑う人も多いです。デバッグ用にJSON形式のロギングを補助として用意しておき、grpc_cligrpcurlツールでライブトラフィックを確認しましょう:

# grpcurlのインストール
brew install grpcurl

# curlのようにgRPCエンドポイントを呼び出す
grpcurl -plaintext \
  -d '{"id": "abc-123"}' \
  localhost:50051 \
  user.v1.UserService/GetUser

まず1つのサービスから始め、スキーマファーストのワークフローに慣れてから拡張していきましょう。投資が報われるのは、チームが破壊的なスキーマ変更をリリースしようとした際に、本番環境に触れる前にCIで検出される瞬間です。そして、それは思ったより早く訪れるでしょう。

Share: