💎

ActiveRecordのpreloadメソッドを理解する

2021/12/24に公開

本記事は「Wantedly 新卒 Advent Calendar 2021」の24日目の記事です。アドカレも大詰めですね〜
ネタがない!ということでActiveRecordのpreloadメソッドでも読んでいこうと思います。

はじめに

ActiveRecordはRubyのORMの一つです。

ActiveRecordにはpreloadというメソッドがあり一般的にはN+1回避策として使われています。
次のようにpreloadするとPostに紐づくcommentを取得するselectが発行されます。

posts = Post.preload(:comments)
# Post Load (0.0ms)  SELECT "posts".* FROM "posts" /* loading for inspect */ LIMIT ?  [["LIMIT", 11]]
# Comment Load (0.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?)  [["post_id", 1], ["post_id", 2]]

このpreloadがどのように動いているのか気になったので見ていこうと思います。

準備

先駆者がActiveRecordの設計や主要クラスをまとめてないかなーと思い適当に調べていると「ActiveRecord の歩み方」というスライドを見つけました。

https://speakerdeck.com/osyo/activerecord-falsebu-mifang

次の流れでコードを読んでいるようなので、今回はこの手順を真似て読んでいくことにしました。

  1. 目的のメソッドを決める
  2. 目的メソッドを動かすコードを書く
  3. デバッガーを仕込んで
  4. ステップ実行しつつ読む!

RailsのActiveRecordバグレポートを参考にして次のようなpreloadを実行するコードを書き、デバッガーを仕込み読んでいきます。

(ソースコードはここにあります https://github.com/nasa-playground/active_record_code_reading/blob/main/main.rb)

main.rb

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activerecord",  :path => '/Users/asan/lab/oss/rails'
  gem "sqlite3"
  gem "debug", github: "ruby/debug"
end

require "active_record"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

p = Post.create!
Comment.create!(post_id: p.id)
Comment.create!(post_id: p.id)
Comment.create!(post_id: p.id)

binding.b # ブレークポイント
posts = Post.preload(:comments)
p posts

このコードでは次のことをやっています。

  • Gem install (active recordやdebugger)
  • テーブル定義
  • preload実験用にレコード作成
  • preloadする

余談ですが、.rbファイルにGemfileを記述できることや、mysqlなどのRDBを用意せずともmemoryをDBにしてActiveRecordのコードを動かせることは知りませんでした。

Preloadを読む

まずはpreloadの在り処を調べる。

Post.method(:preload).source_location
=> ["/Users/asan/lab/oss/rails/activerecord/lib/active_record/querying.rb", 22]

ActiveRecord::Queringというモジュールに定義されているようですね。
実装を見てみるとpreloadはallに移譲されていることが分かります。
つまりPost.preloadPost.all.preloadは同じ動きをします。

activerecord/lib/active_record/querying.rb
module ActiveRecord
  module Querying
    QUERYING_METHODS = [preload, ...].freeze
    delegate(*QUERYING_METHODS, to: :all)
    ...
  end
end

allの中身までは読みませんが、allはActiveRecord::Relationクラスを返します。なのでActiveRecord#preloadActiveRecord::Relation#preloadと等価であることが分かりますね。
それではActiveRecord::Relation#preloadを見ていきましょう。

lib/active_record/relation/query_methods.rb
module ActiveRecord
  module QueryMethods
    ...
    def preload(*args)
      # 引数が空だった場合にRaiseする君
      check_if_method_has_arguments!(__callee__, args)

      # spawnはpreloadのレシーバーを返してくれる君。今回はPost
      spawn.preload!(*args)
    end

    def preload!(*args)
      # args(今回の例では [:comments])を代入して終わり
      self.preload_values |= args
      self
    end
    ...
  end
end

実態はpreload!にあるようです。このメソッドではpreload_valuesというインスタンス変数に引数を入れて終わりですね。

意外と何もしないんだなーと思いましたが、ActiveRecordはfindpreloadなどのメソッド呼び出し時に順次クエリを発行するのではなく、DBのレコードが必要になったタイミングで一気にクエリを発行してレコードを取得します。

単にpreloadを呼び出しただけではクエリのコンテキストを保持する以外のことをやらないようで、まあそうだよなーという感じですね。

クエリ発行のエントリーポイントを探す

preloadを呼び出しても実際にデータを取りに行かない事が分かったので、データを取りに行くコードを書いて動作を追っていきます。

findwhereをしてクエリを発行してもよいのですが、余計な処理がありそうだなと思ったのでinspectを使うことにしました。(Rails consoleでwhereとかしてるとクエリが発行されているログが出るので、おそらくinspectが呼ばれそれによってクエリ発行されているのでは?と思いinspectを使っています)

irbやRails consoleで表示されるオブジェクト情報はinspectを呼んでいると思っているが実際どうなんだろうか?

次のコードをステップ実行しつつコードを追っていきます。

posts = Post.preload(:comments)
binding.b # ブレークポイント
posts.inspect

ActiveRecord#inspectのコードを見てみると2行目にloadedrecordsなどがあり、かなりDBレコードに近づいた気がしますね。

lib/active_record/relation.rb
def inspect
  # ここがDBに近づいた雰囲気を出している
  subject = loaded? ? records : annotate("loading for inspect")
  entries = subject.take([limit_value, 11].compact.min).map!(&:inspect)

  entries[10] = "..." if entries.size == 11

  "#<#{self.class.name} [#{entries.join(', ')}]>"
end

loaded?は一度レコードを取得するとtrueにセットされるインスタンス変数です。
取得したレコードはrecordsに保持するようになっているので、二度目の呼び出し以降はrecordsを使うようになっています。annotateは実行コンテキストをインスタンス変数に保存し、レシーバ(ActiveRecord::Relation)を返すメソッドになっています。

肝心のクエリの生成やレコードの取得は二行目のtakeで行われています。

このメソッドの詳細は省きますが、Arrayのtakeと同じような振る舞いをします。このtakeの中でActiveRecord::Relation#to_aが呼ばれArrayに変換しているのですが、このto_aではrecordsといういかにも怪しいメソッドを呼んでおり、recordsではloadを呼んでいます。

activerecord/lib/active_record/relation.rb
# Converts relation objects to Array.
def to_ary
  records.dup
end
alias to_a to_ary

def records # :nodoc:
  load
  @records
end

loadメソッドを見るとexec_queriesというやつがあり、ここでクエリが生成および発行されていると見て間違いはないでしょう。

lib/active_record/relation.rb
def load(&block)
  if !loaded? || scheduled?
    @records = exec_queries(&block)
    @loaded = true
  end

  self
end

一旦ここまでで分かったことをまとめます。

  1. preloadを呼んでもクエリの発行は行わず、クエリ生成に必要な情報を保存するだけ
  2. ActiveRecord::Relation#to_aが呼ばれる時にクエリが発行される
  3. DBから得たレコードはインスタンス変数に保持するので複数回取得しない
  4. ActiveRecord::Relation#exec_queriesがクエリ発行のエントリーポイント

ActiveRecord::Relation#exec_queriesを読む

ではメインディッシュになりそうなexec_queriesを見ていきますか。

lib/active_record/relation.rb
def exec_queries(&block)
  skip_query_cache_if_necessary do
    rows = if scheduled?
      future = @future_result
      @future_result = nil
      future.result
    else
      exec_main_query
    end

    records = instantiate_records(rows, &block)
    preload_associations(records) unless skip_preloading_value

    records.each(&:readonly!) if readonly_value
    records.each(&:strict_loading!) if strict_loading_value

    records
  end
end

ここまでと比べコード量が増えましたね。ここからは詳細には立ち入らず雰囲気を理解していきましょう。

重要なところは8行目のexec_main_queryと11行目のpreload_associationsです。

exec_main_queryではその名の通りメインクエリが発行されています。
今回実行しているコードではPostモデルとCommentモデルのレコードを取得しています。このときPostモデルをレシーバとしてpreloadを呼び出していますが、このPostのレコードを取得するクエリがメインクエリになっています。
本記事ではメインクエリはメインディッシュではないので読み飛ばしてしまおうと思います。どのようにクエリが組み立てられているか興味はありますがまたの機会にでも。。。

posts = Post.preload(:comments)
# Post Load (0.0ms)  SELECT "posts".* FROM "posts" /* loading for inspect */ LIMIT ?  [["LIMIT", 11]]
# Comment Load (0.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?)  [["post_id", 1], ["post_id", 2]]

Postに関連するcommentsを取得する処理はpreload_associationsで行われています。

lib/active_record/relation.rb
def preload_associations(records) # :nodoc:
  # 懐かしのpreload_valuesが出てきた
  preload = preload_values
  preload += includes_values unless eager_loading?
  scope = strict_loading_value ? StrictLoadingScope : nil
  preload.each do |associations|
    ActiveRecord::Associations::Preloader.new(records: records, associations: associations, scope: scope).call
  end
end

preload_associationsを見ると懐かしのpreload_valuesが出てきていますね。preloadメソッドを呼び出したときにはアソシエーション情報がpreload_valuesに格納されていましたが、ここでついにその情報を使います。

ActiveRecord::Associations::Preloader#callを呼び出していますね。ここからはこのPreloaderを見ていきます。

ActiveRecord::Associations::Preloaderを読む

このPreloaderは複数のサブクラスを組み合わせてアソシエーションの取得を行います。コードを見ていってもサブクラスの関連性や全体像が分かりづらそうなので、テキストと図で説明していきます。

まずはどのようなサブクラスがあるか、また責務は何かをまとめます。

class名 責務
Preloader 後述するサブクラスを組み合わせてアソシエーション取得を行う
Branch オブジェクトの親子関係(今回の例だとPost, Commentの親子関係)を保持する。また、関係を元にAssotiactionオブジェクトを生成する。
Batch Brahchオブジェクトが返したAssotiationオブジェクトを呼び出す。レコードを取得済みかみて不要な取得を行わないようにする責務も持つ。
Association 各Assotiactionのローダー。クエリを発行し取得したレコードを保持する役目を持つ

Preloaderの初期化からPreloader#callを図にすると次のようになります。

BranchやBatchは関係を構成したり、ローダーを適切に呼び出すなど重要な要素ではあるのですが、やや複雑なので今回は読み飛ばしてしまおうと思います。
Association#load_records_in_batchによりレコードの取得をしているのでここを見ていきましょう。

Association#load_records_in_batch

では実際にアソシエーションレコードの取得を行っているload_record_in_batchを見ていきましょう。

lib/active_record/relations/preloader/association.rb
def load_records_in_batch(loaders)
  raw_records = records_for(loaders)

  loaders.each do |loader|
    loader.load_records(raw_records)
    loader.run
  end
end

def records_for(loaders)
  # ここでAssociation用のloaderを組み立てて`records`でレコードの取得を行っている。
  LoaderRecords.new(loaders, self).records
end

2行目のrecords_forでクエリ発行が行われています。records_forではLoaderRecordsクラスをインスタンス化しrecordsメソッドを呼びレコードの取得を行っています。LoadRecordsについては後に説明します。

3行目以降の処理はここで取得したレコードと親オブジェクト(Post)のレコードの紐付けを行っているようです。取得したアソシエーションレコードはpreloaded_recordsというインスタンス変数に保持しておき 、複数回レコード取得を行わないためにこのpreloaded_recordsに該当レコードが存在するかを見てたりします。

次にレコード取得を実際に行っているLoadRecords#recordsですが、これはload_records_for_keysをいうメソッドを呼び出しており、ここではActiveRecord#relation#whereが呼ばれています。

lib/active_record/relations/preloader/association.rb
def load_records_for_keys(keys, &block)
  scope.where(association_key_name => keys).load(&block)
end

久しぶりに見たことあるメソッドが現れましたね。loadは前述したActiveRecord::Relation#loadexec_queriesを呼び出し実際のレコード取得を行う君です。preloadでも特段レコード取得の方法は変わらずloadを使うようですね。

ここでwhereの引数となっているassociation_key_namekeysにはどのような値が入っているのでしょうか?ここではメソッドを追っていくのではなくどのような値が入っているのかをみて雰囲気を掴むだけにします。

load_records_for_keysにデバッガーを仕込み、次のコードを用いて値を見てみましょう

p1 = Post.create!
p2 = Post.create!

Comment.create!(post_id: p1.id)
Comment.create!(post_id: p2.id)

posts = Post.preload(:comments)
posts.to_a
    38|           def load_records_for_keys(keys, &block)
=>  39|             binding.b
    40|             scope.where(association_key_name => keys).load(&block)
    41|           end
    42|         end
    43|
=>#0	ActiveRecord::Associations::Preloader::Association::LoaderQuery#load_records_for_keys(keys=#<Set: {1, 2}>, block=#<Proc:0x00000001257cc448 /Users/asan/la...) at ~/lab/oss/rails/activerecord/lib/active_record/associations/preloader/association.rb:39
  #1	ActiveRecord::Associations::Preloader::Association::LoaderRecords#load_records at ~/lab/oss/rails/activerecord/lib/active_record/associations/preloader/association.rb:76
  # and 18 frames (use `bt' command for all frames)
(rdbg) association_key_name    # ruby
"post_id" # Postモデルと関連付けられているカラム名が入っている
(rdbg) keys
#<Set: {1, 2}> # Postのid、ここではPostの絞り込みを行っていないので全件入っている(postレコードは2つ作っているのでそれぞれid 1,2)
(rdbg)

これらのカラム名やPostのidがどのようにしてここで解決されているのかまでは追えませんでしたが、Post.preload(:comments)としたときには実際には次のコードに相当する処理が内部で行われているようですね。そしてここで取得したレコードをいい感じに保持しつつ複数回のローディングを減らしたりbatch loadしてN+1を減らしたりしていることが分かりました。

Comment.where("post_id" => [Post.all.pluck(:id)])

まとめ

後半駆け足になってしまいましたが、ここまででpreload時の大まかな動作は分かった気がします。読者の皆さんに少しでも学びがあれば嬉しいです!

ActiveRecord#RelationやActiveRecord::Associationの内部構造(レコードやアソシエーションをどのように解決、保持しているか)は読み飛ばしてしまったので今後説明するかもしれません。

最後までお付き合いありがとうございました〜。

Discussion