gRPC とは Google が開発したオープンソースの RPC フレームワークで、一般に同じく Google の開発したシリアライズ形式の Protocol Buffers とセットで用いられています。この gRPC と Protocol Buffers の嬉しい点は色々あって、様々な言語に対応しているのもその一つです。
Wantedly 社内ではもっぱら Ruby で書かれたサービスをマイクロサービス化していくために使っているのですが、そうなると困った所も出てきます。普通 gRPC を使って API を定義する際、リクエストやレスポンスがどのような形式をしているか—すなわち型—を Protocol Buffers の形式で明示的に書かなければならないのですが、Ruby は静的に型チェックを行ってくれないので、せっかく書いた型の情報をデバッグに役立てられないのです。お陰で、型の違うレスポンスを返す gRPC サーバーが書けてしまうことに起因する障害まで起きてしまいました。
本記事では、Ruby で gRPC メソッドを呼び出す際に動的な型チェックを行う手法を解説します。静的な型チェッカを導入するのに比べると素朴な解決法ではありますが、分かりやすいエラーメッセージを出力すれば十分に開発の助けになることでしょう。本記事で解説する gRPC メソッドの動的な型チェッカは下記の Gem としても公開しています。興味があればご利用ください。
実行時に型情報を得る
さて、gRPC を用いて API を定義する際には Protocol Buffers の形式で型を明示するとはいえ、どうやってその情報を Ruby のプログラムから利用したものでしょうか?.proto ファイルのパーサーを Ruby で再実装するのは不毛ですし、骨が折れそうです。
幸いなことに、Protocol Buffers が .proto ファイルをパースして生成する Ruby コードの中には、どのようにしてシリアライズ及びデシリアライズを行うかの情報のみならず、リクエストとレスポンスがどのクラスのインスタンスであるかの情報まで含まれています。この情報を用いれば、動的な型チェッカが簡単に実装できそうに見えますね。
例えば gRPC のデモ にある、 greeter_server の SayHello というAPIの、
リクエストとレスポンスがどのようなクラスのインスタンスであるかを見てみましょう。
[1] pry(main)> Helloworld::Greeter::Service.rpc_descs
=> {:SayHello=>
#<struct GRPC::RpcDesc name=:SayHello,
input=Helloworld::HelloRequest,
output=Helloworld::HelloReply,
marshal_method=:encode,
unmarshal_method=:decode>}
この SayHello
という API は文字列 name
をリクエストとして受け取って、文字列 "Hello #{name}"
をレスポンスとして返すものです。Protobuf によって自動生成された、gRPC サーバーのインターフェースを定義するクラスが Helloworld::Greeter::Service
なのですが、 rpc_descs なるクラスメソッドには
シリアライズやシリアライズに用いるメソッドの情報のみならず、リクエストは Helloworld::HelloRequest
のインスタンスであることや、レスポンスが Helloworld::HelloReply
のインスタンスであることの情報が含まれていますね。
従って、gRPC サーバーを実装する際に rpc_descs
を参照してやれば、 gRPC サーバーが誤った型のレスポンスを返していないことを実行時に確かめられますし
# 公式のデモにある gRPC サーバーの実装
class GreeterServer < Helloworld::Greeter::Service
# 同じく gRPC メソッドの実装
def say_hello(hello_req, _unused_call)
# 元々の API の処理
response = Helloworld::HelloReply.new(message: "Hello #{hello_req.name}")
# 型の合ったレスポンスを返していない場合
unless response.is_a?(self.class.rpc_descs[:SayHello][:output])
# 分かりやすいエラーメッセージを添えて、 gRPC のステータスコードで内部エラーを告げる
raise GRPC::Internal, "the response of say_hello is expected to be an instance of #{rpc_method[:output]}, but the response is an instance of #{response.class}"
end
response
end
end
gRPC メソッドを呼び出す際に同じようなコードを書けば、型の合わないリクエストを渡していないことを動的に確かめられますね。
# 公式のデモにある gRPC メソッドのリクエスト
request = Helloworld::HelloRequest.new(name: user)
# .proto ファイルに書いたリクエストの型
klass = Helloworld::Greeter::Service.rpc_descs[:SayHello][:input]
# 型の合ったリクエストを渡そうとしていない場合
unless request.is_a?(klass)
# わかりやすいエラーメッセージを添えて例外を投げる
raise GRPC::InvalidArgument, "the request of say_hello expected to be an instance of #{klass}, but the request is an instance of #{request.class}"
end
# 元々のデモにあった greeter_client.rb の処理
message = stub.say_hello(request).message
p "Greeting: #{message}"
gRPC サーバーに誤った型のリクエストが与えられた場合はそもそもデシリアライズに失敗するので型チェックの余地がないのですが、gRPC クライアントが誤った型のレスポンスを受け取っていないかのテストは書いても良かったかもしれないですね。
しかし、我々は怠惰なのでいちいちこんな長々としたバリデーションを書きたくないですし、毎回書こうものならかえってバグを混入させそうです。何も書かずとも型検査が行われるようにする術は無いものでしょうか?
Interceptor
型検査を自動的に行うようにする際、役に立ちそうなのが interceptor と呼ばれる機能です。これは gRPC メソッド呼び出しの前後に適当な処理を挟むことのできる機能で、クライアント側で処理を追加する client interceptor と、サーバー側で行う server interceptor の2種類が存在します。
例えば簡単な例として、以下のようなリクエストとレスポンスを標準出力に垂れ流すだけの client interceptor を考えてみましょう。
# リクエストとレスポンスを表示するだけの client interceptor
class ClientPrintInterceptor < GRPC::ClientInterceptor
def request_response(request: nil, call: nil, method: nil, metadata: nil)
p request
# 引数として与えられたブロックを呼び出すと、gRPC メソッド呼び出しが行われ、レスポンスが返る
response = yield
p response
response
end
end
gRPC メソッド呼び出しに先立って Stub
と呼ばれるクライアントを作る必要があるのですが、その際に client interceptor を登録してやれば、メソッド呼び出しの前後で interceptor の処理が走るようになります。
stub = Helloworld::Greeter::Stub.new(
hostname,
:this_channel_is_insecure,
interceptors: [ClientPrintInterceptor.new]
)
message = stub.say_hello(Helloworld::HelloRequest.new(name: user)).message
p "Greeting: #{message}"
# <Helloworld::HelloRequest: name: "world">
# <Helloworld::HelloReply: message: "Hello world">
# "Greeting: Hello world"
gRPC にはそんな便利な機能があるのなら、リクエストの型チェックを行うような client interceptor を定義してやれば、Stub
を作る際に登録してやるだけで勝手に型検査が行われるようになりそうですね。実際に実装してみると以下のようになります。
class ClientTypecheckInterceptor < GRPC::ClientInterceptor
# Helloworld::Greeter::Service のようなモジュールを引数に取るコンストラクタ
def initialize(service_class: nil)
@service = service_class
end
def request_response(request: nil, call: nil, method: nil, metadata: nil)
# 誤った型のリクエストが与えられた場合
unless request.is_a?(request_class(method))
# gRPC メソッド呼び出しを行わず、直ちに例外を投げる
raise GRPC::InvalidArgument, "the request of #{method} expected to be an instance of #{request_class(method)}, but the request is an instance of #{request.class}"
end
# 型が合っていればそのままメソッド呼び出しを行う
yield
end
private
# request_response の引数 method には、"/helloworld.Greeter/SayHello" のような形式で
# サービス名とメソッド名が含まれるので、これと rpc_method を用いてリクエストの型を得るメソッド
def request_class(method)
rpc_method = method.split('/')[2].to_sym
@service.rpc_descs[rpc_method][:input]
end
end
直感的には server interceptor を利用すればレスポンスの型チェックをサーバー側で行えそうなものですが、現状の gRPC の Ruby 実装では server interceptor でレスポンスを取り扱うことができないので上手くいきません。例えば、リクエストとレスポンスを標準出力に書き出す server interceptor を定義してみましょう。
# リクエストとレスポンスを表示するだけの server interceptor
class ServerPrintInterceptor < GRPC::ServerInterceptor
def request_response(request: nil, call: nil, method: nil, metadata: nil)
p request
# 引数として与えられたブロックを呼び出すと gRPC メソッド呼び出しが行われるのは同じだが、
# 現状の gRPC の実装では nil しか返してくれない
response = yield
p response
response
end
end
gRPC サーバーを作る際に server interceptor を登録してやれば、メソッド呼び出しの前後で interceptor の処理が実行されるようになります。
# server interceptor を登録して、gRPC サーバーを作る
s = GRPC::RpcServer.new(interceptors: [ServerPrintInterceptor.new])
# gRPC サーバーの設定
s.add_http2_port('0.0.0.0:50051', :this_port_is_insecure)
s.handle(GreeterServer)
# gRPC サーバーを起動する
s.run_till_terminated_or_interrupted([1, 'int', 'SIGQUIT'])
# 以下、greeter_client を動かした際の出力
# <Helloworld::HelloRequest: name: "world">
# nil
# "Greeting: Hello world"
もっとも、server interceptor ではレスポンスを受け取ることはできないのですが。
Interceptor を用いてリクエストの型チェックを自動化することはできましたが、同様にしてレスポンスの型チェックを行うことは困難であることが分かりました。何か別の手立てによって、型チェックを自動的に行うことはできないでしょうか?
メタプログラミングによる解決
Ruby は極めて動的な言語で、実行時にプログラムを書き換えるプログラムをいともたやすく記述することができます。諸々のメタプログラミングの技法を用いて、gRPC メソッドの実装に型チェックの処理を自動的に付け加えるプログラムを書いてしまえば、interceptor に頼らずともレスポンスの型チェックを自動化できることでしょう。
まず、既存のメソッドに機能を付け加える際に良く用いられる機能として、Module#prepend
があります。これは引数で与えられたモジュールを self
の継承ツリーの先頭に追加するメソッドなので、引数に与えるモジュールの方に、書き換えたいメソッドと同名のメソッドを定義すればオーバーライドすることができます。
従って、gRPC サーバーを実装しているクラスに適当なモジュールを prepend
し、そのモジュールの方にレスポンスの型チェックを行うメソッドを定義してオーバーライドすると良さそうです。greeter_server の例に適用するならこんな感じでしょうか?
# gRPC サーバーに prepend するモジュール
module PrependedModule
def say_hello(*args)
response = super
unless response.is_a?(self.class.rpc_descs[:SayHello][:output])
raise GRPC::Internal, "the response of say_hello is expected to be an instance of #{rpc_method[:output]}, but the response is an instance of #{response.class}"
end
response
end
end
class GreeterServer < Helloworld::Greeter::Service
prepend PrependedModule # ここで prepend
def say_hello(hello_req, _unused_call)
Helloworld::HelloReply.new(message: "Hello #{hello_req.name}")
end
end
もっとも、ただ prepend
で型チェックの処理を分離しただけでは、同じような処理を gRPC メソッド呼び出しの数と同じだけ書く必要があって嬉しくありません。どうせならこれを自動生成したいですよね。
OCaml に親しんだ身からすると驚くべきことに、 Ruby では実行時にメソッドを定義することができます。加えて、メソッドの定義を Module#method_added
でフックすることまでできてしまいますから、以下のように gRPC メソッドの実装を検知して、自動的に型チェックの処理を実装することもできます。
# StringString#camelize のために ActiveSupport を使う
require "active_support/core_ext/string/inflections"
module PrependedModule
# この中身は method_added が勝手に生成してくれるので、ここでは何も書かなくて良い
end
class GreeterServer < Helloworld::Greeter::Service
prepend PrependedModule
# method_added をオーバーライドすればメソッドの定義をフックできる
def self.method_added(method)
rpc_method = rpc_descs[method.to_s.camelize.to_sym]
# gRPC メソッドの定義でなければ何もしない
return unless rpc_method
# prepend したモジュールの方に、型チェックを行うような同名のメソッドを自動生成してオーバーライドする
PrependedModule.class_eval do
define_method method do |*args, **kwargs, &block|
response = super(*args, **kwargs, &block)
unless response.is_a?(rpc_method[:output])
raise GRPC::Internal, "the response of #{method} is expected to be an instance of #{rpc_method[:output]}, but the response is an instance of #{response.class}"
end
response
end
end
end
def say_hello(hello_req, _unused_call)
Helloworld::HelloReply.new(message: "Hello #{hello_req.name}")
end
end
しかし、いくら gRPC メソッドごとではなく gRPC サーバーごとに書けば良いとはいえ、こんな邪悪なコードを何度も書きたくはないですよね。なので、prepend
や method_added
の実装をモジュールにまとめてしまって、それを include
するようにしましょう。こんな風に。
# gRPC サーバーの実装に include すると、型チェックを行うようにするモジュール
module GrpcServerTypechecker
class << self
# モジュールが include された時に呼ばれる関数
def included(base)
# prepend するモジュールを新しく生成
mod = Module.new
base.class_eval do
prepend mod
# prepend したモジュールをインスタンス変数に覚えておく
@_prepended_module_for_type_check_ = mod
# method_added を追加するための処理
# クラスメソッドの追加はこういう手間をかけないといけない
extend GrpcServerTypechecker::ClassMethods
end
end
end
module ClassMethods
def method_added(method)
# 既に method_added が定義されていたら super を呼ぶ
super if defined? super
rpc_method = rpc_descs[method.to_s.camelize.to_sym]
return unless rpc_method && @_prepended_module_for_type_check_
# prepend したモジュールはインスタンス変数に覚えておいたので、そこにメソッドを追加
@_prepended_module_for_type_check_.class_eval do
define_method method do |*args, **kwargs, &block|
response = super(*args, **kwargs, &block)
unless response.is_a?(rpc_method[:output])
raise GRPC::Internal, "the response of #{method} is expected to be an instance of #{rpc_method[:output]}, but the response is an instance of #{response.class}"
end
response
end
end
end
end
end
class GreeterServer < Helloworld::Greeter::Service
# このモジュールを include するだけで型チェックが行われるようになる
include GrpcServerTypechecker
def say_hello(hello_req, _unused_call)
Helloworld::HelloReply.new(message: "Hello #{hello_req.name}")
end
end
あるいは、怠惰な人は include
の一文すら書きたくないと考えるかもしれません。実は、Protocol Buffers が .proto ファイルを入力として生成する、Helloworld::Greeter::Service
のようなクラスは必ず GRPC::GenericService
を include
していますから、これに monkey patch を当てれば、gRPC サーバーの実装に何も書かなくてもレスポンスの型チェックを行えます。
module GRPC::GenericService
# GRPC::GenericService#included は既に定義されているので、ここでも一旦モジュールを
# 定義してから prepend が必要
module IncludedForClassMethods
def included(base)
super
base.extend(GRPC::GenericService::ClassMethods)
end
end
class << self
prepend IncludedForClassMethods
end
module ClassMethods
# Helloworld::Greeter::Service のようなクラスのメソッドを上書きしたい訳ではなく、
# それを継承した gRPC サーバーを実装するクラスのメソッドを上書きしたいので、
# 継承をフックしてモジュールの生成と prepend を行う
def inherited(subclass)
super if defined? super
mod = Module.new
subclass.class_eval do
prepend mod
@_prepended_module_for_type_check_ = mod
end
end
def method_added(method)
super if defined? super
rpc_method = rpc_descs[method.to_s.camelize.to_sym]
return unless rpc_method && @_prepended_module_for_type_check_
@_prepended_module_for_type_check_.class_eval do
define_method method do |*args, **kwargs, &block|
response = super(*args, **kwargs, &block)
unless response.is_a?(rpc_method[:output])
raise GRPC::Internal, "the response of #{method} is expected to be an instance of #{rpc_method[:output]}, but the response is an instance of #{response.class}"
end
response
end
end
end
end
end
# gRPC サーバーの実装では特段型チェックのための記述は必要ない
class GreeterServer < Helloworld::Greeter::Service
def say_hello(hello_req, _unused_call)
Helloworld::HelloReply.new(message: "Hello #{hello_req.name}")
end
end
まとめ
本記事では、Ruby で gRPC メソッドを呼び出す際に動的な型チェックを行う手法を解説しました。サーバー側の型チェッカはいささか強引な実装ですし、静的な型チェッカを導入するのに比べると素朴な解決法ではありますが、分かりやすいエラーメッセージの出力は開発者に十分な恩恵をもたらすことでしょう。
本記事で解説する gRPC メソッドの動的な型チェッカは Gem としても公開しています。興味があればご利用ください。