【コードリーディング】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.new
)
Faradayの初期化(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