はじめに
ポートフォリオ作成中の学びを記載します。
記事を読んでいただくことで次の2点をお伝えできると思います。
- eager_loadによるN+1の解決
- scopeによるクエリ文の可読性向上
やりたいこと
動作環境
Ruby: 3.1.2
Ruby on Rails: 6.1.7
関連の確認
アソシエーションは次の通りです。
実装してみる
泣けなしの知識で早速実装します。動作しますが、問題はController(Modelも)にあります。お分かりでしょうか。
Model
class Event < ApplicationRecord
has_many :hosted_dates
end
class HostedDate < ApplicationRecord
belongs_to :event
end
Controller
joins
とselect
によって関連先レコードを取得できそうだぞ、と鼻息が荒くなっています。
注意:このコードは危険です。絶対に真似しないでください。
class EventController < ApplicationController
def index
@events = Event.joins(:hosted_dates).select('hosted_dates.*, events_title, events_discription').where(is_published: true).order(:date_at)
end
end
View
Controllerで取得した@events
内からdate_at
を取り出し表示します。
<% @events.each do |event| %>
<%= link_to event do %>
<h1><%= event.title %></h1>
<p><%= event.discription %></p>
<ul>
<% event.hosted_dates.each do |date| %>
<li><%= l.(date.date_at, format: :long) %></li>
<% end %>
</ul>
<% end %>
<% end %>
何が問題なのか
上記コードでも動作する分に問題ありませんが次の問題があります。
- SQLが悲惨(N+1問題)
- Contollerにロジックを書いており、可読性が悪い
SQLが悲惨(N+1問題)
サーバーを走らせ、一覧を取得したときのSQLログは次の通りです。見事にN+1回のクエリが発生しています。
サーバーログからみるSQL
Processing by EventController#index as HTML
Rendering layout layouts/application.html.erb
Rendering events/index.html.erb within layouts/application
Event Load (1.6ms) SELECT "events".* FROM "events" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC [["is_published", true]]
↳ app/views/events/index.html.erb:4
HostedDate Load (3.6ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 15], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.5ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 14], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 14], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.3ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 13], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 13], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 12], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 12], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (0.9ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 11], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 11], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 10], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 10], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 9], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.1ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 8], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
CACHE HostedDate Load (0.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 8], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (1.0ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 7], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (0.8ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 4], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (0.7ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 3], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (0.7ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 2], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
HostedDate Load (0.7ms) SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2 [["event_id", 1], ["LIMIT", 1]]
↳ app/views/events/index.html.erb:8
Rendered events/index.html.erb within layouts/application (Duration: 47.6ms | Allocations: 19073)
略
Completed 200 OK in 69ms (Views: 48.0ms | ActiveRecord: 17.9ms | Allocations: 30484)
どうやらView内の次のeach文で繰り返したN回分のクエリが発生しています。今回はレコード件数が少ないこともあり処理時間は69msで済んでいますが、レコードが増えてアクセス数も増えるとサーバーはダウンしてしまうでしょう。
<% event.hosted_dates.each do |date| %>
<li><%= l(date.date_at, format: :long) %></li>
<% end %>
N+1問題の解決策としてeager_loadする
ActiveRecordが提供しているメソッドとして、 preload
, eager_load
, includes
があります。それぞれ微妙に挙動が違いますが、アソシエーションを結合してテーブルを作成しActiveRecordのキャッシュメモリに情報を保持することにより、クエリ発行数を1, 2回に収めて高速通信を実現するしくみです。
最初のjoins
とselect
も結合しアソシエーションで条件を絞ることができますが、関連先レコードを保持することができません。
今回はアソシエーションで条件を絞りたかったのでeager_load
を選択しました。次のように使用することで関連先のレコードを保持したままインスタンスを作成することができます。これより先ほどのViews内のeach文においても追加でSQLが発生することはありません。
@events = Event.eager_load(:hosted_dates).where(is_published: true).order(date_at: :desc)
Contollerにロジックを書いており、可読性も悪い
eager_load
により一件落着。と言いたいところですが、まだ残っている問題としてはControllerにロジックを持たせているところです。
ControllerはユーザーとMode, Viewを繋ぐ指令等のため、できるだけ薄くシンプルにし、今回のようなロジックはModelに持たせるべきと言えます。
また、今回の例ではwhere
やorder
などのクエリメソッドは読みやすい方ではありますが、条件が複雑になってくると可読性に乏しくなることでしょう。
scopeによりクエリ条件のエイリアスを作る
Model内にscope
メソッドを定義し、第一引数にシンボルを使ったエイリアス名、第二引数にアロー記法を使ってクエリ文を渡します。
class Event < ApplicationRecord
scope :published, -> { where(is_published: true) }
scope :sorted, -> { order(updated_at: :desc) }
End
次のようなコマンドでクエリを出すことができます。とても直感的でわかりやすいですよね。
# Event.where(is_published: true)と等価
Event.published
# Event.order(updated_at: :desc)と等価
Event.sorted
また定義したscopeを再利用することも可能です。
略
scope :recent, -> { pulished.sorted }
# Event.where(is_published: true).order(updated_at: :desc)と等価
Event.recent
注意点としてscope
による検索結果が存在しない場合あっても、必ずActiveRecord::Relation
を返します。クラスメソッドでも同じような実装が可能ですが存在しない場合はnil
が返されます。要件によって使い分ける必要がありそうですね。
改善後
修正したコードは次の通りになります。(Viewに変化はありません)
Modelにロジックを持たせたことで、Controllerの見通しも良く直感的になりました。
Model
class Event < ApplicationRecord
has_many :hosted_dates
scope :with_dates, -> { eager_load(:hosted_dates) }
scope :published, -> { where(is_published: true) }
scope :sorted, -> { order(date_at: :desc) }
scope :with_recent_dates, -> { with_dates.published.sorted }
end
Controller
class EventController < ApplicationController
def index
@events = Event.with_recent_dates
end
end
SQL削減結果
SQL発行数も1件となりスッキリ解決です。処理時間も69msから39msと43%削減することができました!
Processing by EventController#index as HTML
Rendering layout layouts/application.html.erb
Rendering events/index.html.erb within layouts/application
SQL (2.4ms) SELECT "events"."id" AS t0_r0, "events"."owner_id" AS t0_r1, "events"."name" AS t0_r2, "events"."place" AS t0_r3, "events"."title" AS t0_r4, "events"."discription" AS t0_r5, "events"."thumbnail_url" AS t0_r6, "events"."price" AS t0_r7, "events"."required_time" AS t0_r8, "events"."is_published" AS t0_r9, "events"."capacitiy" AS t0_r10, "events"."created_at" AS t0_r11, "events"."updated_at" AS t0_r12, "hosted_dates"."id" AS t1_r0, "hosted_dates"."event_id" AS t1_r1, "hosted_dates"."start_at" AS t1_r2, "hosted_dates"."end_at" AS t1_r3, "hosted_dates"."created_at" AS t1_r4, "hosted_dates"."updated_at" AS t1_r5 FROM "events" LEFT OUTER JOIN "hosted_dates" ON "hosted_dates"."event_id" = "events"."id" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC [["is_published", true]]
↳ app/views/events/index.html.erb:4
Rendered events/index.html.erb within layouts/application (Duration: 14.8ms | Allocations: 4094)
略
Completed 200 OK in 39ms (Views: 32.1ms | ActiveRecord: 3.8ms | Allocations: 15336)
最後に
ポートフォリオは実際にユーザーに使用いただく予定のためパフォーマンスは重要です。
また、改善を続けていけるように可読性・保守性も考慮する必要があります。
引き続きこれらを心がけて実装を進めていきます。
参考
Railsガイド Active Record クエリインターフェイス
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
[Rails]モデルのscopeメソッド