1
/
5

gRPC Ruby でハマらないための型チェッカー

Photo by Seyi Ariyo on Unsplash

 gRPC とは Google が開発したオープンソースの RPC フレームワークで、一般に同じく Google の開発したシリアライズ形式の Protocol Buffers とセットで用いられています。この gRPC と Protocol Buffers の嬉しい点は色々あって、様々な言語に対応しているのもその一つです。

 Wantedly 社内ではもっぱら Ruby で書かれたサービスをマイクロサービス化していくために使っているのですが、そうなると困った所も出てきます。普通 gRPC を使って API を定義する際、リクエストやレスポンスがどのような形式をしているか—すなわち型—を Protocol Buffers の形式で明示的に書かなければならないのですが、Ruby は静的に型チェックを行ってくれないので、せっかく書いた型の情報をデバッグに役立てられないのです。お陰で、型の違うレスポンスを返す gRPC サーバーが書けてしまうことに起因する障害まで起きてしまいました。

 本記事では、Ruby で gRPC メソッドを呼び出す際に動的な型チェックを行う手法を解説します。静的な型チェッカを導入するのに比べると素朴な解決法ではありますが、分かりやすいエラーメッセージを出力すれば十分に開発の助けになることでしょう。本記事で解説する gRPC メソッドの動的な型チェッカは下記の Gem としても公開しています。興味があればご利用ください。


grpc_typechecker | RubyGems.org | your community gem host
RubyGems.org is made possible through a partnership with the greater Ruby community. Fastly provides bandwidth and CDN support, Ruby Central covers infrastructure costs, and Ruby Together funds ongoing development and ops work. Learn more about our sponso
https://rubygems.org/gems/grpc_typechecker


実行時に型情報を得る

 さて、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 サーバーごとに書けば良いとはいえ、こんな邪悪なコードを何度も書きたくはないですよね。なので、prependmethod_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::GenericServiceinclude していますから、これに 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 としても公開しています。興味があればご利用ください。

grpc_typechecker | RubyGems.org | your community gem host
RubyGems.org is made possible through a partnership with the greater Ruby community. Fastly provides bandwidth and CDN support, Ruby Central covers infrastructure costs, and Ruby Together funds ongoing development and ops work. Learn more about our sponso
https://rubygems.org/gems/grpc_typechecker
Wantedly, Inc.では一緒に働く仲間を募集しています
20 いいね!
20 いいね!
同じタグの記事
今週のランキング
Wantedly, Inc.からお誘い
この話題に共感したら、メンバーと話してみませんか?