テーマ
コードを追うための考え方と、追うことで得られることを、実際に追ってみることで考えてみる
(↑をまとめつつ、ついでにARの内部の作りを知る)
追いかけるターゲット
User.where(name: 'Hoge').first
注意
- Railsのバージョンは6.0.2.2
- データベースはPostgreSQL
まずは準備
$ mkdir ar_experiment && cd ar_experiment
# Gemfileを作成
$ bundle init
# Gemfileで以下のgemを指定
gem 'activerecord'
gem 'pry'
# install
$ bundle install
# activerecordをrequireしつつpryを開始
bundle exec pry -ractive_record
進む前に: pryの show-source
を使おう
ソースをすぐに読めるのでとても便利。
公式に他にもいくつか機能があるけど、これさえあれば大体大丈夫。
[1] pry(main)> show-source ActiveRecord::Relation#where
From: /.../gems/activerecord-6.0.2.2/lib/active_record/relation/query_methods.rb:524:
Owner: ActiveRecord::QueryMethods
Visibility: public
Signature: where(opts=?, *rest)
Number of lines: 9
def where(opts = :chain, *rest)
if :chain == opts
WhereChain.new(spawn)
elsif opts.blank?
self
else
spawn.where!(opts, *rest)
end
end
というわけで本題
思考整理
ActiveRecord::Base.where
# show-source ActiveRecord::Base.where
# ActiveRecord::Querying
delegate(*QUERYING_METHODS, to: :all)
# allにdelegateされているということは User.where = User.all.where であるとわかる
# show-source ActiveRecord::Base.all
# ActiveRecord::Scoping::Named::ClassMethods
def all
scope = current_scope
if scope
if scope._deprecated_scope_source
ActiveSupport::Deprecation.warn(<<~MSG.squish)
Class level methods will no longer inherit scoping from `#{scope._deprecated_scope_source}`
in Rails 6.1. To continue using the scoped relation, pass it into the block directly.
To instead access the full set of models, as Rails 6.1 will, use `#{name}.unscoped`,
or `#{name}.default_scoped` if a model has default scopes.
MSG
end
if self == scope.klass
scope.clone
else
relation.merge!(scope)
end
else
default_scoped
end
end
# current_scopeは一旦無視 (nilになると仮定して)
# show-source ActiveRecord::Base.default_scoped
# ActiveRecord::Scoping::Named::ClassMethods
def default_scoped(scope = relation) # :nodoc:
build_default_scope(scope) || scope
end
# build_default_scopeは一旦無視 (railsの default scopeに関係する何かだろう多分)
# show-source ActiveRecord::Base.relation
# ActiveRecord::Core::ClassMethods
def relation
relation = Relation.create(self)
if finder_needs_type_condition? && !ignore_default_scope?
relation.where!(type_condition)
relation.create_with!(inheritance_column.to_s => sti_name)
else
relation
end
end
# ifの上段はSTIの場合にtypeを付与しているようなコードっぽいので、今回はelse節をみる
# とすると結果は ActiveRecord:Relation.create(self)
# createしているが多分newして返してるんだと思っておく
# show-source ActiveRecord::Relation#where
# ActiveRecord::QueryMethods
def where(opts = :chain, *rest)
if :chain == opts
WhereChain.new(spawn)
elsif opts.blank?
self
else
spawn.where!(opts, *rest)
end
end
# 今回は :chain ではないのでspawnの部分になる
思考整理
命名などからおおよその仮説を立てる
-
spawn
はcopy, clone, fork的な意味で広く使われるので、おそらくRelationインスタンスのコピーだろう、ぐらいに思っておく -
where!
の!マークはRubyでは破壊的メソッドに使われる。コピーしたものを破壊すれば元のものには影響がない。- つまり、 コピー機能(spawn) と 破壊的操作(where!) を使って 非破壊バージョン(where) を構築しているのだ、と考える
思考整理
ActiveRecord::Relation.where!
# show-source ActiveRecord::Relation#where!
# ActiveRecord::QueryMethods
def where!(opts, *rest) # :nodoc:
opts = sanitize_forbidden_attributes(opts)
references!(PredicateBuilder.references(opts)) if Hash === opts
self.where_clause += where_clause_factory.build(opts, rest)
self
end
# `references!` や `where_clause` などの内部状態を書き換えている
# show-source ActiveRecord::PredicateBuilder.references
# ActiveRecord::PredicateBuilder
def self.references(attributes)
attributes.map do |key, value|
if value.is_a?(Hash)
key
else
key = key.to_s
key.split(".").first if key.include?(".")
end
end.compact
end
# `references` は、ドットで切って最初を取り出しているので、WHERE句で使用するテーブル名を抽出しているようだ
# show-source ActiveRecord::Relation#where_clause_factory
# ActiveRecord::QueryMethods
def where_clause_factory
@where_clause_factory ||= Relation::WhereClauseFactory.new(klass, predicate_builder)
end
# show-source ActiveRecord::Relation::WhereClauseFactory#build
# ActiveRecord::Relation::WhereClauseFactory
def build(opts, other)
case opts
when String, Array
parts = [klass.sanitize_sql(other.empty? ? opts : ([opts] + other))]
when Hash
attributes = predicate_builder.resolve_column_aliases(opts)
attributes.stringify_keys!
parts = predicate_builder.build_from_hash(attributes)
when Arel::Nodes::Node
parts = [opts]
else
raise ArgumentError, "Unsupported argument type: #{opts} (#{opts.class})"
end
WhereClause.new(parts)
end
# `where_clause` には `WhereClause` クラスのインスタンスで条件を表現して追加している
# `a += b` は `a = a + b` と同値なので、合成しているイメージはわかるが、仮に左辺がArrayなら右辺はArrayでなければならないけど、そうじゃなくて右辺がWhereClauseになっている、よって左辺はArrayではない。+の両辺が異なる型ということは少ないと考えると、きっと左辺も最初からWhereClauseインスタンスで、WhereClause自体に+が定義されていると予想する。
# show-source ActiveRecord::Relation::WhereClause#+
# ActiveRecord::Relation::WhereClause
def +(other)
WhereClause.new(
predicates + other.predicates,
)
end
思考整理
コード上の同値性を抑えつつ書き換えて整理する
ActiveRecord::Relation#first
# show-source ActiveRecord::Relation#first
# ActiveRecord::FinderMethods
def first(limit = nil)
if limit
find_nth_with_limit(0, limit)
else
find_nth 0
end
end
# limitは呼ばないので find_nth 0 と同じ
# show-source ActiveRecord::Relation#find_nth
# ActiveRecord::FinderMethods
def find_nth(index)
@offsets[offset_index + index] ||= find_nth_with_limit(index, 1).first
end
- @offsets はメモ化によりキャッシュされている。実際の結果は find_nth_with_limit(0, 1).first
# show-source ActiveRecord::Relation#find_nth_with_limit
# ActiveRecord::FinderMethods
def find_nth_with_limit(index, limit)
if loaded?
records[index, limit] || []
else
relation = ordered_relation
if limit_value
limit = [limit_value - index, limit].min
end
if limit > 0
relation = relation.offset(offset_index + index) unless index.zero?
relation.limit(limit).to_a
else
[]
end
end
end
- Arrayを返しているとわかる。これに対して `first` しているのであるから、この結果を深堀りすればいい
- loaded? はロジックを見る限り 一度取りに行ってrecordsに取り込み済みかどうかという判断をしていると考えられる。今回は初ロードなのでelse節にいくと考える
- indexは今回0なので、結局 ordered_relation.limit(1).to_a だと考えられる
# show-source ActiveRecord::Relation#ordered_relation
# ActiveRecord::FinderMethods
def ordered_relation
if order_values.empty? && (implicit_order_column || primary_key)
order(arel_attribute(implicit_order_column || primary_key).asc)
else
self
end
end
- 主キーによるascを入れた結果のRelationを返している、と何となく分かる。
- orderメソッドもlimitメソッドも、結局where同様にspawnしてから状態を書き換えてるのだろうと考えると、order!, limit!を見ればいい
# show-source ActiveRecord::Relation#order!
# ActiveRecord::QueryMethods
def order!(*args) # :nodoc:
preprocess_order_args(args)
self.order_values += args
self
end
# show-source ActiveRecord::Relation#limit!
# ActiveRecord::QueryMethods
def limit!(value) # :nodoc:
self.limit_value = value
self
end
- limit_valueとorder_valuesという状態が書き換えられていると分かった。
思考整理
ActiveRecord::Relation#to_a
# show-source ActiveRecord::Relation#to_a
# ActiveRecord::Relation
def to_ary
records.dup
end
# show-source ActiveRecord::Relation#records
# ActiveRecord::Relation
def records # :nodoc:
load
@records
end
# show-source ActiveRecord::Relation#load
# ActiveRecord::Relation
def load(&block)
exec_queries(&block) unless loaded?
self
end
# show-source ActiveRecord::Relation#exec_queries
# ActiveRecord::Relation
def exec_queries(&block)
skip_query_cache_if_necessary do
@records =
if eager_loading?
apply_join_dependency do |relation, join_dependency|
if relation.null_relation?
[]
else
relation = join_dependency.apply_column_aliases(relation)
rows = connection.select_all(relation.arel, "SQL")
join_dependency.instantiate(rows, &block)
end.freeze
end
else
klass.find_by_sql(arel, &block).freeze
end
preload_associations(@records) unless skip_preloading_value
@records.each(&:readonly!) if readonly_value
@loaded = true
@records
end
end
- eager_loadingやpreloadはJOIN的なことをしていないのであまり関係無いだろう、ということで見なかったことにすると、 klass.find_by_sql(arel, &block).freeze が本体
- arelとはなにか?
思考整理
思い切って大雑把な仮定を置き、思考を簡略化する
- find_by_sqlにarelが渡されている、ということは、arelとはSQLを表現しているであろう、と推測
- arelは、Relationの状態から作られたSQL文を含んでいるクラスだ、と仮定し、深追いをやめる
- 細かいことは、知りたくなったときに続きから調べればいい
ActiveRecord::Base.find_by_sql
# show-source ActiveRecord::Base.find_by_sql
# ActiveRecord::Querying
def find_by_sql(sql, binds = [], preparable: nil, &block)
result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable)
column_types = result_set.column_types.dup
attribute_types.each_key { |k| column_types.delete k }
message_bus = ActiveSupport::Notifications.instrumenter
payload = {
record_count: result_set.length,
class_name: name
}
message_bus.instrument("instantiation.active_record", payload) do
if result_set.includes_column?(inheritance_column)
result_set.map { |record| instantiate(record, column_types, &block) }
else
# Instantiate a homogeneous set
result_set.map { |record| instantiate_instance_of(self, record, column_types, &block) }
end
end
end
- connection.select_allが中心
- データベースから取得して、最終的には結果からモデルのインスタンスを生成する、という流れを押さえる
# show-source ActiveRecord::Base.connection
# ActiveRecord::ConnectionHandling
def connection
retrieve_connection
end
# show-source ActiveRecord::Base.retrieve_connection
# ActiveRecord::ConnectionHandling
def retrieve_connection
connection_handler.retrieve_connection(connection_specification_name)
end
- コネクションハンドラというものにコネクションの取得を移譲している
# show-source ActiveRecord::Base.connection_handler
# ActiveRecord::Base
def self.connection_handler
Thread.current.thread_variable_get("ar_connection_handler") || default_connection_handler
end
- スレッド単位でハンドラは存在している
- ちょっと面倒くさそうなので、名称からクラスだけ特定する
- ActiveRecord::ConnectionAdapters::ConnectionHandlerというのがあるのでこれだと思っておく
思考整理
ActiveRecord::ConnectionAdapters::ConnectionHandler#retrieve_connection
# show-source ActiveRecord::ConnectionAdapters::ConnectionHandler#retrieve_connection
# ActiveRecord::ConnectionAdapters::ConnectionHandler
def retrieve_connection(spec_name) #:nodoc:
pool = retrieve_connection_pool(spec_name)
unless pool
# multiple database application
if ActiveRecord::Base.connection_handler != ActiveRecord::Base.default_connection_handler
raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found for the '#{ActiveRecord::Base.current_role}' role."
else
raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found."
end
end
pool.connection
end
# コネクションプールというものがあって、そこからコネクションを取ってきている。どこかで聞いたことがある気がするなあ
# show-source ActiveRecord::ConnectionAdapters::ConnectionHandler#retrieve_connection_pool
# ActiveRecord::ConnectionAdapters::ConnectionHandler
def retrieve_connection_pool(spec_name)
owner_to_pool.fetch(spec_name) do
# Check if a connection was previously established in an ancestor process,
# which may have been forked.
if ancestor_pool = pool_from_any_process_for(spec_name)
# A connection was established in an ancestor process that must have
# subsequently forked. We can't reuse the connection, but we can copy
# the specification and establish a new connection with it.
establish_connection(ancestor_pool.spec.to_hash).tap do |pool|
pool.schema_cache = ancestor_pool.schema_cache if ancestor_pool.schema_cache
end
else
owner_to_pool[spec_name] = nil
end
end
end
# owner_to_poolはもはやわからないけど、establish_connectionの命名的に言ってここでプールを作っていそうだと考える
# show-source ActiveRecord::ConnectionAdapters::ConnectionHandler#establish_connection
# ActiveRecord::ConnectionAdapters::ConnectionHandler
def establish_connection(config)
resolver = ConnectionSpecification::Resolver.new(Base.configurations)
spec = resolver.spec(config)
remove_connection(spec.name)
message_bus = ActiveSupport::Notifications.instrumenter
payload = {
connection_id: object_id
}
if spec
payload[:spec_name] = spec.name
payload[:config] = spec.config
end
message_bus.instrument("!connection.active_record", payload) do
owner_to_pool[spec.name] = ConnectionAdapters::ConnectionPool.new(spec)
end
owner_to_pool[spec.name]
end
# ここも全然わからないけど、文脈を考えれば結果として ActiveRecord::ConnectionAdapters::ConnectionPool というクラスが得られるのだろうと予想できる
# show-source ActiveRecord::ConnectionAdapters::ConnectionPool#connection
# ActiveRecord::ConnectionAdapters::ConnectionPool
def connection
@thread_cached_conns[connection_cache_key(current_thread)] ||= checkout
end
def checkout(checkout_timeout = @checkout_timeout)
checkout_and_verify(acquire_connection(checkout_timeout))
end
def acquire_connection(checkout_timeout)
# :
if conn = @available.poll || try_to_checkout_new_connection
conn
else
reap
@available.poll(checkout_timeout)
end
end
# 辛いけど追いかける..
def try_to_checkout_new_connection
# first in synchronized section check if establishing new conns is allowed
# and increment @now_connecting, to prevent overstepping this pool's @size
# constraint
do_checkout = synchronize do
if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @size
@now_connecting += 1
end
end
if do_checkout
begin
# if successfully incremented @now_connecting establish new connection
# outside of synchronized section
conn = checkout_new_connection
ensure
synchronize do
if conn
adopt_connection(conn)
# returned conn needs to be already leased
conn.lease
end
@now_connecting -= 1
end
end
end
end
def checkout_new_connection
raise ConnectionNotEstablished unless @automatic_reconnect
new_connection
end
def new_connection
Base.send(spec.adapter_method, spec.config).tap do |conn|
conn.check_version
end
end
# ようやくそれっぽい所まで来た
# しかしspecとはなにか。もはや分からないのでspec adapter_methodググると、specがdatabase.ymlの情報に相当する ActiveRecord::ConnectionAdapters::ConnectionSpecification になるらしい
# show-source ActiveRecord::ConnectionAdapters::ConnectionSpecification#adapter_method
attr_reader :name, :config, :adapter_method
# この場合はクラス全体を見ないと追うのは難しいので、クラス全体をざっと眺める
# ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver#spec というのを見ると..
def spec(config)
pool_name = config if config.is_a?(Symbol)
spec = resolve(config, pool_name).symbolize_keys
raise(AdapterNotSpecified, "database configuration does not specify adapter") unless spec.key?(:adapter)
# Require the adapter itself and give useful feedback about
# 1. Missing adapter gems and
# 2. Adapter gems' missing dependencies.
path_to_adapter = "active_record/connection_adapters/#{spec[:adapter]}_adapter"
begin
require path_to_adapter
rescue LoadError => e
# We couldn't require the adapter itself. Raise an exception that
# points out config typos and missing gems.
if e.path == path_to_adapter
# We can assume that a non-builtin adapter was specified, so it's
# either misspelled or missing from Gemfile.
raise LoadError, "Could not load the '#{spec[:adapter]}' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace
# Bubbled up from the adapter require. Prefix the exception message
# with some guidance about how to address it and reraise.
else
raise LoadError, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace
end
end
adapter_method = "#{spec[:adapter]}_connection"
unless ActiveRecord::Base.respond_to?(adapter_method)
raise AdapterNotFound, "database configuration specifies nonexistent #{spec[:adapter]} adapter"
end
ConnectionSpecification.new(spec.delete(:name) || "primary", spec, adapter_method)
end
# と書いてあって、このadapter_methodと等しいだろうと推定する
# specはdatabase.ymlそのものだったので、adapter: postgresqlであれば、postgresql_connectionという名前だといえる。
# また、requireを見るに、動的にクラスをロードしているのだろうと察しが付く
#
# 以下はruby-pg gemを入れないと進めない。
# require "active_record/connection_adapters/postgresql_adapter"
#
# ここはコードを見にいけば
# PostgreSQLAdapter < AbstractAdapter となっていることがわかる
整理
だいたい流れが分かったのでこのへんで終了
まとめ
コードを追いかけるときの注意点
- 全部一回で追わないように、大雑把に仮説を立てながら重要な所から進める
- どうしても細かいところを調べないと気が済まなくなることがあるが、強い気持ちで押さえる
- 仮説をたてるときは、文脈や単語の意味合い、ロジックを見て役割や結果を推測する
- 単語は共通の意味を持っていることが多い。言語にとらわれず、一歩引いて抽象的な役割を考える
コードを追うことで得られること
- 追えないだろうと思ったとき"こそ"コメントを活用する
- 追いやすいコードを自分でも書けるようになる
- 追いにくいケース
- インスタンス変数
- メモ化やキャッシュのような遅延的代入は追いやすい
- initializeあたりで初期化されると予測する
- メタプロ
- なるべくシンプルで分かりやすい規約にとどめるべき
- インスタンス変数
- 追いにくいケース
- 実際に使われている実装のパターンを習得できる
- クラスやモジュールの切り分け
- 単一ファイルにどの程度クラスを置くか
その他
- Ruby2.*には型がないが、返り値の型が分かる場合には、ここまで追わなくても分かっただろうな、と感じる (3に期待)
- とりあえずpryが便利