protobuf スキーマと gRPC 通信
Wantedlyでは主に以下の3種類のプロトコルを通信に使っています。
gRPC + protobuf
GraphQL
HTTP + JSON
それぞれの利点・欠点を踏まえて、2024年現在は以下のような使い分けがなされています。
マイクロサービス間通信
原則として gRPC + protobuf を使う。
昔の名残りで HTTP + JSON も使われている。gRPC + protobufへの移行中
Webアプリ・モバイルアプリとシステムとの通信
原則として GraphQL を使う。
昔の名残りで HTTP + JSON も使われている。GraphQLへの移行中
本稿では gRPC + protobuf の入門とWantedlyにおけるベストプラクティスを紹介します。
protobufとgRPC
protobuf (Protocol Buffers) はデータフォーマットで、JSONの役割を置き換えるものです。一方 gRPC は通信プロトコルで、HTTPの役割を置き換えるものです。
gRPC + JSON や HTTP + protobuf のような組み合わせも可能ですが、Wantedlyでは使わないので以降では考えません。
JSONとprotobufの重要な違いとして、protobufはフォーマットがスキーマに依存するという点があります。JSONはスキーマがなくても完全なシリアライズ・デシリアライズが可能ですが、protobufのデータをシリアライズ・デシリアライズするにはスキーマ情報が必要です。gRPCは技術的には必ずしもスキーマ依存ではありませんが、実装上はスキーマなしで実装するのは困難です。
この技術的制約によりスキーマファースト開発が強制されるのが protobuf + gRPC の強みのひとつです。スキーマファーストであることによって以下のような利点があります。
サーバーとクライアントが実装を並行して進めることができる。
データに関する暗黙の知識を言語化し、共有する機会になる。
protobuf を利用している、より詳しい背景についてはブログ記事 Protocol Buffers によるプロダクト開発のススメ - API 開発の今昔 - を参照してください。
protoファイル
protobufのスキーマはprotoファイルという専用のフォーマットで記述します。このファイルにはprotobufのスキーマに加えて、gRPCのAPI定義も記載することができます。
protoファイルを作ったら、これを各プログラミング言語の実装に変換する必要があります。これはprotocというコンパイラを使って行います。
protoc本体が対応しているのはC++, C#, Java, JavaScript, Objective-C, PHP, Python, Rubyの7言語だけですが、それ以外の言語でもプラグインを用意することでコンパイルが可能です。たとえばGoはprotoc本体に同梱されていないだけで、Go側から使える google.golang.org/protobuf
という公式ライブラリにコンパイラが組込まれています。
たとえば、Rubyへのコンパイルは以下のようにして行えます。
出力されたコードにはクラス定義が含まれていて、メッセージオブジェクトを生成してシリアライズすることができます。
protoファイルとJSON API
protoファイルにprotobufのスキーマ定義とgRPCのAPI定義を書けることを紹介しましたが、実はprotoファイルにはJSONのスキーマ定義とHTTPのAPI定義を書くこともできます。
これにより gRPC + protobuf で実装されたAPIを HTTP + JSON に自動変換して提供することができます。この機能はgrpc-gatewayというGoのライブラリで実際に実装されていますが、gRPCとGraphQLが標準になった現在では非推奨となります。 2024年現在、Wantedlyでは一部のモバイル向けAPIをこの方法で提供しています。
APIs (wantedly/apis)
スキーマ・API定義はマイクロサービス間で共有されてはじめて意味のあるものです。WantedlyではAPI定義のための中央リポジトリ wantedly/apis を用意することで定義の共有を実現しています。
wantedly/apis に変更を加えると、従属する以下のリポジトリが自動的に更新されます。
wantedly/apis-ruby
wantedly/apis-go
wantedly/apis-python
etc.
各マイクロサービスでこれらのパッケージに依存し、必要に応じて更新することで、APIの変更を一貫した形で反映することができるようになっています。
互換性
マイクロサービスアーキテクチャではマイクロサービス境界はチーム境界と対応するのが原則ですから、API定義は他チームとの間の規約に他なりません。そのため、APIに変更を加えるときは互換性を保つのが大原則です。 (まだ本番投入されていないことが明らかな場合など、例外的に互換性を壊す判断をすることもあります)
protoファイルにおける「互換性」には2種類の視点があります。ひとつはスキーマ・通信APIの互換性です。もうひとつはライブラリとしてのAPIの互換性です。とはいえ、継続的な更新を可能にするために、いずれの互換性も保つことが望ましいです。
スキーマ・通信APIの互換性
たとえば、以下のような変更をすると正しく通信できなくなってしまいます。
このような事態を防ぐためにも、型とタグは変更してはいけません。例外として互換性のある型はいくつかありますが、本稿では詳細は述べません。oneofやenumの互換性についても気をつけるべき点がいくつかありますが、必要に応じてproto3のLanguage Guideを参照するといいでしょう。
gRPCではメソッドを修飾名で区別するので、パッケージ名を含めた名前の変更も基本的には行ってはいけません。
状況次第で以下のような追加の要件もあります。
Any型を使っているとき
Any型に登場するmessageの修飾名を変えてはいけません。
JSONマッピングを使っているとき
フィールド名を変えてはいけません。
ライブラリとしての互換性
生成されるライブラリのAPIが変わるような変更も極力避けましょう。典型的にはフィールド名の変更がこれに当たります。
非推奨化と予約
フィールドを安全に削除するために、非推奨化と予約という機能があります。
非推奨化 (deprecation) はフィールド等を実際には削除せずに警告扱いにする方法です。JavaやGoなどでは警告用のアノテーションがつけられるのでコンパイル時に古いコードを発見することができます。
予約 (reservation) は番号が誤って再利用されるのを防ぐための仕組みです。
API定義は全てのマイクロサービスで継続的に更新するのが望ましいですが、実際にはそうならないこともあります。その場合、とても古いAPI定義を使っているマイクロサービスと、最新のAPI定義を使っているマイクロサービスが共存する可能性があります。これらのマイクロサービスが2番のタグを別の意味で使っていた場合に何らかの事故が起こる可能性があるので、できるだけreservedを使うようにしましょう。
タグは15以下、2047以下でそれぞれ1バイトずつ節約できる点を除けば、基本的にどの番号でも違いはありません。無理に詰めようとせず、互換性を最大限に保つ形で割り当てましょう。
nullとoneofの扱い
JSONから入ってきた人にとって戸惑いのもとになりがちなのが、nullとoneofの扱いです。
protobufにはnullという値はなく、フィールドが存在しないことをnullとして表現することがあります。ところが、フィールドが存在しない場合でも別のデフォルト値が使われることもあり、その規則は以下のように複雑です。
repeatedの場合: 空配列 (
[]
)mapの場合: 空マップ (
{}
)repeatedでもmapでもない場合
ネストしたメッセージの場合:
null
スカラー型 (プリミティブ型、enum型) の場合
oneofの一部の場合:
null
optionalの場合 (protoc 3.15.0以降の機能):
null
oneofでもoptionalでもない場合: プリミティブ型のデフォルト値が使われる
整数型:
0
浮動小数点数型:
0.0
bool型:
false
文字列・バイト列: 空文字列 (
""
)enum: enumの最初の値 =
0
なお、mapのキーが省略された場合はスカラー型の規則に従い、値が省略されたときの挙動は実装依存です。
nullがあるかないかはAPI定義においては重要な関心のひとつなので、大変ですが上の規則は覚えてしまうのがいいでしょう。
nullをうまく表明するテクニックは以下の通りです。
nullが欲しいのにない場合
protoc 3.15.0以降ではoptionalが使えます。
プリミティブ型に関しては、
google.protobuf.StringValue
などのラッパー型が提供されています。enumやrepeated/mapにnullが必要ならmessageでラップするのがいいでしょう。ただし、enumは最初の値をnull相当の値として扱うべしとされているので、nullが必要な状況はコードスメルかもしれません。
nullの可能性を排除したい場合
ネストしたメッセージがnullでないことを表明する構文はありません。
Wantedlyでは
// Required
というコメントをつけています。アノテーションを用意する場合もあるようです。
oneofは直和的なデータを表現するための道具です。
GoやRust(prost)では実際に直和的な表現になりますが、言語によっては単なるoptionalフィールドの集まりのように表現されます。 (ただし、複数の選択肢が同時に有効にならないような仕組みは通常あります) 実はprotobufのエンコーディング的にも、「単なるoptionalフィールドの集まり」として表現されています。また、JSONマッピングでも後者の解釈で表現されるので、気持ちの持ち方としてはこちらで考えておいたほうがいいでしょう。
また、protobufのoneofは**「どのフィールドもnull」という状態が許容されている**ので注意が必要です。このケースを考慮しておくことは、oneofのフィールドが増えたときの互換性の維持のためにも有用です。
gRPC APIの基本
gRPC側は比較的シンプルで、名前、引数型、戻り値型の3つを決めることで新しいAPIが生やせます。
引数型や戻り値型に stream
という指定をつけることで、 Server Streaming / Client Streaming / BiDi Streaming という特別なモードにすることもできます。これはJavaScriptのジェネレーター関数のようなものをイメージするといいでしょう。使う機会は多くないですが、覚えておくと車輪の再発明を防げるかもしれません。
rpc
の外側にある service
はRPCの実装単位です。 service
内に複数の rpc
がある場合、それらのrpcはまとめて実装することになります。Rubyでは service
ごとに1つのクラスが生成されますし、 Goでは service
ごとに1つのinterfaceが生成されます。
gRPC APIの設計
gRPCの骨組みは比較的単純ですが、実際にAPIを設計しようとするとそれなりに自由度があることがわかります。この自由度を抑えつつできるだけよい設計に近づけるために、GoogleのAPI Design Guideを参考にしています。
上記のAPIガイドラインに含まれない、より高度な提案はGoogle API Improvement Proposals (AIPs)というサイトにまとめられています。こちらも必要に応じて参照します。
本稿では特に指摘が起きやすい点をいくつか抜き出して説明します。
列挙型(Enum)
命名については下のように行うことが推奨されています。
列挙型:
UpperCamelCase
列挙値:
CAPITALIZED_NAMES_WITH_UNDERSCORES
デフォルト値:
ENUM_TYPE_UNSPECIFIED
列挙型は C++ と同じようなスコープになっているため、列挙値は列挙型が定義されているレベルでのスコープとなります。例えば次の定義では、Sample1 と Sample2 は同レベルに存在するため同じ BAR という名前を使用することができません。Sample1 と Test.Sample3 のようにレベルが違う場合は同じ名前を使用することができます。特にトップレベルに列挙型を定義する場合にはスコープに気を付けて定義してください。
リソース指向
Ruby on Railsを知っている人には馴染み深いかもしれませんが、リソースに注目してAPIを分割することが推奨されています。
たとえば、Ruby on Railsでは BooksController
に #index
, #show
, #delete
などのアクションを生やすことがあります。同様に、Google API Design Guideでも操作対象のリソースを service
にして、その下に標準的な名前の rpc
を置くことを推奨しています。
GetBook, CreateBook, UpdateBookが全てBookを返していることに注目してください。この部分がGetBookResponse, CreateBookResponseのようになっていたらコードスメルです。
Input only, output only
リソース指向と関連する話ですが、Google API Design Guideでは同じリソース型を入出力で再利用することを推奨しています。
入力で必要なフィールドと出力で必要なフィールドは必ずしも一致しないので、別のメッセージに分けてしまいたくなる人もいるかもしれませんが、区別することで得られるメリットは複雑性の増加に見合わないとGoogleは判断したのでしょう。ここは大人しくGoogleの経験に従うのがいいでしょう。
かわりに、リソース型の中で入力専用フィールドや出力専用フィールドに説明を加えることが推奨されています。
標準フィールド名
Standard Fields にフィールド名の命名規則が挙げられています。
命名は必ずしも合理性があって決まっているわけではないですが、長い物に巻かれるためにも迷ったら従っておくのがいいでしょう。
エラーの表現
APIから詳細なエラー情報を返したくなることがあります。この場合のベストプラクティスはErrorsにまとめられています。
リソース指向デザインを維持するため、またエラーを正しく各プログラミング言語のエラーにマップするために、エラーはgRPCのエラーの枠組みで返す (正常系のレスポンスの一部として返さない) ようにしてください。
gRPCのエラーには通常「ステータスコード」と「英語のエラーメッセージ」の2つが入っていますが、それに加えて任意のAny型のペイロードを入れられるようになっていて、エラーペイロードとして使うための標準的な型がいくつか定義されています。
ページネーション
ページネーションにも推奨の方法があります。 Google API Design Guide の Design Patterns のページに Pagination のセクションがあります。
ドキュメンテーション
Protobuf のメッセージや Enum の定義では、すべてのフィールド・値に対して1行以上のコメントで、そのフィールドの説明を記述する必要があります(SHOULD)。
Wantedly のプロダクト開発では、Protobuf IDL で記述されたメッセージ定義・API スキーマを使ってバックエンドとフロントエンド(Web・モバイルアプリ)のエンジニアがコミュニケーションをします。 そのため、.proto ファイルにフィールドの説明・仕様・想定している使われ方などを明記しておくことで、円滑なコミュニケーションの助けになり、余計な確認や手戻りを大きく減らすことにつながります。
gRPC 通信のエラーハンドリング
gRPCをリクエストする側は、サーバー側から返ってくるエラーを必要に応じてハンドリングしてあげる必要があります。
エラーのハンドリング方法としては以下のようなものがあります。
リトライ
フォールバック
別の手段を使う(別APIを使うなど)
空の値や既定値を返す
エスカレーション(エラーを受け止めた上で別の形にして呼び出し元に失敗を伝える)
2回までリトライして、それでもダメならフォールバックして空の値を返す、のようにこれらの手段を組み合わせる場合もあります。
以下に、各エラーに対する Wantedly で推奨されるハンドリングポリシーを示します。 ここで未定義とは、社内でまだ議論が十分なされておらず統一的なポリシーが確定していないことを示します。 各エラーの内容については Handling Errors を参照してください。
その他Wantedlyで利用しているツール群
Linter / Formatter
https://github.com/bufbuild/buf
https://github.com/yoheimuta/protolint
デバッグ
https://github.com/ktr0731/evans
https://github.com/rerost/giro
GraphQL Gatewayへの型変換
https://github.com/proto-graphql/proto-graphql-js
話を聞きに行きたい
Slack: #backend_chapter, #microservices
もっと知りたい
https://github.com/wantedly/apis
最終更新