Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

RailsのスコープをプリロードしてN+1クエリを解決する方法

本記事は、Soonsang Hong氏のご協力により韓国語版も公開されています。

Railsのスコープ(scope)を使えば、条件に合ったレコードを簡潔に取得できます。

app/models/review.rb
class Review < ActiveRecord::Base
  belongs_to :restaurant

  scope :positive, -> { where("rating > 3.0") }
end
irb(main):001:0> Restaurant.first.reviews.positive.count
  Restaurant Load (0.4ms)  SELECT  `restaurants`.* FROM `restaurants`  ORDER BY `restaurants`.`id` ASC LIMIT 1
   (0.6ms)  SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
=> 5

しかし、スコープの使い方を誤ると、アプリケーションのパフォーマンスに深刻な悪影響を与えてしまいます。

なぜスコープはプリロードできないのか

理由はシンプルで、スコープはプリロード(preload)できないからです。例えば、複数のレストランとそれぞれの高評価レビューを表示しようとすると、次のようなコードになりがちです。

irb(main):001:0> restaurants = Restaurant.first(5)
irb(main):002:0> restaurants.map do |restaurant|
irb(main):003:1*   "#{restaurant.name}: #{restaurant.reviews.positive.length} positive reviews."
irb(main):004:1> end
  Review Load (0.6ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
  Review Load (0.5ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 2 AND (rating > 3.0)
  Review Load (0.7ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 3 AND (rating > 3.0)
  Review Load (0.7ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 4 AND (rating > 3.0)
  Review Load (0.7ms)  SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`restaurant_id` = 5 AND (rating > 3.0)
=> ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]

ご覧のとおり、レストランの数だけSQLが発行されています。そう、これはN+1クエリ問題です。Railsアプリケーションが遅くなる最大の原因として知られています。

しかし、リレーションの捉え方を少し変えるだけで、この問題は簡単に解決できます。

スコープをアソシエーションに変換する

Railsのアソシエーション(belongs_tohas_many)は、通常次のように定義します。

app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  has_many :reviews
end

ところが、ドキュメントを読んでみると分かるように、これらのメソッドには追加の引数を渡すことで挙動をカスタマイズできます。

その中でも特に便利なのがスコープブロックです。書き方は冒頭のscopeとまったく同じです。

app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  has_many :reviews
  has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review"
end
irb(main):001:0> Restaurant.first.positive_reviews.count
  Restaurant Load (0.2ms)  SELECT  `restaurants`.* FROM `restaurants`  ORDER BY `restaurants`.`id` ASC LIMIT 1
   (0.4ms)  SELECT COUNT(*) FROM `reviews` WHERE `reviews`.`restaurant_id` = 1 AND (rating > 3.0)
=> 5

こうして定義した新しいアソシエーションなら、includesを使ってプリロードできます。

irb(main):001:0> restaurants = Restaurant.includes(:positive_reviews).first(5)
  Restaurant Load (0.3ms)  SELECT  `restaurants`.* FROM `restaurants`  ORDER BY `restaurants`.`id` ASC LIMIT 5
  Review Load (1.2ms)  SELECT `reviews`.* FROM `reviews` WHERE (rating > 3.0) AND `reviews`.`restaurant_id` IN (1, 2, 3, 4, 5)
irb(main):002:0> restaurants.map do |restaurant|
irb(main):003:1*   "#{restaurant.name}: #{restaurant.positive_reviews.length} positive reviews."
irb(main):004:1> end
=> ["Judd's Pub: 5 positive reviews.", "Felix's Nightclub: 6 positive reviews.", "Mabel's Burrito Shack: 7 positive reviews.", "Kendall's Burrito Shack: 2 positive reviews.", "Elisabeth's Deli: 15 positive reviews."]

SQLの発行回数は6回からわずか2回に減りました。

class_nameオプションを使うと、同じモデルに対して複数のアソシエーションを定義できます。このテクニックは実際の開発でも頻繁に役立ちます。)

条件の重複はどう回避する?

ただし、このままではまだ問題が残ります。where("rating > 3.0")という条件がRestaurantモデル側にも書かれてしまうため、後から「高評価」の基準をrating > 3.5に変更した場合、2箇所を修正しなければなりません

さらに厄介なことに、「ユーザーが過去に残したすべての高評価レビュー」を取得したい場合には、Userモデルにも同じスコープを複製する必要が出てきます。

app/models/user.rb
class User < ActiveRecord::Base
  has_many :reviews
  has_many :positive_reviews, -> { where("rating > 3.0") }, class_name: "Review"
end

これはあまりDRYとは言えませんね。

幸い、簡単な解決策があります。whereの中では、Reviewクラスに定義したpositiveスコープをそのまま呼び出せるのです。

app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  has_many :reviews
  has_many :positive_reviews, -> { positive }, class_name: "Review"
end

こうすることで、「レビューが高評価であるとはどういうことか」というビジネスロジックは、Reviewモデルの一箇所だけに集約されます。

まとめ

スコープは非常に強力な機能です。適切な場所で使えば、データのクエリを簡単かつ快適なものにしてくれます。しかし、N+1クエリを避けたいなら、その扱いには注意が必要です。

スコープがパフォーマンスの問題を引き起こし始めたら、それをアソシエーションでラップしてプリロードしましょう。手間はほんの少し増えるだけですが、大量のSQL呼び出しを節約でき、アプリケーションの応答速度が大きく改善します。

  1. Rubyでコマンドラインアプリケーション(CLI)を作る方法を徹底解説

    RubyはWebアプリケーション開発のイメージが強く、それ以外の用途があることを忘れられがちです。そこで本記事では、Rubyを使ってコマンドラインアプリケーション(CLI)を構築する方法を詳しく解説します。 まず、皆さんがよく目にするコマンドラインアプリケーションの例を挙げてみましょう。 psql(PostgreSQLの対話型ターミナル) rails bundler gem git CLIアプリケーションの作り方はさまざまですが、この記事では代表的な3つの方法に焦点を当てて紹介します。 この記事で学べること ARGV配列の使い方 OptParseライブラリの使い方 Thor gemの使い

  2. RailsアプリでTwitterログインを実装する方法:OmniAuth-Twitter完全ガイド

    このチュートリアルでは、RailsアプリケーションのユーザーがTwitterアカウントを使ってログインできるようにする方法を解説します。OAuthなどのツールのおかげで、この実装はとても簡単に行えるようになりました。 今回は、OmniAuth用のTwitterストラテジーを含む「OmniAuth-Twitter」を使用します。 それでは始めましょう! はじめに:プロジェクトの準備 まずはRailsアプリケーションを生成します。ターミナルで以下のコマンドを実行してください。 rails new Tuts-Social -T 次にGemfileを開き、bootstrap gemを追加します。