概要
Ruby on Rails7で突然登場した以下の記述を見てどんな処理をされているか想像できなかったため、調べた内容に沿って自分なりの理解をまとめました。
class BoardsController < ApplicationController
def index
@boards = Board.includes(:user)
end
end
TL;DR
ユーザー:userに関連する掲示板の投稿内容の検索結果が@boardsになっている。
N+1問題回避のため、アソシエーションで設定した内容に沿って適切なクエリ発行をRails側でやってくれている。
調べたこと
どのようにこの問題に対処するか悩んでいた時に、ディープコードリーディングという考え方を見つけました。この考え方に関心し、この考え方を実践して以下の通りに調べていきました。
現状(As is)と理想(To be)の把握
As is
@boards = Board.includes(:user)で、includesメソッドによってどのようなふるまいがされているかがわからない。
To be
- As isで示したコードによって発行されるクエリは何なのか
- includesメソッドによって何が出来るのか
AIに発行されるクエリは何なのか聞く
学習中にアソシエーションを設定した例で類似した記述があったため、まずはそれと対比させてAIに聞いてみた:
質問:RailsでUserクラスとBoardクラスが1対多のアソシエーションが張られている時、以下a.rbとb.rbの動きは同等と考えていいでしょうか
@board = Board.find(params[:id])
@user = @board.user
@boards = Board.includes(:user)
ざっくり言うと
違います。a.rb
Board.find(params[:id]) → 1件だけ取得@board.user → その Board の User を遅延読み込み(必要になったらもう 1 件の SELECT が走る)
b.rb
Board.includes(:user) → 全ての Board と関連する User をまとめて(または分けて)取得(何も id を指定していないので、@boards は すべてのレコード になります)
なぜ同等ではないか
項目 a.rb b.rb 対象レコード数 1 件 (params[:id] の Board) すべての Board(Board.all と同じ) クエリ数 SELECT * FROM boards WHERE id = ? → 1
SELECT * FROM users WHERE id = ? → 1> (@board.user を呼んだ瞬間に実行)2 本 (Rails のデフォルトは「boards 用 + users 用」)
※条件付きで JOIN に変わる場合もある使用目的 単一の Board とその User を取得したい時 複数の Board を表示しつつ、各 Board の User を N+1 問題なく使いたい時
比較用のa.rbは、「params[:id]で受け取った値であるboards.idを1件検索して、そのidでusersを取得」をする。クライアント画面で一覧表示された投稿内容をクリックしたら、その投稿に対応したidをHTTPメソッドGETでルーティングされて受け取った値と想定される。
しかし、今回の問題であるb.rbがいまいち理解できておりませんでした。AIから得たクエリとしては以下の通りでした:
実行されるクエリ
SELECT * FROM boards
SELECT * FROM users WHERE id IN (...)
この時点で すべての Boards と関連 User が取得済み。後で @boards.each { |b| b.user } しても追加クエリは発生しません。
全てのBoardを1本目のクエリで取得し、関連するUserのレコードを全件取得している。
この呼び出し方にすることで、N+1問題を回避します。回避することで、クエリ発行を減らして実行時間が減りパフォーマンス低下を防ぐ効果があります。N+1問題と回避するメソッドは以下記事でも紹介されております。
ここで関係性を整理するためにER図と二つのテーブル間のアソシエーションを整理しました。
ER図とアソシエーション
UserテーブルとBoardテーブルのER図を示すと以下の通り。
UserとBoardは、1対多の関係にある。あるUserは複数の投稿をするためUserテーブル側でアソシエーションhas_many: :boardsを設定する。一方、Boardsテーブルから見ると同一Userが複数いて従属関係にあるためアソシエーションbelongs_to: :userを設定している。
ディープコードリーディング開始
問題の1行からわかること
ここまでの内容を以て、@boards = Board.includes(:user) を見ていく。
右辺のクラス名がBoardとなっているので、対応するモデルクラスboard.rbでincludesメソッドを定義していないか辿っていく。見てみると以下の通り:
class Board < ApplicationRecord
validates :title, presence: true, length: { maximum: 255 }
validates :body, presence: true, length: { maximum: 65_535 }
belongs_to :user
end
includesメソッドが無い!しかし、一行目にヒントがある。「BoardクラスはApplicationRecordクラスを継承しています」という意味です。親クラスでincludesメソッドが定義されてれば、継承された子クラスに定義されてなくても機能を引き継げるので問題ない。なので、次は、Rails公式のソースの中のどこにincludesメソッドがあるか突き止めに行く。
AIに追跡ルートを定めるための質問をする
Railsのソースコードをすべて読み込むと時間かかりすぎる!!!そこで、ここでAIに質問してみた:
質問:Rails公式のレポジトリを参照して、ApplicationRecordのincludesメソッドはどこに定義されているか教えてください
ざっくり言うと…
ApplicationRecord は ActiveRecord::Base を継承しています。
includes メソッドは ActiveRecord の Relation(クエリオブジェクト)に定義されている ので、実際には ApplicationRecord.includes(...) と呼ぶと「Relation オブジェクトを作ってその上で includes を呼び出す」形になります。
ソースコードの場所は Rails 本体のリポジトリ → activerecord/lib/active_record/relation/query_methods.rb(もしくは同じファイル内)です。
なんと場所は、activerecord/lib/active_record/relation/query_methods.rbでincludesメソッドがどうやら定義されているとのこと。Rails公式のソースリポジトリにアクセスして、このディレクトリにアクセスする。
includesメソッド定義を突き止める
ブラウザのページ内検索機能(Ctrl+Fキー)で「includes」と入力しdef includes(...)が登場する箇所を探す。すると、250行目にメソッド定義をdef includes(*args)を発見!このメソッド上部に長いコメントがあり内容を抜粋すると以下の通り:
# Specify associations +args+ to be eager loaded to prevent N + 1 queries.
# A separate query is performed for each association, unless a join is
# required by conditions.
#
# For example:
#
# users = User.includes(:address).limit(5)
# users.each do |user|
# user.address.city
# end
#
# # SELECT "users".* FROM "users" LIMIT 5
# # SELECT "addresses".* FROM "addresses" WHERE "addresses"."id" IN (1,2,3,4,5)
...
メソッドのヘッダコメントでincludesメソッド仕様・具体例・具体例に対応したクエリ発行が記載されてる。冒頭の記述より、N+1問題を回避するために、アソシエーション受け取ってeagar loadするとのこと。N+1問題回避で生成された複数のクエリは各々アソシエーションによって実装されている。LEFT OUTER JOINで、1回のクエリで済ませるなら、条件式(conditions)が必要だ と説明されてました。
ここで、AIの回答に続きがありこの説明がありました。
| クラス/モジュール | 主な役割 | 定義場所 |
|---|---|---|
| ApplicationRecord | あなたのモデル基底クラス | app/models/application_record.rb(自分で作ったファイル) |
| ActiveRecord::Base | すべての Active Record モデルの親 | activerecord/lib/active_record/base.rb |
| ActiveRecord::Relation | クエリビルダ/実際に SQL を発行するオブジェクト | activerecord/lib/active_record/relation.rb |
| ActiveRecord::QueryMethods | Relation に付くクエリメソッド(where, order など) | activerecord/lib/active_record/relation/query_methods.rb |
ActiveRecord::Relation内でSQLを発行していること、先ほどのコメントでN+1問題回避でeagar loadをするとあったので、includesとeagar loadが関わるところ見てみると、以下のeagar_loading?メソッドが見つかった:
# Returns true if relation needs eager loading.
def eager_loading?
@should_eager_load ||=
eager_load_values.any? ||
includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end
N+1問題対策で紹介されたメソッドを参照すると、includesメソッドの呼び出しによって、このeagar_loading?メソッドに入ることが見えてきます。モデル間のアソシエーション設定されていて、呼び出し元の宣言内容に応じて、最終的に発行するクエリを変えているというのがincludesメソッドの実態でした。
調査を通して理解したこと
- ソースコードの理解はまず、深く一行で記述された内容を整理する
- 一次情報の公式ドキュメントや公式リポジトリにあたる習慣をつける
- 思考過程で探す手間を省くために、AIに補助してもらう
- Rubyは短く記述出来てラク!でも、一行の重みがデカイので曖昧にしていたらマズい
- ヘッダコメントの冒頭で理解できれば、仕様も理解できる
まとめ
調べてきたことを時系列で追ってまとめました。理解がずれている点がありましたらコメントいただけますと幸いです。