【コードリーディング】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