AWS SDK for Rubyの仕組みを調べてみた Seahorse::Client編

記事タイトルとURLをコピーする

今回はAWS SDK for Ruby(version 3)でHTTPリクエストを発行するために使われている Seahorse::Clientというモジュールの仕組みについて調べてみたので、紹介します。 この記事で引用するソースコードは公開時点でのmasterブランチのものです。
引用したコードブロックにはgithubへのリンクを付記しています。

調べようと思ったきっかけはSDKのresponseに利用できるとあるメソッドが、リクエスト実行時に動的にinclude(extend)されているらしいと気づいたためです。

例えば Aws::EC2::Client#describe_instancesの返り値であるAws::EC2::Types::DescribeInstancesResultにはlast_page?というメソッドが生えています。

require 'aws-sdk-ec2'
=> true
client = Aws::EC2::Client.new
client.describe_instances
=> #<struct Aws::EC2::Types::DescribeInstancesResult reservations=[....(省略)
client.describe_instances.last_page?
=> true

しかし、テスト用途などで自分で同じクラスのオブジェクトを作成してもlast_page?メソッドは生えていません。

response = Aws::EC2::Types::DescribeInstancesResult.new(reservations: [], next_token: nil)
=> #<struct Aws::EC2::Types::DescribeInstancesResult reservations=[], next_token=nil>
response.last_page?
NoMethodError (undefined method `last_page?' for #<Aws::EC2::Types::DescribeInstancesResult:0x0000000002a93bf0>)

ざっとみた感じ、どうもresponseオブジェクに対して、動的にlast_page?メソッドが含まれるモジュールがincludeされているようです。

そこでSDKのリクエスト送信部分に利用されているSeahorse::Clientのコードを読んでいき、このメソッドがどこで動的に生やされているのかをみていきます。

まずはAws::EC2::Client#describe_instancesの定義を確認します。

    def describe_instances(params = {}, options = {})
      req = build_request(:describe_instances, params)
      req.send_request(options)
    end

build_requestAws::EC2::Clientの親クラスであるSeahorse::Client::Baseクラスのメソッドなので、このBaseクラスをみていきます。 (Baseクラスのインスタンスが実際のclientのインスタンスなので、今後clientと表記します。)

clientはpluginsというオブジェクトを所有しています。

    class Base
      ...
      ...(省略)...
      ...
      
      # default plugins
      @plugins = PluginList.new([
        Plugins::Endpoint,
        Plugins::NetHttp,
        Plugins::RaiseResponseErrors,
        Plugins::ResponseTarget,
        Plugins::RequestCallback
      ])
  
      # @api private
      def initialize(plugins, options)
        @config = build_config(plugins, options)
        @handlers = build_handler_list(plugins)
        after_initialize(plugins)
      end

github

さらにpluginはそれぞれ、Handlerオブジェクトを所有しています。 Clientオブジェクトの初期化時にbuild_handler_listメソッドでpluginsが所有するhandlerを@handlersに集めています。

build_handler_listの定義を見るとPluginのadd_handlerメソッドを呼び出しています。

# Gives each plugin the opportunity to register handlers for this client.
def build_handler_list(plugins)
  plugins.inject(HandlerList.new) do |handlers, plugin|
    if plugin.respond_to?(:add_handlers)
      plugin.add_handlers(handlers, @config)
    end
    
    handlers
  end
end

Pluginには色々な種類がありますが、各Puginクラスの構造を要約すると以下のような構造になっています。

class ExamplePlugin < Seahorse::Client::Plugin
  ...(省略)...
  
  def add_handlers(handlers, config)
    handlers.add(Handler)
  end
  
  ...(省略)...

  class Handler < Seahorse::Client::Handler
    ...
    ...(省略)...
    ...
  end
end

Pluginクラスは同じ名前空間の中に、Seahorse::Client::Handlerを継承したHandlerクラスを持っていて、add_handlerでそれを渡されたHandlerListオブジェクトに格納します。 (HandlerListは複数のHandlerを保持するコレクションオブジェクトです)

この一連の流れで、clientのインスタンス変数@handlersに各Pluginの名前空間にあったHandlerが集められたことでclientから直接扱えるようになっています。 実際にリクエストを送信する主役となるのはこのHandlerオブジェクトです。

Handlerの基底クラスの中身はこのようになっています。

class Handler
  
  # @param [Handler] handler (nil) The next handler in the stack that
  #   should be called from within the {#call} method.  This value
  #   must only be nil for send handlers.
  def initialize(handler = nil)
    @handler = handler
  end
  
  # @return [Handler, nil]
  attr_accessor :handler
  
  # @param [RequestContext] context
  # @return [Response]
  def call(context)
    @handler.call(context)
  end
  
  def inspect
    "#<#{self.class.name||'UnnamedHandler'} @handler=#{@handler.inspect}>"
  end
end

github

Handlerは別のHandlerオブジェクトを所有し、渡された引数を別のhandlerにそのまま渡して呼び出します。そしてそのHandlerはさらに別のHandlerに... と続いていきます。 RubyのRackやPythonのWSGIと同じマトリョーシカ構造です。

コンソールからも以下のように色々な種類のhandlerがマトリョーシカ構造になった様子を確認できます。

ec2_client.handlers.to_stack
=> #<Seahorse::Client::Plugins::ResponseTarget::Handler @handler=#<Aws::Plugins::ParamConverter::Handler @handler=#<Aws::Plugins::IdempotencyToken::Handler @handler=#<Aws::Plugins::JsonvalueConverter::Handler @handler=#<Seahorse::Client::Plugins::RaiseResponseErrors::Handler @handler=#<Aws::Plugins::ParamValidator::Handler @handler=#<Seahorse::Client::Plugins::Endpoint::Handler @handler=#<Aws::Plugins::EndpointDiscovery::Handler @handler=#<Aws::Plugins::EndpointPattern::Handler @handler=#<Aws::Plugins::UserAgent::Handler @handler=#<Aws::Plugins::Protocols::EC2::Handler @handler=#<Aws::Plugins::RetryErrors::Handler @handler=#<Aws::Plugins::HelpfulSocketErrors::Handler @handler=#<Aws::Xml::ErrorHandler @handler=#<Seahorse::Client::Plugins::ContentLength::Handler @handler=#<Seahorse::Client::NetHttp::Handler @handler=nil>>>>>>>>>>>>>>>>

この大きなオブジェクトも1つのhandlerなので、最後にcallメソッドを呼び出せばAPIリクエストが送信されます。
たくさんのhandlerはそれぞれに役割分担して、callメソッドに渡された引数を適宜修正しながらバケツリレーして送信します。
返ってきたきたレスポンスはhandlerを送信時とは逆の順番でバケツリレーされてreturnされていきます。
図にするとこうです。
handlerが再帰的に呼び出される様子 図では簡略化のためcontextやresponseをそのまま渡していますが、実際にはそれぞれのHandlerは各自の役割によってcontextやresponseを加工しながら次のHandlerに渡しています。
例えば Seahorse::Client::Plugins::Endpoint::Handlerは引数からエンドポイント(URI)を組み立てる役割を持ちます。

class Handler < Client::Handler
  
  def call(context)
    context.http_request.endpoint = URI.parse(context.config.endpoint.to_s)
    @handler.call(context)
  end
  
end

github

Plugins::RaiseResponseErrors::Handler はresponseがエラーだった場合にraiseする役割があります。

 # @api private
class Handler < Client::Handler
  def call(context)
    response = @handler.call(context)
    raise response.error if response.error
    response
  end
end

github

Logging::Handlerは送信前, 受信後どちらのタイミングでも機能するhandlerで、送信時刻と受信時刻を記録してログ出力します。

class Handler < Client::Handler
  
# @param [RequestContext] context
# @return [Response]
def call(context)
  context[:logging_started_at] = Time.now
  @handler.call(context).tap do |response|
    context[:logging_completed_at] = Time.now
    log(context.config, response)
  end
end

github

最後に実際のHTTPリクエストを送信するのは Seahorse::Client::NetHttp::Handler です。

# @param [RequestContext] context
# @return [Response]
def call(context)
  transmit(context.config, context.http_request, context.http_response)
  Response.new(context: context)
end

github

このように役割分担できているおかげで、各機能に必要なHandlerとPluginを特定の機能だけ追加するということもできます。実際にS3の署名に関する処理や、paramsのバリデーションなどの処理は適宜Clientの定義時にプラグインとして追加されます。
もし自作のPluginとHandlerを書くようなことがあれば、以下の要領でhandlerを書くことになります。

class Handler < Seahorse::Client::Handler
  
# @param [RequestContext] context
# @return [Response]
def call(context)
  # request 前に実行したい追加の処理を書く
  response = @handler.call(context) # context を渡して次の handler を呼び出す
  # resuest 後 または response に対して実行したい追加の処理を書く
  return response # response を次の handler に返す
end

もちろん、contextを次のhandlerに渡して呼び出すことと、受け取ったresponseをreturnすることは実装する上で必須となります(バケツリレーの呼び出しの連鎖が機能しなくなるため)。

ところで、clinetが所有するhandlersは特定の並び替えアルゴリズムによって順序づけされます。HTTPリクエストを送信するハンドラーが一番最初に実行されてしまうようなことがあると、他のhanlderとの連携がうまくいかないためです。
それを管理しているのが HandlerListEntryというHandlerのラッパークラスです。

      # @param [Class<Handler>] handler_class This should be a subclass
      #   of {Handler}.
      #
      # @option options [Symbol] :step (:build) The request life-cycle
      #   step the handler should run in.  Defaults to `:build`.  The
      #   list of possible steps, in high-to-low priority order are:
      #
      #   * `:initialize`
      #   * `:validate`
      #   * `:build`
      #   * `:sign`
      #   * `:send`
      #
      #   There can only be one send handler. Registering an additional
      #   `:send` handler replaces the previous one.
      #
      # @option options [Integer] :priority (50) The priority of this
      #   handler within a step.  The priority must be between 0 and 99
      #   inclusively.  It defaults to 50.  When two handlers have the
      #   same `:step` and `:priority`, the handler registered last has
      #   the highest priority.
      #
      # @option options [Array<Symbol,String>] :operations A list of
      #   operations names the handler should be applied to.  When
      #   `:operations` is omitted, the handler is applied to all
      #   operations for the client.
      #
      # @raise [InvalidStepError]
      # @raise [InvalidPriorityError]
      # @note There can be only one `:send` handler.  Adding an additional
      #   send handler replaces the previous.
      #
      # @return [Class<Handler>] Returns the handler class that was added.
      #
      def add(handler_class, options = {})
        @mutex.synchronize do
          add_entry(
            HandlerListEntry.new(options.merge(
              handler_class: handler_class,
              inserted: next_index
            ))
          )
        end
        handler_class
      end

github

コメントにもあるように handlerはsteppriorityという値と共に登録されます。
stepはinitialize, validate, build, sign, sendの段階に別れていて、sendのステップのハンドラーは常に一種類だけです。
sendのハンドラーが追加される場合は現在のsendステップのハンドラーが置き換えられます。先ほどの例では実際にリクエストを送信していたSeahorse::NetHttp::HandlersendステップのHandlerでした。
stepの他にもpriorityという0~99(default: 50)の数値も一緒に登録され、stepとpriorityを合わせてHanderLIst#to_stackでマトリョーシカ構造にした時の並び順が決定します。
ちなみに、sendステップのハンドラーは常に1種類だけという制約が活きているなと感じる実装として、スタブ機能の実装があります。
スタブは主にテスト用途のためにあらかじめclientにレスポンスを登録し、呼び出し時に登録していたレスポンスを返させる機能です。つまり実際にリクエストの送信は行うことはしません。
実際に Aws::Plugins::StubResponseではStub機能をもつHandlerをsendステップとして追加しています。
これで、もともと登録されていたsendステップのHandlerが置き換わったため、特に他のコードの変更はしなくとも実際のHTTPリクエストは発行されなくなります。

      def add_handlers(handlers, config)
        handlers.add(Handler, step: :send) if config.stub_responses
      end

github


さて、ここで改めて発端となった問題について調べてみます。 動的に生やされているらしいメソッドlast_page?を探すとAws::PageableReponseというモジュールが見つかりました。 このmoduleにメソッドが定義されていました。

module Aws
  module PageableResponse
    ...
    ...(省略)...
    ...
    # Returns `true` if there are no more results.  Calling {#next_page}
    # when this method returns `false` will raise an error.
    # @return [Boolean]
    def last_page?
      if @last_page.nil?
        @last_page = !@pager.truncated?(self)
      end
      @last_page
    end
    ...
    ...(省略)...
    ...
  end
end

github

さらにAws::PegeableResponseの利用箇所を探すと、探していた部分が見つかりました。

module Aws
  module Plugins
    # @api private
    class ResponsePaging < Seahorse::Client::Plugin
  
      class Handler < Seahorse::Client::Handler
  
        def call(context)
          context[:original_params] = context.params
          resp = @handler.call(context)
          resp.extend(PageableResponse)
          resp.pager = context.operation[:pager] || Aws::Pager::NullPager.new
          resp
        end
  
      end
  
      handle(Handler, step: :initialize, priority: 90)
  
    end
  end
end

github

このAws::Plugins::ResponsePagingに定義されているHandlerは前のHandlerから受け取ったレスポンスに先ほどのモジュール PageableResponseをextendさせています。これにより、このHandlerが返すレスポンスオブジェクトはPageableResponseのメソッドが使える状態でreturnされています。 これで、last_page?などのメソッドが、実際のレスポンスには使えて、自分で初期化したレスポンスオブジェクトには使えない理由がわかりました。 (ちなみに先ほどのスタブ機能を使うとこのHandlerの動作を再現させることができるため、このメソッドを使うテストを書く場合は自前でMock化するよりもスタブ機能を使った方が良さそうです。)

AWS SDK for Ruby のスタブ機能の使い方についてはこちらの記事が詳しいです。 blog.serverworks.co.jp

ところで、このAws::Plugins::ResponsePagingは冒頭に紹介した、Seahorse::Client::Baseのデフォルトのプラグインには含まれていません。

    class Base
      ...
      ...
      ...
      
      # default plugins
      @plugins = PluginList.new([
        Plugins::Endpoint,
        Plugins::NetHttp,
        Plugins::RaiseResponseErrors,
        Plugins::ResponseTarget,
        Plugins::RequestCallback
      ])
  
      # @api private
      def initialize(plugins, options)
        @config = build_config(plugins, options)
        @handlers = build_handler_list(plugins)
        after_initialize(plugins)
      end

github

Aws::EC2::Clientの定義箇所を探してみると、add_pluginされていました。

class Client < Seahorse::Client::Base
  
    include Aws::ClientStubs
  
    @identifier = :ec2
  
    set_api(ClientApi::API)
    ...
    ...(省略)...
    ...

    add_plugin(Aws::Plugins::ResponsePaging) 
    
    ...
    ...(省略)...
    ...

add_pluginSeahorse::Client::Baseのクラスメソッドで、引数に渡されたプラグインを、@plugins(クラスインスタンス変数)に追加します。

def add_plugin(plugin)
  @plugins.add(plugin)
end

github

ということなので、サービスごとのClientで特有の動作をしていた場合は、Aws::***::Clientで特殊なpluginが追加されていないか確認してみるのも良さそうです。


長々と説明が続きましたが、弊社ではこんな感じで AWS SDK for Ruby を使ったサービス Cloud Automator を作っています。

cloudautomator.com

AWSが好きな方、Rubyが好きな方、AWS SDK for Ruby が好きな方、ちょっと話を聞いてみたいという方ぜひ下記よりお問い合わせお待ちしております!

www.serverworks.co.jp

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。