ActiveRecordのpreloadメソッドを理解する
本記事は「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 の歩み方」というスライドを見つけました。
次の流れでコードを読んでいるようなので、今回はこの手順を真似て読んでいくことにしました。
- 目的のメソッドを決める
- 目的メソッドを動かすコードを書く
- デバッガーを仕込んで
- ステップ実行しつつ読む!
RailsのActiveRecordバグレポートを参考にして次のようなpreloadを実行するコードを書き、デバッガーを仕込み読んでいきます。
(ソースコードはここにあります https://github.com/nasa-playground/active_record_code_reading/blob/main/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.preload
とPost.all.preload
は同じ動きをします。
module ActiveRecord
module Querying
QUERYING_METHODS = [preload, ...].freeze
delegate(*QUERYING_METHODS, to: :all)
...
end
end
all
の中身までは読みませんが、all
はActiveRecord::Relationクラスを返します。なのでActiveRecord#preload
はActiveRecord::Relation#preload
と等価であることが分かりますね。
それではActiveRecord::Relation#preload
を見ていきましょう。
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はfind
やpreload
などのメソッド呼び出し時に順次クエリを発行するのではなく、DBのレコードが必要になったタイミングで一気にクエリを発行してレコードを取得します。
単にpreload
を呼び出しただけではクエリのコンテキストを保持する以外のことをやらないようで、まあそうだよなーという感じですね。
クエリ発行のエントリーポイントを探す
preload
を呼び出しても実際にデータを取りに行かない事が分かったので、データを取りに行くコードを書いて動作を追っていきます。
find
やwhere
をしてクエリを発行してもよいのですが、余計な処理がありそうだなと思ったのでinspect
を使うことにしました。(Rails consoleでwhere
とかしてるとクエリが発行されているログが出るので、おそらくinspect
が呼ばれそれによってクエリ発行されているのでは?と思いinspect
を使っています)
irbやRails consoleで表示されるオブジェクト情報はinspect
を呼んでいると思っているが実際どうなんだろうか?
次のコードをステップ実行しつつコードを追っていきます。
posts = Post.preload(:comments)
binding.b # ブレークポイント
posts.inspect
ActiveRecord#inspect
のコードを見てみると2行目にloaded
やrecords
などがあり、かなりDBレコードに近づいた気がしますね。
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
を呼んでいます。
# 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
というやつがあり、ここでクエリが生成および発行されていると見て間違いはないでしょう。
def load(&block)
if !loaded? || scheduled?
@records = exec_queries(&block)
@loaded = true
end
self
end
一旦ここまでで分かったことをまとめます。
- preloadを呼んでもクエリの発行は行わず、クエリ生成に必要な情報を保存するだけ
-
ActiveRecord::Relation#to_a
が呼ばれる時にクエリが発行される - DBから得たレコードはインスタンス変数に保持するので複数回取得しない
-
ActiveRecord::Relation#exec_queries
がクエリ発行のエントリーポイント
ActiveRecord::Relation#exec_queries
を読む
ではメインディッシュになりそうなexec_queries
を見ていきますか。
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
で行われています。
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
を見ていきましょう。
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
が呼ばれています。
def load_records_for_keys(keys, &block)
scope.where(association_key_name => keys).load(&block)
end
久しぶりに見たことあるメソッドが現れましたね。load
は前述したActiveRecord::Relation#load
でexec_queries
を呼び出し実際のレコード取得を行う君です。preload
でも特段レコード取得の方法は変わらずload
を使うようですね。
ここでwhere
の引数となっているassociation_key_name
とkeys
にはどのような値が入っているのでしょうか?ここではメソッドを追っていくのではなくどのような値が入っているのかをみて雰囲気を掴むだけにします。
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