実践: gRPC in Ruby

本章では、gRPC in Ruby の Quick Start から一歩先、「Wantedly で Ruby を使って gRPC Server/Client の開発をどう行なっているのか」について紹介します。

Ruby で gRPC Server を作る

Rails としてのディレクトリ構成

まずはディレクトリ構成について説明します。基本的に Ruby on Rails の上で作ることを想定しています。

.
├── app
│   ├── grpc_services
│   ├── models
│   └── pb_serializers
├── bin
├── config
└── db

特徴的な部分を紹介します。

  • app/grpc_services

    • protobuf から生成したサービスクラスを継承したサービスクラスを置きます

    • ファイル名およびクラス名にはそれぞれ _grpc_service, GrpcService という suffix を付与します

  • app/pb_serializers

    • Protobuf Message object を生成する pb-serializer の実装クラスを配置します

    • ファイル名およびクラス名にはそれぞれ _pb_serializer, PbSerializer という suffix を付与します

Rails としてのアーキテクチャ

このセクションは主に Rails 経験者のための解説です。先ほどのディレクトリ構成の背後にあるアーキテクチャと、デフォルトの Rails との差分について軽く触れておきます。図を見てもらいつつ、二段階に分けて説明します。

まず、デフォルトの Rails の MVCアーキテクチャ(①)に対して、JSON / HTTP を話す API サーバーにするために ActionView を取り去って ActiveModelSerializers などのシリアライズ機構を導入します。

次に、②のアーキテクチャに対して、Protobuf / gRPC を話せるように各責務に対応するコンポーネントを差し替えたのが Wantedly における gRPC サーバーの推奨のアーキテクチャ(③)です。

一見大きく変わったように見えるかもしれませんが、モデルやシリアラズなどの概念は同じなので案外馴染みやすいのではないかと思います(付け加えると、ここで書いていないコンポーネント、たとえば ActiveJob や ActionMailer などについては特に変わることはありません)。どちらかというと実際の開発体験としては、要素技術の変更よりは API のスキーマ情報を先に更新して gem を取り込むというワークフローが入ることが大きな変化になると思います。

ということで、gRPC のインタフェース定義から具体的に説明していきます。

wantedly/apis - gRPC のインタフェース定義

apis は Wantedly で .proto file を集約するための repository です。apis へ PR を作るだけで、CI によって「各言語向けのコード生成」が自動で行われるようになっており、「protoc のセットアップ」などの煩雑な作業を個々の開発者がしなくても良い仕組みになっています。

Ruby であれば wantedly/apis-ruby というリポジトリに生成コードがコミットされ、private gem として配信されるようになっています。 各マイクロサービスリポジトリでは、Gemfile に apis を記述した後に bundle install を行って利用するというフローになっています。 以前は apis 内のコードは明示的に require を書かなければ読み込まれなかったのですが、バージョン 2021.12.22.1128 以降から自動的に読み込まれるようになったので、現在では個々の開発者が require を書く必要はありません。

インタフェース定義におけるルール・tips などはprotobufスキーマとgRPC通信を参照してください。

RPC の実装

さて、apis repository によって「gRPC Service Class」と「Protocol Buffers Message Class」のコードを生成して配信することができました。gRPC Server が意味のある response を返すには、これらを利用して ロジックを実装する必要があります。

一例として、ProfileServiceBatchGetProfiles という RPC の処理を見てみましょう。 以下のように、apis で生成された gRPC Service Class である W::UsersPb::ProfileService::Service を継承した ProfileGrpcService Class で、 batch_get_profiles method を実装します。 この例は簡略化して書いてますが、実際にはメソッド内でDB への問い合わせるなどして意味のあるデータを保持したオブジェクトを作成、 最終的に W::UsersPb::BatchGetProfilesResponse オブジェクトとして返します。

# Protobuf で `wantedly.foo.bar` というパッケージがあったとき、
# Ruby では `W::Foo::BarPb` というモジュールとする規約になっている
class ProfileGrpcService < W::UsersPb::ProfileService::Service
  # @param [W::UsersPb::BatchGetProfilesRequest] req
  # @param [GRPC::ActiveCall::SingleReqView] call
  # @return [W::UsersPb::BatchGetProfilesResponse]
  def batch_get_profiles(req, call)
    # ...

    W::UsersPb::BatchGetProfileResponse.new(profiles: profiles)
  end
end

ここで引数に渡ってくる req はリクエストメッセージで、 call は呼び出しに関する情報を持つ GPRC::ActiveCallのオブジェクトです (正確には GRPC::ActiveCall::SingleReqView または GRPC::ActiveCall::MultiReqViewのどちらか)。call.metadata とするとメタデータが取得することができます。

the_pb と pb-serializer によるメッセージの組み立て

Ruby では通常は以下のようにして Protobuf object を作ります。

message = W::UsersPb::Profile.new(
  user_id: profile.user_id,        # uint64
  name: profile.name,              # string
  avatar_url: profile.avatar_url,  # string
)
message.to_proto  # => binary

上記は最も単純な例です。protobufスキーマとgRPC通信で紹介されているラッパー型など、 いわゆる Well-Known Types が入ってくると、もっと面倒なコードを書くことになります(面倒なのでここには書きません)。

ここで活躍するのが the_pbpb-serializer という2つの gem です。

the_pb は Ruby オブジェクトを前述した Well-Known Types に変換するユーティリティを提供する gem です。 pb-serializerActiveModelSerializers のような DSL で Protobuf object の Serializer を実装できる gem です。内部では the_pb に依存しています。

先程例に出した Profile object の serializer であれば以下のように実装できます。

# @attr_reader [::Profile] object
class ProfilePbSerializer < Pb::Serializer::Base
  message W::UsersPb::Profile

  attribute :user_id
  attribute :name
  # avatar_url が google.protobuf.StringValue だったとしても、よしなに変換してくれる
  attribute :avatar_url, allow_nil: true
end

ProfilePbSerializer.new(profile).to_pb
#  => <W::UsersPb::Profile ...>

the_pb と pb-serializer をうまく活用することで、gRPC server 実装時の手間を大きく削減することが出来ます。 pb-serializer の詳しい使い方についてはリポジトリ の README や examples を参照してください。

エラーハンドリング

Ruby における gRPC server の実装では、GRPC::BadStatus というエラークラスを raise することでクライアントにエラーを返すことになります。 また、エラーコードごとに GRPC::BadStatus のサブクラスが用意されており、それを利用することも出来ます。 以下2行は同じ意味になります。

raise GRPC::BadStatus, Google::Rpc::Code::NOT_FOUND, "user not found"
raise GRPC::NotFound, "user not found"

この「エラーコード」は google.rpc.Code に定義されている Protobuf の Enum になります。 エラーコードの使い分けなどについてはprotobufスキーマとgRPC通信を参照してください。

複雑なエラー表現

さて、GRPC::BadStatusGoogle::Rpc::Code を利用することでメッセージとエラーコードからなる単純なエラーを表現することは出来ました。 しかし、これでは実際のアプリケーションで表現力が足りません。 例えば単純なフォームのバリデーションエラーでも、フィールドごとにエラーメッセージを保持できる必要があります。

gRPC におけるエラーは実際には google.rpc.Status に定義されるような Protobuf message で表されます。

message Status {
  // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
  int32 code = 1;

  // A developer-facing error message, which should be in English. Any
  // user-facing error message should be localized and sent in the
  // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
  string message = 2;

  // A list of messages that carry the error details.  There is a common set of
  // message types for APIs to use.
  repeated google.protobuf.Any details = 3;
}

この google.rpc.Status のうち、 Any の配列である details にエラーの詳細を詰めることが可能です。 Google API Design Guide の Errors では google/rpc/error_details.proto に定義されるメッセージの利用が推奨されており、Wantedly でもそれに則っています。

Ruby でのエラー詳細の扱いに関しては、the_pb gem で .new_rpc_error という関数が提供されており、それを使うと便利です。

raise Pb.new_rpc_error(
  "post is invalid",
  code: Google::Rpc::Code::INVALID_ARGUMENT,
  details: [
    Google::Rpc::BadRequest.new(
      field_violations: [{ field: 'title', description: 'タイトルは必須です' }],
    )
  ],
)

テスト

gRPC の RPC の実装は見ての通り普通のメソッドなので、最も単純には普通にメソッド呼び出しをして、返り値に対してテストをすることが出来ます。

require 'rails_helper'

RSpec.describe ProfileGrpcService do
  let(:grpc_service) { described_class.new }
  let(:call) { instance_double("GRPC::ActiveCall::SingleReqView", metadata: {}) }

  describe '#batch_get_profiles' do
    let(:request) { W::UsersPb::BatchGetProfilesRequest.new(user_ids: user_ids) }
    let(:response) { grpc_service.batch_get_profiles(request, call) }
    let(:user_ids) do
      # ...
    end

    it 'returns user profiles' do
      # ...
    end
  end
end

ただ、これだと Protobuf の marshal / unmarshal 含め gRPC の内部実装を全く介さないため、 もう少し丁寧にやるなら GrpcTesting (private library) の利用を検討するといいでしょう。

require 'rails_helper'

RSpec.describe ProfileGrpcService do
  include GrpcTesting
  let(:stub) { test_stub_class(described_class.superclass.module_parent).new(described_class.new) }

  describe '#batch_get_profiles' do
    let(:response) { stub.batch_get_profiles(request, call) }

    # ...
  end
end

起動・デバッグ

次に、gRPC Server Process を起動します。gRPC Server を起動して Request を処理させるには、app/grpc_services に定義した「gRPC Service Class を継承した Class」を Handler として登録する必要があります。また、Wantedly では、マイクロサービスは Kubernetes 上で Docker Container として動かしているので、Heath Check の為に gRPC Health Checking Protocol を実装した Handler の登録も必要です。その他、Observability を高めるための gRPC interceptor(gRPC の拡張機能。後述するが、Rack Middleware 相当のもの)の設定なども行う必要があります。

こういった「どの repository でも共通で必要になる処理」については、個々の開発者が気にする必要がないよう、servicex (internal) と呼ばれる社内 library (gem) の機能として提供するようにしています。具体的には、grpc-server という名前のコマンドを用意しており、それを実行するだけで「gRPC Service Class の load および Handler としての登録、その他に共通で必要になる各種設定」が行われるようにしています。

grpc-server コマンドは以下のように利用することができます。

$ bundle exec grpc-server
handling /grpc.health.v1.Health/Check with #<Method: Grpc::Health::Checker#check>
handling /grpc.health.v1.Health/Watch with #<Method: Grpc::Health::Checker(Grpc::Health::V1::Health::Service)#watch>
handling /wantedly.users.UserService/ListUsers with #<Method: UsersGrpcService#list_users>
.
.
.
gRPC server starting...
* Listening on tcp://0.0.0.0:6046
* Environment: development
Use Ctrl-C to stop

この grpc-server コマンドのうち、Health Checker やシグナルハンドリングなどの設定を隠蔽したものは grpc_server gem として公開されています。

gRPC server にリクエストを送ってデバッグする

JSON/HTTP な API であれば cURL コマンドや Insomnia, Postman といったツールで実際にリクエストを送ってデバッグすることがあるかもしれません。 gRPC server では標準でコマンドラインツールが提供されている ほか、grpcurlevans といった 3rd party のツールが存在します。 一方でこれらのツールでは「元になった .proto ファイル」もしくは「Server Reflection に対応した gRPC server」が必要になり、Wantedly の環境では少し使いづらいという問題がありました。

そこで Wantedly では giro というツールを利用し、なるべく少ない手順・依存で gRPC server にリクエストを送れるような環境になっています。詳しくは giro の解説をしているブログ もしくは giro の使い方ドキュメント (internal) を参照してください。

Ruby で gRPC Client を使う

gRPC Client を開発する際にも、gRPC Server 開発と同様に .proto file を集約する apis repository や servicex と呼ばれる社内 library (gem) を活用するようにしています。

gRPC Client としては、apis repository で自動生成した gRPC Service Class のコードを load したうえで、その Stub Class を利用します。gRPC Client を単純に利用するだけであれば、以下のコードで動きます。

client = W::UsersPb::ProfileService::Stub.new(url, :this_channel_is_insecure)

ただし、Production の Microservices で利用する上では「必ずセットして欲しい gRPC interceptor(gRPC の拡張機能。後述するが、Faraday Middleware 相当のもの)」などが存在するので、個々の開発者が意識しなくてもそれらの設定が自動で行われる様に、servicex gem の中で .stub_for という「gRPC Client 生成用のメソッド」を用意しています。

client = Servicex::Grpc.stub_for(W::UsersPb::ProfileService, grpc_url)

.stub_for の実装はだいたい以下のようになっています。 元々、HTTP/1.1 の通信では servicex gem の中で Faraday Middleware などの設定を行なった API Client を提供する様にしていました。 .stub_for は同様の体験を gRPC でも提供することが意図されています。 Observability を確保するための interceptor のセットや User Agent の設定なども .stub_for で自動で行われるようになっています。

# @example
#    Servicex::Grpc.stub_for(W::UsersPb::ProfileService, UsersApi.base_url)
# @param service_class [Class<Grpc::GeneralService>]
# @param url [String]
# @yield [opts] To modify options for instantiating a Stub
# @yieldparam opts [Hash] Default options
def stub_for(service_class, url)
  opts = {
    channel_args: {
      'grpc.primary_user_agent' => Servicex.user_agent,
    },
    interceptors: Servicex::Grpc.client_interceptors,
  }
  yield opts if block_given?
  service_class::Stub.new(url, :this_channel_is_insecure, opts)
end

gRPC Client については、基本的にやる事はこれだけです。 「apis と servicex を gem として load すれば、どのマイクロサービスからでも簡単に必要な設定が行われた状態で gRPC での通信が出来る」という環境を作っています。

最終的にはだいたい以下のようなコードを書くことになります(次節で説明するエラーハンドリングをしない場合のパターンです)。

client = Servicex::Grpc.stub_for(
  W::Users::ProfileService,
  UsersGrpcApi.base_url,  # servicex が提供する、ほかのマイクロサービスの URL を取得する関数
  current_user_id: current_user.id,
)
req = W::Users::BatchGetProfileRequest.new(user_ids: user_ids)

data = client.batch_get_profiles(req)
data  # => <UsersPb::BatchGetProfilesResponse: profiles: [...]>

gRPC Client のエラーハンドリング

gRPC における通信の失敗は gRPC のエラーコードとして表されます。 (Handbook - gRPC 通信のエラーハンドリング にその種類と、デフォルトで考慮すべきものがどれかの指針が書かれているので、確認しておいてください。)

マイクロサービス・アーキテクチャでの開発を行う際には、障害の分離のために、通信の失敗について考慮する必要があります。 通信が失敗した際、全体を失敗させるのではなくなんらか部分的に処理が継続可能な場合は、メソッド呼び出しで errors に想定しているエラーの種類を列挙してください。

これは、

client = Servicex::Grpc.stub_for(
  W::Users::ProfileService,
  UsersGrpcApi.base_url,
  current_user_id: current_user.id,
)
req = W::Users::BatchGetProfileRequest.new(user_ids: user_ids)

res = client.batch_get_profiles(req, errors: [:deadline_exceeded])

これは、GRPC のエラーの種類に対応するシンボルとして指定できます。例えば、GRPC のエラーである DEADLINE_EXCEEDED に対応するシンボルは :deadline_exceeded です。 また、サーバー側に原因があると考えられる UNKNOWN, INTERNAL, UNAVAILABLE を :server_errors でまとめて指定できるようにしています。

多くのケースでは、errors: [:server_error, :deadline_exceeded] を書くことをまず考えるのが良いプラクティスでしょう。コードレビューの際にも、そのことをチェックしてください。

errors を指定すると成功と失敗を表現するオブジェクトが返ってくるので、パターンマッチで処理しましょう。

res = client.batch_get_profiles(req, errors: [:server_errors, :deadline_exceeded])
case resp
in Result::Success(data)
  data # => <UsersPb::BatchGetProfilesResponse: profiles: [...]>
in Result::Failure(error)
  # Failure の場合は自動で Honeybadger に送られます。
  error # => [<W::Users::ProfileServiceError: ...>]
end

エラーの種類によって個別の処理をしたい場合は、こういう風にパターンマッチを書くと良いでしょう。

res = client.update_profile(req, errors: [:server_errors, :deadline_exceeded, :permission_denied])
case resp
in Result::Success(data)
  data
in Result::Failure(type: :permission_denied, error: _error)
  Pb::Visit::ToastError.new(message: "このデータを更新する権限がありません")
in Result::Failure(_error)
  Pb::Visit::ToastError.new(message: "更新が行なえませんでした。しばらくしてからもう一度やり直してください。")
end

gRPC Client のモック

多くの場合、テストコード中ではマイクロサービス間通信を含む外部へのリクエストを禁止したくなります。 HTTP であれば webmock gem を使うことが多いですが、gRPC では grpc_mock という gem を利用しています。

before do
  GrpcMock.stub_request('/wantedly.users.ProfileService/BatchGetProfiles').to_return do |req, call|
    W::UsersPb::BatchGetProfilesResponse.new(
      profiles: [],
    )
  end
end

ライブラリ・ツールの紹介

Ruby で gRPC を利用するときに利用する内製の gem やツールの一覧です。 pb-serializer や giro のように直接的に利用するものもあれば、servicex 内で利用されているものもあります。

話を聞きに行きたい

もっと知りたい

最終更新