LoginSignup
2
2

More than 3 years have passed since last update.

ActiveRecordのコードを追う

Posted at
1 / 26

テーマ

コードを追うための考え方と、追うことで得られることを、実際に追ってみることで考えてみる

(↑をまとめつつ、ついでに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

というわけで本題


思考整理

Frame 1.png


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の部分になる

思考整理

Frame 2.png


命名などからおおよその仮説を立てる

  • spawn はcopy, clone, fork的な意味で広く使われるので、おそらくRelationインスタンスのコピーだろう、ぐらいに思っておく
  • where! の!マークはRubyでは破壊的メソッドに使われる。コピーしたものを破壊すれば元のものには影響がない。
    • つまり、 コピー機能(spawn) と 破壊的操作(where!) を使って 非破壊バージョン(where) を構築しているのだ、と考える

思考整理

Frame 3.png


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

思考整理

Frame 4.png


コード上の同値性を抑えつつ書き換えて整理する

Frame 5.png


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_valueorder_valuesという状態が書き換えられていると分かった。

思考整理

Frame 6.png


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_loadingpreloadJOIN的なことをしていないのであまり関係無いだろう、ということで見なかったことにすると、 klass.find_by_sql(arel, &block).freeze が本体
- arelとはなにか?

思考整理

Frame 7.png


思い切って大雑把な仮定を置き、思考を簡略化する

  • 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というのがあるのでこれだと思っておく

思考整理

Frame 8.png


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 となっていることがわかる

整理

Frame 9.png


だいたい流れが分かったのでこのへんで終了


まとめ

コードを追いかけるときの注意点

  • 全部一回で追わないように、大雑把に仮説を立てながら重要な所から進める
    • どうしても細かいところを調べないと気が済まなくなることがあるが、強い気持ちで押さえる
  • 仮説をたてるときは、文脈や単語の意味合い、ロジックを見て役割や結果を推測する
    • 単語は共通の意味を持っていることが多い。言語にとらわれず、一歩引いて抽象的な役割を考える

コードを追うことで得られること

  • 追えないだろうと思ったとき"こそ"コメントを活用する
  • 追いやすいコードを自分でも書けるようになる
    • 追いにくいケース
    • インスタンス変数
      • メモ化やキャッシュのような遅延的代入は追いやすい
      • initializeあたりで初期化されると予測する
    • メタプロ
      • なるべくシンプルで分かりやすい規約にとどめるべき
  • 実際に使われている実装のパターンを習得できる
    • クラスやモジュールの切り分け
    • 単一ファイルにどの程度クラスを置くか

その他

  • Ruby2.*には型がないが、返り値の型が分かる場合には、ここまで追わなくても分かっただろうな、と感じる (3に期待)
  • とりあえずpryが便利
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2