WebMock のしくみを調査

前回のエントリーで cpp-netlib の client をモックにすげ替えることに成功しましたが、 任意のレスポンスを返すためにはどのようしたらいいか、という点は述べていませんでした。 このモックは、RubyWebMock のような設計にしようと考えているので、 WebMock がどのような設計になっているのかを調査しました。なお、調査した WebMock のバージョンは 1.20.4 となります。

WebMock 概要

まず、WebMock を用いて http://www.example.com/ にGETメソッドでアクセスした場合に、メッセージボディが "test" であるレスポンスを返すためには以下のようにします。

require 'net/http'
require 'webmock'

webmock::api.stub_request(:get, 'www.example.com').to_return(body: "test")

client = Net::HTTP.start("www.example.com")
response = client.get("/")
puts response.body # test

これにより、Net::HTTPhttp://www.example.com/ にアクセスしなくなり、かわりにモックで設定したレスポンスを返すようになります。

モックオブジェクトの生成と管理

ここで、モックオブジェクトの生成を行う WebMock::API::stub_request() メソッドは何をしているのでしょうか? このメソッドの定義は、以下のようになっています。

webmock/api.rb

module WebMock
  module API
    # ...
    def stub_request(method, uri)
      WebMock::StubRegistry.instance.
        register_request_stub(WebMock::RequestStub.new(method, uri))
    end
    # ...
  end
end

まず、WebMock::RequestStub というモックオブジェクトを生成します。 このオブジェクトでは、to_return() というレスポンスを設定するメソッドや、 with() メソッドというリクエストの条件を設定するメソッドが定義されています。 また、リクエストの条件は request_pattern プロパティに WebMock::RequestPattern というオブジェクトが保持されています。

webmock/request_stub.rb

module WebMock
  class RequestStub
    attr_accessor :request_pattern
    
    def initialize(method, uri)
      @request_pattern = RequestPattern.new(method, uri)
      # ...
    end
    
    def with(params = {}, &block)
      @request_pattern.with(params, &block)
      # ...
    end
    
    def to_return(*response_hashes, &block)
      # ...
    end
    # ...
  end
end

次に、WebMock::StubRegistry というモックオブジェクトを管理するオブジェクトの register_request_stub() メソッドに生成したモックオブジェクトを渡しています。 このメソッドでは渡された WebMock::RequestStub オブジェクトを request_stubs プロパティに追加します。request_stubs に保持されているモックオブジェクトは必要に応じて探索されます。

webmock/stub_registry.rb

module WebMock
  class StubRegistry
    include Singleton
    
    attr_accessor :request_stubs
    
    def initialize
      reset!
    end
    # ...
    def reset!
      self.request_stubs = []
    end
    # ...
    def register_request_stub(stub)
      request_stubs.insert(0, stub)
      stub
    end
    # ...
  end
end

モックオブジェクトの探索と実行

では Net::HTTP がリクエストを送るときは、どのような処理になっているのでしょうか?

WebMockは require されると、Net::HTTPWebMock::HttpLibAdapters::NetHttpAdapter@webMockNetHTTP に置き換えます。そして、Net::HTTP#request() メソッドが呼ばれた時に以下の動作をするようになります。

webmock/http_lib_adapters/net_http.rb

module WebMock
  module HttpLibAdapters
    class NetHttpAdapter < HttpLibAdapter
      # ...
      @webMockNetHTTP = Class.new(Net::HTTP) do
        # ...
        def request(request, body = nil, &block)
          request_signature = WebMock::NetHTTPUtility.request_signature_from_request(self, request, body)
          # ...
          if webmock_response = WebMock::StubRegistry.instance.response_for_request(request_signature)
            # ...
          elsif WebMock.net_connect_allowed?(request_signature.uri)
            # ...
          else
            raise WebMock::NetConnectNotAllowedError.new(request_signature)
          end
        end
        # ...
      end
      # ...
    end
  end
end

まず、WebMock::NetHTTPUtility::request_signature_from_request() メソッドにより WebMock::RequestSignature オブジェクト request_signature を生成します。このオブジェクトは、各アダプターのリクエストを正規化し保持するためのものです。

つぎに、生成した request_signature オブジェクトを WebMock::StubRegistry#response_for_request() メソッドに渡し、 内部で request_stub_for() メソッドを呼び出します。 そして、request_signature オブジェクトに一致するモックオブジェクトを request_stubs の中から各モックオブジェクトの request_pattern プロパティの matches?() メソッドを用いて線形探索します。

最後に、探索したモックオブジェクトに設定されているレスポンスを返します。このとき、一致するモックオブジェクトが存在しなければ、例外を投げます。ただし、WebMock::Config#allow_net_connecttrue の時は、実際にHTTPアクセスをします。

webmock/stub_registry.rb

module WebMock
  class StubRegistry
    # ...
    def response_for_request(request_signature)
      stub = request_stub_for(request_signature)
      stub ? evaluate_response_for_request(stub.response, request_signature) : nil
    end
    
    private
    
    def request_stub_for(request_signature)
      (global_stubs + request_stubs).detect { |registered_request_stub|
        registered_request_stub.request_pattern.matches?(request_signature)
      }
    end
    
    def evaluate_response_for_request(response, request_signature)
      response.dup.evaluate(request_signature)
    end
  end
end

WebMock::RequestPattern について

上記でモックオブジェクトの探索方法について述べましたが、具体的にはどのように request_signature に一致しているか判定しているのでしょうか?

WebMock::RequestPattern の定義は下記のようになっています。

webmock/request_pattern.rb

module WebMock
  class RequestPattern
    attr_reader :method_pattern, :uri_pattern, :body_pattern, :headers_pattern
    
    def initialize(method, uri, options = {})
      @method_pattern  = MethodPattern.new(method)
      @uri_pattern     = create_uri_pattern(uri)
      @body_pattern    = nil
      @headers_pattern = nil
      @with_block      = nil
      assign_options(options)
    end
    
    def with(options = {}, &block)
      raise ArgumentError.new('#with method invoked with no arguments. Either options hash or block must be specified.') if options.empty? && !block_given?
      assign_options(options)
      @with_block = block
      self
    end
    
    def matches?(request_signature)
      content_type = request_signature.headers['Content-Type'] if request_signature.headers
      content_type = content_type.split(';').first if content_type
      @method_pattern.matches?(request_signature.method) &&
        @uri_pattern.matches?(request_signature.uri) &&
        (@body_pattern.nil? || @body_pattern.matches?(request_signature.body, content_type || "")) &&
        (@headers_pattern.nil? || @headers_pattern.matches?(request_signature.headers)) &&
        (@with_block.nil? || @with_block.call(request_signature))
    end
    # ...
  end
  
  class MethodPattern
    # ...
  end
  
  class URIPattern
    # ...
  end
  
  class BodyPattern
    # ...
  end
  
  class HeadersPattern
    # ...
  end
end

これを見ると、まずオブジェクト生成時の対象HTTPメソッド・URL、with() メソッドで設定された条件(リクエストHTTPヘッダの有無)をもとにパターンを表すオブジェクトを生成し保持します。

そして matches?() メソッドで、これらパターンすべてにマッチする時に一致したと判断し、true を返します。

さいごに

というわけで、WebMock のしくみがだいたいわかったので、これを参考に設計していきます。