こんにちは、Wantedly Visit のバックエンドエンジニアをしている鴛海です。
本稿は、WANTEDLY TECH BOOK 8 から「実践: Ruby gRPC API サーバ」という章を抜粋し加筆修正を加えたものです。ウォンテッドリーでは WANTEDLY TECH BOOK のうち最新版を除いた電子版を無料で配布しています。ぜひ読んでみてください。
以下、本文です。
1: はじめに
本稿では、gRPC の Quick Start から一歩先に進むために何をすればいいかを、Ruby gRPC API サーバを運用した経験を元に紹介します。「2: gRPC の概要」で gRPC とは何か、どういう利点があるのかを紹介した後、「3: Ruby で gRPC API サーバを作る」で Ruby を使って gRPC サーバを作る際のベストプラクティスを紹介します。「4: 新しい API を作る」では gRPC サーバを立てた後、実際にどのように開発を進めていくか、またその中で知っておいた方がよい情報を紹介します。
2: gRPCの概要
gRPC とは、high-performance で様々な環境で動作することを謳った RPC フレームワークです。Protocol Buffers (protobuf) を IDL として使用して、定義した .proto
ファイルからサーバ・クライアントコードを生成することができます。コード生成は現在 Go、Ruby、Python など様々な言語でサポートされており、複数の言語のサーバが存在するマイクロサービス群でも利用することができます。また、gRPC は HTTP/2 を利用することによって双方向の通信も使用することができます。
2.1: RPC とは
RPC とは Remote Procedure Call の略で、ネットワーク越し(=遠隔)にメソッドやサブルーチンなどの手続きを呼び出す技術です。多くの RPC は、あらかじめインターフェイスを決めることによって遠隔にある手続きを呼び出すことを可能にしています。
2.2: REST vs RPC
REST と RPC では API の設計方針が異なります。REST はリソース指向のアーキテクチャスタイルですが、RPC はあえて言うならメソッド指向のアーキテクチャスタイルです。
チャットサービスのメッセージを送信する API を考えてみます。REST API では POST
https://foobar.com/message
のような URI を参照して message
というリソースを作成することでメッセージを送信します。REST は使える動詞を GET
、POST
、PUT
、DELETE
という HTTP メソッドだけに制限することで API の命名に一貫性を持たせようとする狙いがあります。そのため、URI には動詞を含めないのが良い URI だと言われています(参考: RESTful API Design: nouns are good, verbs are bad, https://cloud.google.com/blog/products/api-management/restful-api-design-nouns-are-good-verbs-are-bad)。しかし、HTTP メソッドだけでは表現力が乏しいため先ほどのメッセージ送信の例の POST https://foobar.com/message
という URI だと直感的にメッセージ送信をすることが分かりにくいという場合も出てきます。
対して RPC では SendMessage()
という(サーバにある)メソッドを呼ぶことでメッセージを送信する API を設計します。RPC はメソッドを呼んでいるだけなので命名もメソッドと同じような 動詞を重視した命名
になります。つまり、REST とは対照的に一貫性でなく表現力を上げる命名規則になっています
REST と RPC のどちらを使うのがよいかという議論をする際に重要になってくるのが命名による学習コストと表現力のトレードオフ です。REST は動詞が限定されているおかげで学習コストが少ないというメリットがありますが、表現力が乏しいためより的確な命名は難しくなります。一方で RPC はドメインによって動詞が変わるので学習コストが高くなってしまいますが、そのドメインの用語を使えることでドメインを知っている人にとってはより正確に理解できる名前となります。
学習コストと表現力のどちらを取るかという問題は状況によって変わってきます。クライアントが不特定多数になる公開された API を作る場合は REST の方が向いているでしょう。公開された API では、すべてのクライアントがドメインを熟知している訳ではありません。そのため学習コストが多くの人にのしかかってしまいます。
逆に組織内の API サーバのようなクライアントが限定される場合は、RPC の方が向
いていると言えるでしょう。組織内であれば既にドメイン知識を共有している場合が多いため学習コストはそこまで大きくならずに済みます。ただ、様々な動詞が使えてしまうと REST のような一貫性がなくなってしまうのは事実としてあるので、普段プログラミングでメソッドを作るときのように命名規則を決めてある程度学習コストを低減することも重要です。
3: Ruby で gRPC API サーバを作る
この章では、実際に Ruby で gRPC サーバを作る際のベストプラクティスを説明します。まだ Ruby で gRPC サーバを立てたことがない方は gRPC の Ruby Quick Start を行うことをおすすめします。この章ではこの Quick Start から一歩進んだ、実際に開発をした中で学んだ開発のしやすい構成を説明します。
3.1: ディレクトリ構成
まずはディレクトリ構成について説明します。基本的に Ruby on Rails をまねて作られています。
.
├── app
│ ├── messages
│ ├── models
│ ├── protos
│ ├── servers
│ └── services
├── grpc_server.rb
├── lib
│ └── interceptors
├── bin
├── config
├── db
└── protos
特徴的な部分をいくつか紹介します。app/servers
に protobuf から生成したサービスクラスを継承したサービスクラスを置いていてクラス名は *Server
としています。これは protobuf のサービスクラスがサービス層と被っているため、より明確に区別するために Server にしてあります。 protos
ディレクトリには .proto
ファイルが置かれていて、 app/protos
ディレクトリには protobuf によって生成されたコードが置かれています。app/messages
ディレクトリには、 .proto
ファイルの中で定義したメッセージの中でリソースであるものをラップし ActiveModel として扱うためのクラスが入っています。詳しくは次節で解説します。
3.2: メッセージオブジェクトをラップする
protobuf から生成されたメッセージオブジェクトは便利ですが、独自のメソッドを生やしたくなる場合があります。Ruby は Open Class なのでいじることは可能ですが、完全にラップした方がコードの追いやすさや見やすさの点でよいと考えて別のクラスとして作っています。例えば、 Book
というメッセージがあった場合以下のように定義します。
class Book
include ActiveModel::Model
include ActiveModel::Attributes
attribute :title, :string
...
def to_pb_message
Foo::BarPb::Book.new(
title: title,
...
)
end
end
#to_pb_message
というメソッドは protobuf の生成したメッセージオブジェクトに変換するメソッドです。特に Timestamp を扱う場合は #to_pb_message
内で Timpstamp から protobuf で扱うことが可能な google.protobuf.timestamp
型への変換を行うと便利です。
3.3: インターセプターで呼び出されたメソッドの前後に処理を挟む
インターセプターとは、RPC メソッドが呼び出される前後に処理を挟むことができる
仕組みです。 RpcServer
のインスタンスを作る際に GRPC::RpcServer.new(interceptors: [FooInterceptor, BarInterceptor])
のように指定して使用します。例えばエラーを捕捉し通知するインターセプターは以下のようになります。
class ErrorNotificationInterceptor < GRPC::ServerInterceptor
def request_response(request:, call:, method:)
begin
yield
rescue StandardError => e
notify_error(e)
raise e
end
end
end
#request_response
メソッドは引数にリクエストメッセージ( request )、呼び出しに関する情報( call )
、呼び出されたメソッドの情報( method )の 3つを受けとります。このメソッド内で yield
して呼び出し先の RPC メソッドを呼ぶことで、RPC メソッドを呼び出す前後に処理を挟むことができます。上の例のようなエラー通知の他にも、アクセスロギングやエラークラスの変換( ActiveRecord::RecordNotFound
から GRPC::NotFound
への変換等)などに使うことができます。
4: 新しい API を作る
新しい API (RPC メソッド) を作る作業は大きく分けて「インターフェイスの定義」と「RPC メソッドの実装」に分けることができます。この章では「インターフェイスの定義」と「RPC メソッドの実装」の2つを詳しく説明していきます。
4.1: インターフェイスの定義
「2: gRPCの概要」で説明したように、gRPC サーバでは Protocol Buffers を使用してインターフェイスを定義します。この節ではインターフェイスを定義する方法とベストプラクティスについて解説していきます。
4.1.1: Protocol Buffers によるインターフェイスの定義
Protocol Buffers では以下のような構文でインターフェイスを定義することができます。
service BookShelf {
rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}
message ListBooksRequest {
enum Category {
CATEGORY_UNSPECIFIED = 0;
SCIENCE_FICTION = 1;
COMEDY = 2;
}
Category category = 1;
}
message ListBooksResponse {
repeated Book books = 1;
}
この BookShelf
というサービスには、 ListBooksRequest
というメッセー
ジを受け取って ListBooksResponse
というメッセージを返す ListBooks
というメソッドが存在することが分かります。さらに詳しい Protocol Buffers の記法やどんな型を使えるのかは Protocol Buffers 公式のリファレンスが役に立ちます。このようにインターフェイスを見れば何を渡せばいいか、何が返ってくるのかが明確になりクライアントにとっては非常に助かります。また、インターフェイスが定義されていることによってサーバの実装がされていなくともクライアントを実装することができる、いわゆる スキーマ駆動開発 が可能になります。
4.1.2: gRPC API サーバのためのインターフェイス設計ベストプラクティス
- 命名規則
gRPC はその名の通り RPC フレームワークなので、「2.2: REST vs RPC」で説明した「表現力の高い動詞」が重要になると共に、学習コストを減らすための命名規則が必要です。Google の API 設計ガイドにある「命名規則」のページは非常に役に立ちます。このページに書かれていることからいくつか自分の経験上役に立ったものを紹介します。 - メソッド名
「2.2: REST vs RPC」で説明したように RPC API における動詞の命名は最も重要です。メソッド名はVerbNoun
すなわち「動詞 + 名詞」の形で表現することが推奨されています。ただし、VerbNoun
でもIsBookPublisherApproved
のような直説法ではなく、CheckBookPublisherApproved
- リクエストメッセージとレスポンスメッセージ
RPC メソッドのリクエストメッセージとレスポンスメッセージはそれぞれRequest
、Response
Delete*
メソッドのレスポンスで使用するgoogle.protobuf.Empty
のような空のメッセージやGet*
メソッドでのレスポンスで使用するリソースタイプは例外となります。
- 列挙型(Enum)
列挙型の名前はUpperCamelCase
にし、列挙値はCAPITALIZED_NAMES_WITH_UNDERSCORES
を使用することが推奨されています。また、列挙型の最初の値はデフォルト値となるためENUM_TYPE_UNSPECIFIED
という名前にすることが推奨されます。
- メソッド名
enum FooBar {
// The first value represents the default and must be == 0.
FOO_BAR_UNSPECIFIED = 0;
FIRST_VALUE = 1;
SECOND_VALUE = 2;
}
列挙型は C++ と同じようなスコープになっているため、列挙値は列挙型が定義されているレベルでのスコープとなります。例えば次の定義では、Sample1 と Sample2 は同レベルに存在するため同じ BAR
という名前を使用することができません。Sample1 と Test.Sample3 のようにレベルが違う場合は同じ名前を使用することができます。特にトップレベルに列挙型を定義する場合にはスコープに気を付けて定義してください。
enum Sample1 {
FOO = 0;
BAR = 1;
}
enum Sample2 {
BAR = 0; // Error: "BAR" is already defined.
BAZ = 1;
}
message Test {
emum Sample3 {
BAR = 0; // OK
}
}
- Service の分割
gRPC の Service は複数ハンドルさせることできます。
gRPC に限った話ではありませんが、Service が大きくなると保守が大変になります。
Service が肥大化してきた場合はサブドメインに切るなど分割し、複数のサービス
をハンドルすることをおすすめします。
4.2: RPC メソッドの実装
RPC メソッドはリクエストメッセージを受け取ってレスポンスメッセージを返すただのメソッドです。
以下のように protobuf が生成したサービスクラスを継承したクラスにメソッドを定義します。
class ShelfService < Foo::Bar::Shelf::Service
def list_books(req, call)
# Something to do
Foo::Bar::ListBooksResponse.new
end
end
ここで引数に渡ってくる req
はリクエストメッセージで、 call
は呼び出しに関する情報を持つ GPRC::ActiveCall
のオブジェクトです (正確には GRPC::ActiveCall::SingleReqView
または GRPC::ActiveCall::MultieReqView
のどちらか)。call.matadata
とするとメタデータが取得することができます。
4.2.1: エラーハンドリング
gRPC におけるエラーハンドリングは単純で、 ただ raise GRPC::XXX
とするだけです。使えるエラーステータスは grpc/statuscodes.md
を参照してください。このとき気をつける必要があるのは gRPC のエラーステータスは HTTP のステータスコードと一対一対応していないことです。例えば、 FAILED_PRECONDITION
と INVALID_ARGUMENT
と OUT_OF_RANGE
は HTTP で言えばどれも 400 Bad Request
に当てはまります。FAILED_PRECONDITION
は「削除するディレクトリが空ではなかった」などシステムが操作を行うのに必要な状態でないときに使用し、INVALID_ARGUMENT
は「不正なファイル名」などシステムの状態に関わらない無効な引数の場合に使用します。OUT_OF_RANGE
は「生年月日として現在よりも先の日時が指定された」など値の有効範囲を越えた場合に使用します。「INT64 の範囲を越えた」場合には INVALID_ARGUMENT
を使用します。
4.2.2: RPC メソッドをテストする
RPC メソッドのテストはただメソッドテストをするだけです。実際にサーバを立てローカルでリクエストを送って確かめたい場合は evans
が便利です。 .proto
ファイルを元に REPL やCLI 形式でリクエストを投げることができます。
5: おわりに
gRPC は本稿で解説した部分以外にも高速な通信や双方向通信が可能になるなどの強みがあります。しかしいざ Ruby での gRPC サーバを運用しようと思っても、まだまだ普及しておらず、情報が少ない状況です。本稿が Quick Start から一歩前に進むためのものとなれば幸いです。