protobufスキーマとgRPC通信
Wantedlyでは主に以下の3種類のプロトコルを通信に使っています。
    HTTP + JSON
    gRPC + protobuf
    GraphQL
それぞれの利点・欠点を踏まえて、2021年現在は以下のような使い分けがなされています。
    マイクロサービス間通信
      原則として gRPC + protobuf を使う。
      昔の名残りで HTTP + JSON も使われている。
    Webフロントエンドアプリとシステムとの通信
      できるだけ GraphQL を使う。
      昔の名残りで HTTP + JSON も使われている。
    モバイルアプリとシステムとの通信
      GraphQL の導入が始まっている。
      今のところは HTTP + JSON がメインで使われている。
本稿では 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定義も記載することができます
1
// users.proto
2
3
syntax = "proto3";
4
package wantedly.users;
5
6
// protobufのスキーマ定義
7
message User {
8
// = の後に書かれているのは初期値やデフォルト値ではなく、「タグ」という背番号。
9
uint64 id = 1;
10
string name = 2;
11
}
12
13
message GetUserRequest {
14
uint64 id = 1;
15
}
16
17
// gRPCのAPI定義
18
service UsersService {
19
rpc GetUser(GetUserRequest) returns (User) {}
20
}
Copied!
protoファイルを作ったら、これを各プログラミング言語の実装に変換する必要があります。これはprotocというコンパイラを使って行います。
protoc本体が対応しているのはC++, C#, Java, JavaScript, Objective-C, PHP, Python, Rubyの7言語だけですが、それ以外の言語でもプラグインを用意することでコンパイルが可能です。たとえばGoはprotoc本体に同梱されていないだけで、Go側から使える google.golang.org/protobuf という公式ライブラリにコンパイラが組込まれています。
たとえば、Rubyへのコンパイルは以下のようにして行えます。
1
# users_pb.rb が生成される
2
protoc users.proto --ruby_out=.
Copied!
出力されたコードにはクラス定義が含まれていて、メッセージオブジェクトを生成してシリアライズすることができます。
1
irb(main):001:0> require './users_pb'
2
=> true
3
irb(main):002:0> user = Wantedly::Users::User.new(id: 42, name: "Tanaka Taro")
4
=> <Wantedly::Users::User: id: 42, name: "Tanaka Taro">
5
irb(main):007:0> user.to_proto
6
=> "\b*\x12\vTanaka Taro"
Copied!

protoファイルとJSON API

protoファイルにprotobufのスキーマ定義とgRPCのAPI定義を書けることを紹介しましたが、実はprotoファイルにはJSONのスキーマ定義とHTTPのAPI定義を書くこともできます。
1
// JSONへの対応付けは自動的に生成される
2
// この例では { "id": "42", "name": "Tanaka Taro" } にマッピングされる
3
message User {
4
uint64 id = 1;
5
string name = 2;
6
}
7
8
// serviceをHTTPにマッピングするには追加のアノテーションが必要
9
service UsersService {
10
rpc GetUser(GetUserRequest) returns (User) {
11
option (google.api.http) = {
12
get: "/api/users/{id}"
13
};
14
}
15
}
Copied!
これにより gRPC + protobuf で実装されたAPIを HTTP + JSON に自動変換して提供することができます。この機能はgrpc-gatewayというGoのライブラリで実際に実装されています。2021年現在、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の互換性

たとえば、以下のような変更をすると正しく通信できなくなってしまいます。
1
message User {
2
// 型を変更した
3
fixed64 id = 1;
4
// タグを変更した
5
string name = 3;
6
}
Copied!
このような事態を防ぐためにも、型とタグは変更してはいけません。例外として互換性のある型はいくつかありますが、本稿では詳細は述べません。oneofやenumの互換性についても気をつけるべき点がいくつかありますが、必要に応じてproto3のLanguage Guideを参照するといいでしょう。
gRPCではメソッドを修飾名で区別するので、パッケージ名を含めた名前の変更も基本的には行ってはいけません。
状況次第で以下のような追加の要件もあります。
    Any型を使っているとき
      Any型に登場するmessageの修飾名を変えてはいけません。
    JSONマッピングを使っているとき
      フィールド名を変えてはいけません。

ライブラリとしての互換性

生成されるライブラリのAPIが変わるような変更も極力避けましょう。典型的にはフィールド名の変更がこれに当たります。

非推奨化と予約

フィールドを安全に削除するために、非推奨化と予約という機能があります。
非推奨化 (deprecation) はフィールド等を実際には削除せずに警告扱いにする方法です。JavaやGoなどでは警告用のアノテーションがつけられるのでコンパイル時に古いコードを発見することができます。
1
message User {
2
uint64 id = 1;
3
// 非推奨。かわりにprofile.nameを使ってください。
4
string name = 2 [deprecated = true];
5
Profile profile = 3;
6
}
Copied!
予約 (reservation) は番号が誤って再利用されるのを防ぐための仕組みです。
1
message User {
2
uint64 id = 1;
3
// 将来間違って2を再利用しないようにreserveしておく
4
reserved 2;
5
// JSONマッピングを使っている場合、フィールド名も予約するとよい
6
reserved "name";
7
Profile profile = 3;
8
}
Copied!
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は直和的なデータを表現するための道具です。
1
message Notification {
2
string message = 1;
3
// image, video, userのうち1つを選択
4
oneof rich_content {
5
Image image = 10;
6
Video video = 11;
7
User user = 12;
8
}
9
}
Copied!
GoやRust(prost)では実際に直和的な表現になりますが、言語によっては単なるoptionalフィールドの集まりのように表現されます。 (ただし、複数の選択肢が同時に有効にならないような仕組みは通常あります) 実はprotobufのエンコーディング的にも、「単なるoptionalフィールドの集まり」として表現されています。また、JSONマッピングでも後者の解釈で表現されるので、気持ちの持ち方としてはこちらで考えておいたほうがいいでしょう。
また、protobufのoneofは「どのフィールドもnull」という状態が許容されているので注意が必要です。このケースを考慮しておくことは、oneofのフィールドが増えたときの互換性の維持のためにも有用です。

gRPC APIの基本

gRPC側は比較的シンプルで、名前、引数型、戻り値型の3つを決めることで新しいAPIが生やせます。
1
service UsersService {
2
rpc GetUser(GetUserRequest) returns (User) {}
3
// ^^^^^^^ 名前 ^^^^ 戻り値型
4
// ^^^^^^^^^^^^^^ 引数型
5
}
Copied!
引数型や戻り値型に stream という指定をつけることで、 Server Streaming / Client Streaming / BiDi Streaming という特別なモードにすることもできます。これはJavaScriptのジェネレーター関数のようなものをイメージするといいでしょう。使う機会は多くないですが、覚えておくと車輪の再発明を防げるかもしれません。
rpc の外側にある service はRPCの実装単位です。 service 内に複数の rpc がある場合、それらのrpcはまとめて実装することになります。Rubyでは service ごとに1つのクラスが生成されますし、 Goでは service ごとに1つのinterfaceが生成されます。
1
// GetUserとListUsersはまとめて実装する
2
service UsersService {
3
rpc GetUser(GetUserRequest) returns (User) {}
4
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {}
5
}
6
7
// これは別のサーバーで実装されるかもしれない。
8
service BooksService {
9
rpc GetUser(GetBookRequest) returns (Book) {}
10
rpc ListUsers(ListBooksRequest) returns (ListBooksResponse) {}
11
}
Copied!

gRPC APIの設計

gRPCの骨組みは比較的単純ですが、実際にAPIを設計しようとするとそれなりに自由度があることがわかります。この自由度を抑えつつできるだけよい設計に近づけるために、GoogleのAPI Design Guideを参考にしています。
上記のAPIガイドラインに含まれない、より高度な提案はGoogle API Improvement Proposals (AIPs)というサイトにまとめられています。こちらも必要に応じて参照します。
本稿では特に指摘が起きやすい点をいくつか抜き出して説明します。

列挙型(Enum)

列挙型の名前は UpperCamelCase にし、列挙値は CAPITALIZED_NAMES_WITH_UNDERSCORES を使用することが推奨されています。また、列挙型の最初の値はデフォルト値となるため ENUM_TYPE_UNSPECIFIED という名前にすることが推奨されます。
列挙型は C++ と同じようなスコープになっているため、列挙値は列挙型が定義されているレベルでのスコープとなります。例えば次の定義では、Sample1Sample2 は同レベルに存在するため同じ BAR という名前を使用することができません。Sample1Test.Sample3 のようにレベルが違う場合は同じ名前を使用することができます。特にトップレベルに列挙型を定義する場合にはスコープに気を付けて定義してください。
1
enum Sample1 {
2
FOO = 0;
3
BAR = 1;
4
}
5
enum Sample2 {
6
BAR = 0;
7
BAZ = 1;
8
}
9
10
message Test {
11
emum Sample3 {
12
BAR = 0;
13
}
14
}
Copied!

リソース指向

Ruby on Railsを知っている人には馴染み深いと思いますが、リソースに注目してAPIを分割することが推奨されています。
たとえば、Ruby on Railsでは BooksController#index, #show, #delete などのアクションを生やすことがあります。同様に、Google API Design Guideでも操作対象のリソースを service にして、その下に標準的な名前の rpc を置くことを推奨しています
1
service BooksService {
2
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}
3
rpc GetBook(GetBookRequest) returns (Book) {}
4
rpc CreateBook(CreateBookRequest) returns (Book) {}
5
rpc UpdateBook(UpdateBookRequest) returns (Book) {}
6
rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) {}
7
}
Copied!
GetBook, CreateBook, UpdateBookが全てBookを返していることに注目してください。この部分がGetBookResponse, CreateBookResponseのようになっていたらコードスメルです。

Input only, output only

リソース指向と関連する話ですが、Google API Design Guideでは同じリソース型を入出力で再利用することを推奨しています。
1
message UpdateBookRequest {
2
// 入力としてのBook
3
Book book = 1;
4
google.protobuf.FieldMask update_mask = 2;
5
}
Copied!
入力で必要なフィールドと出力で必要なフィールドは必ずしも一致しないので、別のメッセージに分けてしまいたくなる人もいると思いますが、区別することで得られるメリットは複雑性の増加に見合わないとGoogleは判断したのでしょう。ここは大人しくGoogleの経験に従うのがいいでしょう。
かわりに、リソース型の中で入力専用フィールドや出力専用フィールドに説明を加えることが推奨されています。
1
message Book {
2
uint32 id = 1;
3
// Input only. 書籍の登録時にISBNを指定する。
4
string isbn = 2;
5
// Output only. 書誌情報から取得した題名。
6
string title = 3;
7
// Input only.
8
// 書籍の表紙をユーザーがアップロードするときはS3のURLをここに入れる。
9
string cover_image_url = 4;
10
}
Copied!

標準フィールド名

Standard Fields にフィールド名の命名規則が挙げられています。
命名は必ずしも合理性があって決まっているわけではないですが、長い物に巻かれるためにも迷ったら従っておくのがいいでしょう。

エラーの表現

APIから詳細なエラー情報を返したくなることがあります。この場合のベストプラクティスはErrorsにまとめられています。
リソース指向デザインを維持するため、またエラーを正しく各プログラミング言語のエラーにマップするために、エラーはgRPCのエラーの枠組みで返す (正常系のレスポンスの一部として返さない) ようにしてください。
gRPCのエラーには通常「ステータスコード」と「英語のエラーメッセージ」の2つが入っていますが、それに加えて任意のAny型のペイロードを入れられるようになっていて、エラーペイロードとして使うための標準的な型がいくつか定義されています。

ページネーション

ページネーションにも推奨の方法があります。 Paginationにまとめられています。

ドキュメンテーション

Protobuf のメッセージや Enum の定義では、すべてのフィールド・値に対して1行以上のコメントで、そのフィールドの説明を記述する必要があります(SHOULD)。
Wantedly のプロダクト開発では、Protobuf IDL で記述されたメッセージ定義・API スキーマを使ってバックエンドとフロントエンド(Web・モバイルアプリ)のエンジニアがコミュニケーションをします。 そのため、.proto ファイルにフィールドの説明・仕様・想定している使われ方などを明記しておくことで、円滑なコミュニケーションの助けになり、余計な確認や手戻りを大きく減らすことにつながります。
1
// コメントの例
2
// 内部リンク: https://github.com/wantedly/apis/blob/master/wantedly/profile_page/link_collection.proto
3
4
message LinkCollection {
5
// Required. プロフィールページ上に表示する SNS アカウントの一覧。
6
// 他人のプロフィールページのときは、原則連係済みアカウントのみが表示される。
7
// 自分のプロフィールページのときは、まだ連携していない SNS アカウントのプレースホルダが出る場合がある。
8
// この一覧に含まれない SNS アカウントに関しては編集ハーフモーダル上にのみ表示される。
9
// 代表的な例としては Google などがこれに当たる。
10
// 表示順には影響しない。
11
repeated wantedly.users.v2.SocialProfile.Provider displayed_social_providers = 3 [packed=false];
12
}
Copied!

gRPC 通信のエラーハンドリング

gRPCをリクエストする側は、サーバー側から返ってくるエラーを必要に応じてハンドリングしてあげる必要があります。
エラーのハンドリング方法としては以下のようなものがあります。
    リトライ
    フォールバック
      別の手段を使う(別APIを使うなど)
      空の値や既定値を返す
    エスカレーション(エラーを受け止めた上で別の形にして呼び出し元に失敗を伝える)
2回までリトライして、それでもダメならフォールバックして空の値を返す、のようにこれらの手段を組み合わせる場合もあります。
以下に、各エラーに対する推奨されるハンドリングポリシーを示します。空欄はまだポリシーを議論していないもの、- は議論した上で現時点で統一的なポリシーは持たないと判断したものを示します。- となっていても個別の rpc ではハンドリングした方がいい状況もあります。
エラーの種類
エラーの説明
ハンドリングポリシー
INVALID_ARGUMENT
クライアントが無効な引数を指定しました。詳しくは、エラー メッセージとエラーの詳細を確認してください。
FAILED_PRECONDITION
空でないディレクトリの削除など、現在のシステム状態ではリクエストを実行できません。
OUT_OF_RANGE
クライアントが無効な範囲を指定しました。
UNAUTHENTICATED
OAuth トークンがない、もしくは無効、期限切れのためにリクエストが認証されませんでした。
PERMISSION_DENIED
クライアントに十分な権限がありません。これは、OAuth トークンに正しいスコープが割り当てられていないか、クライアントに権限が付与されていない、または API が有効になっていないことが原因で発生します。
NOT_FOUND
指定されたリソースが見つかりません。
ABORTED
同時実行の競合(読み取り - 変更 - 書き込みの競合など)。
ALREADY_EXISTS
クライアントが作成しようとしたリソースはすでに存在します。
RESOURCE_EXHAUSTED
リソース割り当てが不足しているか、レート制限に達しています。詳しくは、クライアントは google.rpc.QuotaFailure エラーの詳細を確認してください。
CANCELLED
リクエストはクライアントによってキャンセルされました。
DATA_LOSS
復元できないデータ損失またはデータ破損。クライアントはエラーをユーザーに報告する必要があります。
UNKNOWN
不明なサーバーエラー。通常、サーバーのバグです。
当該RPCの成功が、現在実行中の処理の継続に必要でない場合、実行中の処理を中断させないようにする。
INTERNAL
内部サーバーエラー。通常、サーバーのバグです。
当該RPCの成功が、現在実行中の処理の継続に必要でない場合、実行中の処理を中断させないようにする。
NOT_IMPLEMENTED
API メソッドはサーバーによって実装されていません。
UNAVAILABLE
サービス利用不可。通常、サーバーがダウンしています。
rpc の内容によらず確率的に発生しうるため、基本的にハンドリングする。ハンドリングの方法はリトライ、フォールバック、エスカレーションのいずれか。
DEADLINE_EXCEEDED
リクエスト期限を超えました。これは、呼び出し元が、メソッドのデフォルト期限よりも短い期限を設定し(つまり、要求された期限はサーバーがリクエストを処理するのに十分ではない)、リクエストがその期限内に完了しなかった場合にのみ発生します。
rpc の内容によらず確率的に発生しうるため、基本的にハンドリングする。ハンドリングの方法はリトライ、フォールバック、エスカレーションのいずれか。

話を聞きに行きたい

もっと知りたい

最終更新 29d ago