【コードリーディング】Faraday
こんにちは、M-Yamashitaです。
今回の記事は、Faraday gemのコードリーディングです。
こういうことができるgemだということは知っていましたが、内部実装まで踏み込んだことはありませんでした。
最近触る機会があったので、仕組みを知っておきたいと思い、コードリーディングをしました。
この記事で伝えたいこと
- Faradayのコードリーディングによる内部実装の理解
 
Faradayとは
GitHubのREADMEから引用します。
Faraday is an HTTP client library abstraction layer that provides a common interface over many adapters (such as Net::HTTP) and embraces the concept of Rack middleware when processing the request/response cycle.
DeepLで翻訳すると以下のようになります。
FaradayはHTTPクライアント・ライブラリの抽象化レイヤであり、多くのアダプタ(Net::HTTPなど)に共通のインターフェイスを提供し、リクエスト/レスポンス・サイクルを処理する際にRackミドルウェアの概念を取り入れている。
ここで大事なのは、HTTPクライアントライブラリであることとRackミドルウェアの概念を取り入れていることです。Faradayのドキュメントに、Fardayの仕組みをまとめられたページがありますので、参考にされてください。
また、Rackについてはこちらを参考にされてください。
コードリーディング
環境
- Ruby 3.2.2
 - Faraday 2.7.11
 
前提
Faradayを使ったサンプルコードは以下のようになります。
require 'faraday'
class FaradaySample
  def request
    # Create a Faraday connection object with retry middleware
    conn = Faraday.new(url: 'https://example.com') do |faraday|
      faraday.adapter Faraday.default_adapter
    end
    # Make a GET request to the /posts endpoint
    response = conn.get('/')
    # Print the response body
    puts response.body
  end
end
コードリーディングでは上記コードのうち、以下2つを追っていきます。
- Faradayの初期化(
Faraday.new(url: 'https://example.com')) - Getリクエスト(
conn.get('/')) 
 Faradayの初期化(Faraday.new)
newメソッドは以下のとおりです。
    def new(url = nil, options = {}, &block)
      options = Utils.deep_merge(default_connection_options, options)
      Faraday::Connection.new(url, options, &block)
    end
ここではoptionsを作成し、Faraday::Connectionのインスタンスを作成しています。
options作成でのdefault_connection_optionsメソッドを見てみます。
    def default_connection_options
      @default_connection_options ||= ConnectionOptions.new
    end
初期化時点では@default_connection_optionsに何もセットしていないので、ConnectionOptions.newを実行します:
module Faraday
  ConnectionOptions = Options.new(:request, :proxy, :ssl, :builder, :url,
                                  :parallel_manager, :params, :headers,
                                  :builder_class) do
    options request: RequestOptions, ssl: SSLOptions
    memoized(:request) { self.class.options_for(:request).new }
    memoized(:ssl) { self.class.options_for(:ssl).new }
    memoized(:builder_class) { RackBuilder }
    def new_builder(block)
      builder_class.new(&block)
    end
  end
end
ConnectionOptionsにはOptionクラスのインスタンスをセットしています。OptionsクラスはStructの子クラスとなります。そのためConnectionOptionnsは、Optionsクラス(Struct)のインスタンスを保持しています。
またConnectionOptions = Options.newの行を見てみると、ブロックを持っていることがわかります。ブロック内の処理をそれぞれ見ていきます。
まずは以下のoptionsの行をたどります。
module Faraday
  ConnectionOptions = Options.new(:request, :proxy, :ssl, :builder, :url,
                                  :parallel_manager, :params, :headers,
                                  :builder_class) do
    options request: RequestOptions, ssl: SSLOptions
optionsメソッドの定義は以下のとおりです。
    def self.options(mapping)
      attribute_options.update(mapping)
    end
    def self.attribute_options
      @attribute_options ||= {}
    end
ここではハッシュを持つ@attribute_optionsに、先ほどのrequest: RequestOptions, ssl: SSLOptionsをセットしています。
次はmemoizedの箇所を見ます。
memoized(:request) { self.class.options_for(:request).new }
memoized(:ssl) { self.class.options_for(:ssl).new }
memoized(:builder_class) { RackBuilder }
memoizedメソッドの定義は以下のとおりです。
    def self.memoized(key, &block)
      unless block
        raise ArgumentError, '#memoized must be called with a block'
      end
      memoized_attributes[key.to_sym] = block
      class_eval <<-RUBY, __FILE__, __LINE__ + 1
        remove_method(key) if method_defined?(key, false)
        def #{key}() self[:#{key}]; end
      RUBY
    end
    def self.memoized_attributes
      @memoized_attributes ||= {}
    end
memoizedメソッドでは、まずkeyとblockを@memoized_attributesにハッシュとして保持します。
次にclass_evalを使って動的にメソッドを定義します。今回はkeyにrequest、ssl、builder_classを渡しているので、以下のメソッドが動的に定義されます。
def request() self[:request]; end
def ssl() self[:ssl]; end
def builder_class() self[:builder_class]; end
ここまででdefault_connection_optionsの処理が終わりOptionsクラスのインスタンスを返すことがわかったので、options = Utils.deep_merge(default_connection_options, options)のUtils.deep_mergeを追ってみます。(引数のoptionsは、今回何も指定していないのでnilとなります。)
    def deep_merge!(target, hash)
      hash.each do |key, value|
        target[key] = if value.is_a?(Hash) && (target[key].is_a?(Hash) || target[key].is_a?(Options))
                        deep_merge(target[key], value)
                      else
                        value
                      end
      end
      target
    end
    # Recursive hash merge
    def deep_merge(source, hash)
      deep_merge!(source.dup, hash)
    end
ここでは、targetにhashのkey、valueがあれば追加しています。valueやtarget[key]のクラスによっては再帰的にdepp_merge!メソッドを呼び出して追加していくようです。
そのため、ユーザーがFaraday.newの引数optionsに何も指定しなければ、デフォルトのOptionsクラスのインスタンスを使用します。何か指定していればそのインスタンスに追加もしくは上書きしたインスタンスを使用するようです。
Faraday.new内のdeep_merge!メソッドを見終わったので、Farayday::Connection.newに移ります。
    def new(url = nil, options = {}, &block)
      options = Utils.deep_merge(default_connection_options, options)
      Faraday::Connection.new(url, options, &block)
    end
Faraday::Connectionクラスの初期化メソッドは以下のとおりです。
    def initialize(url = nil, options = nil)
      options = ConnectionOptions.from(options)
      if url.is_a?(Hash) || url.is_a?(ConnectionOptions)
        options = Utils.deep_merge(options, url)
        url     = options.url
      end
      @parallel_manager = nil
      @headers = Utils::Headers.new
      @params  = Utils::ParamsHash.new
      @options = options.request
      @ssl = options.ssl
      @default_parallel_manager = options.parallel_manager
      @manual_proxy = nil
      @builder = options.builder || begin
        # pass an empty block to Builder so it doesn't assume default middleware
        options.new_builder(block_given? ? proc { |b| } : nil)
      end
      self.url_prefix = url || 'http:/'
      @params.update(options.params)   if options.params
      @headers.update(options.headers) if options.headers
      initialize_proxy(url, options)
      yield(self) if block_given?
      @headers[:user_agent] ||= USER_AGENT
    end
行数が多いので、部分ごとに見ていきます。まずは、以下にあるoptionsやurl変数へのセットを見ていきます。
      options = ConnectionOptions.from(options)
      if url.is_a?(Hash) || url.is_a?(ConnectionOptions)
        options = Utils.deep_merge(options, url)
        url     = options.url
      end
引数で渡されたoptionsからConnectionOptionsを使用し、Optionクラスのインスタンスを作成します。また、Faraday.newの引数で指定されていたurlがハッシュならば、Utilsクラスを使ってurl keyのvalueを取り出します。今回はurl: 'https://example.com'と与えているので、'https://example.com'がurlにセットされます。
次にインスタンス変数のセットを見てみます。
      @parallel_manager = nil
      @headers = Utils::Headers.new
      @params  = Utils::ParamsHash.new
      @options = options.request
      @ssl = options.ssl
      @default_parallel_manager = options.parallel_manager
      @manual_proxy = nil
      @builder = options.builder || begin
        # pass an empty block to Builder so it doesn't assume default middleware
        options.new_builder(block_given? ? proc { |b| } : nil)
      end
      self.url_prefix = url || 'http:/'
      @params.update(options.params)   if options.params
      @headers.update(options.headers) if options.headers
@manual_proxyへのセットまでは、基本的にデフォルト値がセットされます。また@builderについては、今回blockの指定があるので、上述のOptionsクラスで定義されていたnew_builerメソッドを、proc{ |b| }の引数付きで呼び出し、RackBuilderのインスタンスを作成しています。
このRackBuilderのinitializeメソッドは以下のコードです。
    def initialize(&block)
      @adapter = nil
      @handlers = []
      build(&block)
    end
この初期化メソッドでのbuildメソッドを見てみます。
    def build
      raise_if_locked
      block_given? ? yield(self) : request(:url_encoded)
      adapter(Faraday.default_adapter, **Faraday.default_adapter_options) unless @adapter
    end
raise_if_lockedメソッドについては、今回はnilとなります。次のblock_givenではブロックproc { |b| }は渡されていますが、空ブロックのため何も行いません。
次に、@adapterはまだセットされていないので、adapter(Faraday.default_adapter, **Faraday.default_adapter_options)を実行します。
adapterメソッド引数のFaraday.default_adapter, **Faraday.default_adapter_optionsの定義箇所は以下となります。
  self.default_adapter = :net_http
  self.default_adapter_options = {}
Faradayクラスの変数として定義されているので、クラスロード時に:net_http, {}がデフォルト値としてセットされています。
この引数を使ったadapterメソッドは以下のとおりです。
    ruby2_keywords def adapter(klass = NO_ARGUMENT, *args, &block)
      return @adapter if klass == NO_ARGUMENT || klass.nil?
      klass = Faraday::Adapter.lookup_middleware(klass) if klass.is_a?(Symbol)
      @adapter = self.class::Handler.new(klass, *args, &block)
    end
klassが:net_httpというシンボルなので、Faraday::Adapter.lookup_middleware(klass)を実行します。そのlookup_middlewareメソッドは、Faraday::MiddlewareRegistryモジュールに定義されています。
    def lookup_middleware(key)
      load_middleware(key) ||
        raise(Faraday::Error, "#{key.inspect} is not registered on #{self}")
    end
    def load_middleware(key)
      value = registered_middleware[key]
      case value
      when Module
        value
      when Symbol, String
        middleware_mutex do
          @registered_middleware[key] = const_get(value)
        end
      when Proc
        middleware_mutex do
          @registered_middleware[key] = value.call
        end
      end
    end
load_middlewareメソッドのregistered_middlewareをdebugで見てみると、以下のようになっていました。
(ruby) Faraday::Adapter.registered_middleware
{:test=>Faraday::Adapter::Test, :net_http=>Faraday::Adapter::NetHttp}
これらはFaradayの読み込み時にregistered_middlewareに登録されているようです。
そのため、lookup_middlewareメソッドでは、registered_middleware[key]で:net_httpのキー指定によりFaraday::Adapter::NetHttpモジュールを取り出して、そのモジュールを返しています。
よってklassがそのモジュールとなるので、self.class::Handler.new(klass, *args, &block)を実行し、Handlerインスタンスを@adapterにセットします。
では、Faraday::Connectionクラスの初期化メソッドの確認に戻ります。
@param, @headersについては、options.params, options.headersがあればそれをセットします。今回はparams、headersを指定していないのでスキップします。
Faraday::Connectionクラスの初期化メソッドの最後の部分です。
      initialize_proxy(url, options)
      yield(self) if block_given?
      @headers[:user_agent] ||= USER_AGENT
initialize_proxyメソッドは以下のとおりです。
    def initialize_proxy(url, options)
      @manual_proxy = !!options.proxy
      @proxy =
        if options.proxy
          ProxyOptions.from(options.proxy)
        else
          proxy_from_env(url)
        end
    end
options.proxyは今回指定していないので、@manual_proxyはfalseとなります。また、@proxyへのセットでは、proxy_from_envメソッドが呼ばれます。
    def proxy_from_env(url)
      return if Faraday.ignore_env_proxy
      uri = nil
      case url
      when String
        uri = Utils.URI(url)
        uri = if uri.host.nil?
                find_default_proxy
              else
                URI.parse("#{uri.scheme}://#{uri.host}").find_proxy
              end
      when URI
        uri = url.find_proxy
      when nil
        uri = find_default_proxy
      end
      ProxyOptions.from(uri) if uri
    end
Faraday.ignore_env_proxyはデフォルトでfalseのため、早期returnはスルーします。
urlは"https://example.com"なのでStringのケースに入り、Utils.URIを呼び出します。
    def URI(url) # rubocop:disable Naming/MethodName
      if url.respond_to?(:host)
        url
      elsif url.respond_to?(:to_str)
        default_uri_parser.call(url)
      else
        raise ArgumentError, 'bad argument (expected URI object or URI string)'
      end
    end
    def default_uri_parser
      @default_uri_parser ||= Kernel.method(:URI)
    end
urlはStringのため、default_uri_parser.call(url)が実行されます。つまりKernel.method(:URI).call("https://example.com")となり、返り値はURI::HTTPSクラスのインスタンスです。
ではUtils.URIの呼び出し元のコードに戻ります。
uri.hostは存在するのでURI.parseのコードを実行しますが、proxyを設定していないのでfind_proxyメソッドの戻り値はnilとなります。そのため、ProxyOptions.from(uri)の実行はスキップされ、@proxyはnilとなります。
initialize_proxyメソッドの確認が終わったので以下の処理を見ていきます。
      yield(self) if block_given?
      @headers[:user_agent] ||= USER_AGENT
@headersについてはUSER_AGENTをセットしているのみです。その前のblock_givenについては、Faraday.newに以下blockが与えられているので、それを実行します。
    conn = Faraday.new(url: 'https://example.com') do |faraday|
      faraday.adapter Faraday.default_adapter
    end
このfaraday.adapterはConnectionクラスにて定義されています。
    def_delegators :builder, :use, :request, :response, :adapter, :app
adapterメソッドの処理は、buider、つまりRackBuilderインスタンスに移譲されています。
ここの Faraday.default_adapterをセットする処理は、上述した処理と同じであるため割愛します。
以上で、Faraday::Connection.new(url, options, &block)の確認が終わり、Faraday.newの処理はここで終わりです。
Faraday#getの呼び出し
Faradayの初期化が終了したので、以下getのコードリーディングをしていきます。
response = conn.get('/')
getメソッドは、Faraday::Connectionクラスでは動的に作成されます。
  class Connection
    # A Set of allowed HTTP verbs.
    METHODS = Set.new %i[get post put delete head patch options trace]
    METHODS_WITH_QUERY.each do |method|
      class_eval <<-RUBY, __FILE__, __LINE__ + 1
        def #{method}(url = nil, params = nil, headers = nil)
          run_request(:#{method}, url, nil, headers) do |request|
            request.params.update(params) if params
            yield request if block_given?
          end
        end
      RUBY
    end
そのため、動的に作成されたgetメソッドは以下のようになります。
def get(url = nil, params = nil, headers = nil)
  run_request(:get, url, nil, headers) do |request|
    request.params.update(params) if params
    yield request if block_given?
  end
end
run_requestメソッドが呼び出されるのでそれを見てみます。
    def run_request(method, url, body, headers)
      unless METHODS.include?(method)
        raise ArgumentError, "unknown http method: #{method}"
      end
      request = build_request(method) do |req|
        req.options.proxy = proxy_for_request(url)
        req.url(url)                if url
        req.headers.update(headers) if headers
        req.body = body             if body
        yield(req) if block_given?
      end
      builder.build_response(self, request)
    end
今回はgetメソッドを呼んでいるので、unlessには引っかかりません。
次にbuild_requestメソッドを呼び出します。
    def build_request(method)
      Request.create(method) do |req|
        req.params  = params.dup
        req.headers = headers.dup
        req.options = options.dup
        yield(req) if block_given?
      end
    end
Request.createとあるので、そこを見てみます。
  Request = Struct.new(:http_method, :path, :params, :headers, :body, :options) do
    extend MiddlewareRegistry
    alias_method :member_get, :[]
    private :member_get
    alias_method :member_set, :[]=
    private :member_set
    # @param request_method [String]
    # @yield [request] for block customization, if block given
    # @yieldparam request [Request]
    # @return [Request]
    def self.create(request_method)
      new(request_method).tap do |request|
        yield(request) if block_given?
      end
    end
Request.createメソッドではStructのnewメソッドを呼び出しているのみとなります。
そのcreateメソッドではblockを呼び出しているので、build_requestメソッドのブロックを見てみます。
    def build_request(method)
      Request.create(method) do |req|
        req.params  = params.dup
        req.headers = headers.dup
        req.options = options.dup
        yield(req) if block_given?
      end
    end
ブロックではパラメータ、ヘッダーを指定しています。さらにbuild_requestメソッド呼び出し元でブロックがあれば、更にそれを呼び出します。それでは呼び出し元のrun_requestメソッドを見ます。
      request = build_request(method) do |req|
        req.options.proxy = proxy_for_request(url)
        req.url(url)                if url
        req.headers.update(headers) if headers
        req.body = body             if body
        yield(req) if block_given?
      end
ここではurl、ヘッダー、ボディを設定しています。更にrun_requestメソッドの呼び出し元にブロックがあればそれを呼び出します。
呼び出し元を見てみると、以下のようになっています。
def get(url = nil, params = nil, headers = nil)
  run_request(:get, url, nil, headers) do |request|
    request.params.update(params) if params
    yield request if block_given?
  end
end
paramsはnilであることや、getメソッドの呼び出し元ではブロックを指定していないため、run_requestメソッドのブロックでは何も行いません。
ここまでで、yieldをたどる確認が終わったので、run_requestメソッドのbuilder.build_response(self, request)の確認に移ります。
    def run_request(method, url, body, headers)
      unless METHODS.include?(method)
        raise ArgumentError, "unknown http method: #{method}"
      end
      request = build_request(method) do |req|
        req.options.proxy = proxy_for_request(url)
        req.url(url)                if url
        req.headers.update(headers) if headers
        req.body = body             if body
        yield(req) if block_given?
      end
      builder.build_response(self, request)
    end
builderはFaraday::Connectionクラスの初期化時に設定されたものとなります。今回はRackBuilderのインスタンスになるので、そのクラスのbuild_responseメソッドを見ます。
    def build_response(connection, request)
      app.call(build_env(connection, request))
    end
まずは引数にあるbuild_envメソッドを見ます。
    def build_env(connection, request)
      exclusive_url = connection.build_exclusive_url(
        request.path, request.params,
        request.options.params_encoder
      )
      Env.new(request.http_method, request.body, exclusive_url,
              request.options, request.headers, connection.ssl,
              connection.parallel_manager)
    end
build_excluesive_urlメソッドは以下のとおりです。
    def build_exclusive_url(url = nil, params = nil, params_encoder = nil)
      url = nil if url.respond_to?(:empty?) && url.empty?
      base = url_prefix.dup
      if url && !base.path.end_with?('/')
        base.path = "#{base.path}/" # ensure trailing slash
      end
      url = url.to_s.gsub(':', '%3A') if URI.parse(url.to_s).opaque
      uri = url ? base + url : base
      if params
        uri.query = params.to_query(params_encoder || options.params_encoder)
      end
      uri.query = nil if uri.query && uri.query.empty?
      uri
    end
このメソッドでは、url、params, params_encoderを組み合わせて、URIを作るメソッドとなります。
ではbuild_envメソッドに戻ります。次はEnv.newを見てみます。Envは、Structを親に持つOptionのインスタンスとなっています。今回のEnv.newではHTTPリクエストメソッドの:get、リクエストボディなどをセットします。
  Env = Options.new(:method, :request_body, :url, :request,
                    :request_headers, :ssl, :parallel_manager, :params,
                    :response, :response_headers, :status,
                    :reason_phrase, :response_body) do
それではbuild_envメソッドの呼び出し元である、app.callの処理に戻ります。
    def build_response(connection, request)
      app.call(build_env(connection, request))
    end
appメソッドは以下のとおりです。
    def app
      @app ||= begin
        lock!
        ensure_adapter!
        to_app
      end
    end
    def to_app
      # last added handler is the deepest and thus closest to the inner app
      # adapter is always the last one
      @handlers.reverse.inject(@adapter.build) do |app, handler|
        handler.build(app)
      end
    end
@handlersについては、今回何も追加のハンドラーを指定していないため、{}となっています。また@adapterはFaraday::Adapter::NetHttpとなっています。そのため、Faraday::Adapter::NetHttpのインスタンスが@appに入ります。
よってapp.call(build_env(connection, request))はFaraday::Adapter::NetHttpインスタンスのcallメソッド呼び出しとなります。
この先はFaradayのgemとは別のgemとなりますので、コードリーディングはここまでとなります。なお、callメソッドの返り値はFaraday::Responseとなります。
おわりに
今回はFaradayのコードリーディングを行いました。
追いかけるのが少し大変な部分もありましたが、比較的読みやすいコードだったと思います。
今後はFaradayが呼び出すことができるnet_httpや、stub、retryまでコードリーディングを広げたいですね。
この記事が誰かのお役に立てれば幸いです。
Discussion