今回は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_requestはAws::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
さらに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
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されていきます。
図にするとこうです。
図では簡略化のため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
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
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
最後に実際の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
このように役割分担できているおかげで、各機能に必要な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
コメントにもあるように handlerはstepとpriorityという値と共に登録されます。
stepはinitialize, validate, build, sign, sendの段階に別れていて、sendのステップのハンドラーは常に一種類だけです。
sendのハンドラーが追加される場合は現在のsendステップのハンドラーが置き換えられます。先ほどの例では実際にリクエストを送信していたSeahorse::NetHttp::Handlerがsendステップの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
さて、ここで改めて発端となった問題について調べてみます。
動的に生やされているらしいメソッド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
さらに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
この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
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_pluginはSeahorse::Client::Baseのクラスメソッドで、引数に渡されたプラグインを、@plugins(クラスインスタンス変数)に追加します。
def add_plugin(plugin) @plugins.add(plugin) end
ということなので、サービスごとのClientで特有の動作をしていた場合は、Aws::***::Clientで特殊なpluginが追加されていないか確認してみるのも良さそうです。
長々と説明が続きましたが、弊社ではこんな感じで AWS SDK for Ruby を使ったサービス Cloud Automator を作っています。
AWSが好きな方、Rubyが好きな方、AWS SDK for Ruby が好きな方、ちょっと話を聞いてみたいという方ぜひ下記よりお問い合わせお待ちしております!
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。