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定義も記載することができます

// users.proto

syntax = "proto3";
package wantedly.users;

// protobufのスキーマ定義
message User {
  // = の後に書かれているのは初期値やデフォルト値ではなく、「タグ」という背番号。
  uint64 id = 1;
  string name = 2;
}

message GetUserRequest {
  uint64 id = 1;
}

// gRPCのAPI定義
service UsersService {
  rpc GetUser(GetUserRequest) returns (User) {}
}

protoファイルを作ったら、これを各プログラミング言語の実装に変換する必要があります。これはprotocというコンパイラを使って行います。

protoc本体が対応しているのはC++, C#, Java, JavaScript, Objective-C, PHP, Python, Rubyの7言語だけですが、それ以外の言語でもプラグインを用意することでコンパイルが可能です。たとえばGoはprotoc本体に同梱されていないだけで、Go側から使える google.golang.org/protobuf という公式ライブラリにコンパイラが組込まれています。

たとえば、Rubyへのコンパイルは以下のようにして行えます。

# users_pb.rb が生成される
protoc users.proto --ruby_out=.

出力されたコードにはクラス定義が含まれていて、メッセージオブジェクトを生成してシリアライズすることができます。

irb(main):001:0> require './users_pb'
=> true
irb(main):002:0> user = Wantedly::Users::User.new(id: 42, name: "Tanaka Taro")
=> <Wantedly::Users::User: id: 42, name: "Tanaka Taro">
irb(main):007:0> user.to_proto
=> "\b*\x12\vTanaka Taro"

protoファイルとJSON API

protoファイルにprotobufのスキーマ定義とgRPCのAPI定義を書けることを紹介しましたが、実はprotoファイルにはJSONのスキーマ定義とHTTPのAPI定義を書くこともできます。

// JSONへの対応付けは自動的に生成される
// この例では { "id": "42", "name": "Tanaka Taro" } にマッピングされる
message User {
  uint64 id = 1;
  string name = 2;
}

// serviceをHTTPにマッピングするには追加のアノテーションが必要
service UsersService {
  rpc GetUser(GetUserRequest) returns (User) {
    option (google.api.http) = {
      get: "/api/users/{id}"
    };
  }
}

これにより 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の互換性

たとえば、以下のような変更をすると正しく通信できなくなってしまいます。

message User {
  // 型を変更した
  fixed64 id = 1;
  // タグを変更した
  string name = 3;
}

このような事態を防ぐためにも、型とタグは変更してはいけません。例外として互換性のある型はいくつかありますが、本稿では詳細は述べません。oneofやenumの互換性についても気をつけるべき点がいくつかありますが、必要に応じてproto3のLanguage Guideを参照するといいでしょう。

gRPCではメソッドを修飾名で区別するので、パッケージ名を含めた名前の変更も基本的には行ってはいけません。

状況次第で以下のような追加の要件もあります。

  • Any型を使っているとき

    • Any型に登場するmessageの修飾名を変えてはいけません。

  • JSONマッピングを使っているとき

    • フィールド名を変えてはいけません。

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

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

非推奨化と予約

フィールドを安全に削除するために、非推奨化と予約という機能があります。

非推奨化 (deprecation) はフィールド等を実際には削除せずに警告扱いにする方法です。JavaやGoなどでは警告用のアノテーションがつけられるのでコンパイル時に古いコードを発見することができます。

message User {
  uint64 id = 1;
  // 非推奨。かわりにprofile.nameを使ってください。
  string name = 2 [deprecated = true];
  Profile profile = 3;
}

予約 (reservation) は番号が誤って再利用されるのを防ぐための仕組みです。

message User {
  uint64 id = 1;
  // 将来間違って2を再利用しないようにreserveしておく
  reserved 2;
  // JSONマッピングを使っている場合、フィールド名も予約するとよい
  reserved "name";
  Profile profile = 3;
}

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は直和的なデータを表現するための道具です。

message Notification {
  string message = 1;
  // image, video, userのうち1つを選択
  oneof rich_content {
    Image image = 10;
    Video video = 11;
    User user = 12;
  }
}

GoやRust(prost)では実際に直和的な表現になりますが、言語によっては単なるoptionalフィールドの集まりのように表現されます。 (ただし、複数の選択肢が同時に有効にならないような仕組みは通常あります) 実はprotobufのエンコーディング的にも、「単なるoptionalフィールドの集まり」として表現されています。また、JSONマッピングでも後者の解釈で表現されるので、気持ちの持ち方としてはこちらで考えておいたほうがいいでしょう。

また、protobufのoneofは**「どのフィールドもnull」という状態が許容されている**ので注意が必要です。このケースを考慮しておくことは、oneofのフィールドが増えたときの互換性の維持のためにも有用です。

gRPC APIの基本

gRPC側は比較的シンプルで、名前、引数型、戻り値型の3つを決めることで新しいAPIが生やせます。

service UsersService {
  rpc GetUser(GetUserRequest) returns (User) {}
  //  ^^^^^^^ 名前                      ^^^^ 戻り値型
  //          ^^^^^^^^^^^^^^ 引数型
}

引数型や戻り値型に stream という指定をつけることで、 Server Streaming / Client Streaming / BiDi Streaming という特別なモードにすることもできます。これはJavaScriptのジェネレーター関数のようなものをイメージするといいでしょう。使う機会は多くないですが、覚えておくと車輪の再発明を防げるかもしれません。

rpc の外側にある service はRPCの実装単位です。 service 内に複数の rpc がある場合、それらのrpcはまとめて実装することになります。Rubyでは service ごとに1つのクラスが生成されますし、 Goでは service ごとに1つのinterfaceが生成されます。

// GetUserとListUsersはまとめて実装する
service UsersService {
  rpc GetUser(GetUserRequest) returns (User) {}
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {}
}

// これは別のサーバーで実装されるかもしれない。
service BooksService {
  rpc GetUser(GetBookRequest) returns (Book) {}
  rpc ListUsers(ListBooksRequest) returns (ListBooksResponse) {}
}

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 のようにレベルが違う場合は同じ名前を使用することができます。特にトップレベルに列挙型を定義する場合にはスコープに気を付けて定義してください。

enum Sample1 {
  FOO = 0;
  BAR = 1;
}
enum Sample2 {
  BAR = 0;
  BAZ = 1;
}

message Test {
  emum Sample3 {
    BAR = 0;
  }
}

リソース指向

Ruby on Railsを知っている人には馴染み深いかもしれませんが、リソースに注目してAPIを分割することが推奨されています。

たとえば、Ruby on Railsでは BooksController#index, #show, #delete などのアクションを生やすことがあります。同様に、Google API Design Guideでも操作対象のリソースを service にして、その下に標準的な名前の rpc を置くことを推奨しています

service BooksService {
  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) {}
  rpc GetBook(GetBookRequest) returns (Book) {}
  rpc CreateBook(CreateBookRequest) returns (Book) {}
  rpc UpdateBook(UpdateBookRequest) returns (Book) {}
  rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) {}
}

GetBook, CreateBook, UpdateBookが全てBookを返していることに注目してください。この部分がGetBookResponse, CreateBookResponseのようになっていたらコードスメルです。

Input only, output only

リソース指向と関連する話ですが、Google API Design Guideでは同じリソース型を入出力で再利用することを推奨しています。

message UpdateBookRequest {
  // 入力としてのBook
  Book book = 1;
  google.protobuf.FieldMask update_mask = 2;
}

入力で必要なフィールドと出力で必要なフィールドは必ずしも一致しないので、別のメッセージに分けてしまいたくなる人もいるかもしれませんが、区別することで得られるメリットは複雑性の増加に見合わないとGoogleは判断したのでしょう。ここは大人しくGoogleの経験に従うのがいいでしょう。

かわりに、リソース型の中で入力専用フィールドや出力専用フィールドに説明を加えることが推奨されています。

message Book {
  uint32 id = 1;
  // Input only. 書籍の登録時にISBNを指定する。
  string isbn = 2;
  // Output only. 書誌情報から取得した題名。
  string title = 3;
  // Input only.
  // 書籍の表紙をユーザーがアップロードするときはS3のURLをここに入れる。
  string cover_image_url = 4;
}

標準フィールド名

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 ファイルにフィールドの説明・仕様・想定している使われ方などを明記しておくことで、円滑なコミュニケーションの助けになり、余計な確認や手戻りを大きく減らすことにつながります。

// コメントの例
// 内部リンク: https://github.com/wantedly/apis/blob/master/wantedly/profile_page/link_collection.proto

message LinkCollection {
  // Required. プロフィールページ上に表示する SNS アカウントの一覧。
  // 他人のプロフィールページのときは、原則連係済みアカウントのみが表示される。
  // 自分のプロフィールページのときは、まだ連携していない SNS アカウントのプレースホルダが出る場合がある。
  // この一覧に含まれない SNS アカウントに関しては編集ハーフモーダル上にのみ表示される。
  // 代表的な例としては Google などがこれに当たる。
  // 表示順には影響しない。
  repeated wantedly.users.v2.SocialProfile.Provider displayed_social_providers = 3 [packed=false];
}

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

gRPCをリクエストする側は、サーバー側から返ってくるエラーを必要に応じてハンドリングしてあげる必要があります。

エラーのハンドリング方法としては以下のようなものがあります。

  • リトライ

  • フォールバック

    • 別の手段を使う(別APIを使うなど)

    • 空の値や既定値を返す

  • エスカレーション(エラーを受け止めた上で別の形にして呼び出し元に失敗を伝える)

2回までリトライして、それでもダメならフォールバックして空の値を返す、のようにこれらの手段を組み合わせる場合もあります。

以下に、各エラーに対する Wantedly で推奨されるハンドリングポリシーを示します。 ここで未定義とは、社内でまだ議論が十分なされておらず統一的なポリシーが確定していないことを示します。 各エラーの内容については Handling Errors を参照してください。

エラーの種類
ハンドリングポリシー

INVALID_ARGUMENT

未定義

FAILED_PRECONDITION

未定義

OUT_OF_RANGE

未定義

UNAUTHENTICATED

未定義

PERMISSION_DENIED

未定義

NOT_FOUND

未定義

ABORTED

未定義

ALREADY_EXISTS

未定義

RESOURCE_EXHAUSTED

未定義

CANCELLED

未定義

DATA_LOSS

未定義

UNKNOWN

当該RPCの成功が、現在実行中の処理の継続に必要でない場合、実行中の処理を中断させないようにする。

INTERNAL

当該RPCの成功が、現在実行中の処理の継続に必要でない場合、実行中の処理を中断させないようにする。

NOT_IMPLEMENTED

未定義

UNAVAILABLE

rpc の内容によらず確率的に発生しうるため、基本的にハンドリングする。ハンドリングの方法はリトライ、フォールバック、エスカレーションのいずれか。

DEADLINE_EXCEEDED

rpc の内容によらず確率的に発生しうるため、基本的にハンドリングする。ハンドリングの方法はリトライ、フォールバック、エスカレーションのいずれか。

その他Wantedlyで利用しているツール群

  • Linter / Formatter

    • https://github.com/bufbuild/buf

    • https://github.com/yoheimuta/protolint

  • デバッグ

  • GraphQL Gatewayへの型変換

    • https://github.com/proto-graphql/proto-graphql-js

話を聞きに行きたい

もっと知りたい

  • https://github.com/wantedly/apis

最終更新