はじめに
今回ポートフォリオ(以後PF)制作で、
日本各地の名所を投稿できるサイトを制作しました。
実際に製作したサイトと、コード(GitHub)は下記のURLからご覧ください。
・サイトURL : https://japansiteinfo.com (今後予告なく公開停止する場合があります。ご了承ください。)
・GitHubのURL : https://github.com/yuta-pharmacy2359/dwc_JapanSiteInfo_app
今回は前回も取り上げた「N+1問題」に関して、includesメソッドで解決できないものについて
joinsメソッド
を用いた解決法を紹介したいと思います。
本題
1. 「N+1問題」と前回のおさらい
「N+1問題」とは、「データベースからデータを取り出す際、不要なSQLが発行される」ことで、
パフォーマンスの低下を招くため、実際のサイトでは解決することが必要不可欠となります。
前回の記事 (https://qiita.com/yuta-pharmacy2359/items/61cff1aed171fedeff6e) では
includesメソッド
による解決法を紹介しました。
基本的に、「単に2つ以上のテーブルの情報を表示するような一覧画面」などは、
以下の式でN+1問題を解決することができます。
モデル名.includes(:関連名)
しかし、以下に挙げるような、
**「別のテーブルで計算(合計、平均など)した結果を表示する」**場合は
includesメソッド
では解決することができません。
・(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示
・(キーワード一覧画面において)1つのキーワードにおけるスポット評価の平均
・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総いいね数表示
・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総スポット数表示
・フォロー数、フォロワー数表示
・(フォロー・フォロワー画面における)各ユーザーの最終更新日表示
今回は上記のうち、**「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示」**機能におけるN+1問題の解決法を、
joinsメソッド
用いて解決していきたいと思います。
2. 実際に発生した問題
当PFでは、ユーザー詳細画面でそのユーザーが獲得した総いいね数を表示しています。
そのカウントの仕組みを模式的に表したのが下図です。
この図の場合、id = 1のユーザーが投稿したスポットのidを外部キーとして持ついいねの数を合計した値を
「1人のユーザーが獲得した総いいね数」として表示しています。
この機能をN+1問題を考慮しないで実装する場合、usersコントローラーおよびshowビューファイルでの記述は以下の通りになります。
(もちろん、記述方法は他にもあると思いますので、あくまで一例とお考えいただければと思います。)
# showアクションのうち、今回の機能に関係のある部分のみ抜粋
def show
@user = User.find(params[:id])
@user_all_spots = @user.spots
@user_all_favorites_count = 0
@user_all_spots.each do |spot|
@user_all_favorites_count += spot.favorites.count
end
end
<!--今回の機能に関係のある部分のみ抜粋-->
<tr>
<td>総いいね数</td>
<td class="user-favorite-count-<%= @user.id %>"><%= @user_all_favorites_count %></td>
</tr>
コントローラーファイルに関して簡単に説明すると、showアクションの中身において
【1行目】
当該のユーザーidのユーザーを選択
【2行目】
対象のユーザー(@user)のスポットを@user_all_spotsに代入
(上図の例では、id=1のユーザーのスポット(id=2,13,28,144,186,207)を代入)
【3行目】
@user_all_favorites_countに0を代入して値をリセット
【4~6行目】
対象のユーザーの各スポットが獲得したいいね数を
順に@user_all_favorites_countに加算していく
(上図の例では
spot_id=2 => いいね数3
spot_id=13 => いいね数2
spot_id=28 => いいね数1
spot_id=144 => いいね数2
spot_id=186 => いいね数1
spot_id=207 => いいね数3
@user_all_favorites_count=3+2+1+2+1+3=12)
となります。【4~6行目】の説明を見て薄々お気づきかと思いますが、
この記述では、以下のようなアクセスが行われています。
User Load (0.2ms) SELECT "users".* FROM "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ? [["id", 1], ["LIMIT", 1]]
Spot Load (0.2ms) SELECT "spots".* FROM "spots" WHERE "spots"."user_id" = ? [["user_id", 1]]
(0.2ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 2]]
(0.2ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 13]]
(0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 28]]
(0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 144]]
(0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 186]]
(0.1ms) SELECT COUNT(*) FROM "favorites" WHERE "favorites"."spot_id" = ? [["spot_id", 207]]
SQL2行目では、showアクションの【4行目】に則り、
user_id=1の全スポット
が呼び出されています。
そしてSQL3行目以降では、showアクションの【5行目】に則り、
user_id=1の各スポットにそれぞれ紐づけられたいいねの情報
が呼び出されています。
まさに「N+1問題」が発生している状況ですね。
そして、今回の例ではスポット数が6でしたので、アクセス回数は7で済んでいますが、
極端な例ですが「三度の飯より旅行」なユーザーが1000件、10000件投稿していたとしたら...
想像するだけで頭が痛くなってしまいますね。
それでは、この問題を解決する記述方法を紹介したいと思います。
3. 解決法
# showアクションのうち、今回の機能に関係のある部分のみ抜粋
def show
@user = User.find(params[:id])
@user_all_spots = @user.spots
@user_all_favorites_count = @user_all_spots.joins(:favorites).count
end
早速usersコントローラーでの記述から紹介しました。(showのビューファイルは変更なし)
showアクションの中身において、【1~2行目】は先ほどと同じです。
問題の【3行目】に関して、順を追って説明していきます。
ここで出てきたjoinsメソッド
は、**「関連するテーブル同士を内部結合するメソッド」**です。
基本的な定義は以下の通りです。
モデル名.joins(:関連名)
引数は、includesメソッド
と同じく**関連名(モデルファイルでhas_many、またはbelongs_toの後ろに記述した名前)**ですのでご注意ください。
「結合」とは、一言で言えば**「結合条件に従って複数のテーブルを1つのテーブルとして結合させること」**です。
そして結合も「内部結合」および「外部結合」の2種類に大別されますが、今回のテーマからそれてしまうため、
詳しい説明は省略させていただきます。
(簡単に言えば、複数のテーブルを1つにした際に、
・「内部結合」・・・片方のテーブルに情報が入ってない場合、もう片方のテーブルに情報があっても表示されない
・「外部結合」・・・片方のテーブルに情報が入ってない場合でも、もう片方のテーブルの情報があれば、それを表示する
です。機会があれば取り上げたいと思います。)
ひとまず今回は、下図のようなイメージを持っていただければと思います。
ここで、「spotsテーブル
におけるid
」と「favoritesテーブル
におけるspot_id
」は同じ内容であることに着目すると、
これら2つのテーブルを結合した際に上図の右側のようにデータが配置され、あたかも
「id=4,16,39...348,371,397のいいねは全てid=1のユーザーに対して付与されたもの」
と捉えることができます。これがミソであり、今まで1人のユーザーが獲得したいいね数をカウントする場合は、
「id=1のユーザーが投稿したのはid=2,13,28,144,186,207のスポットである。そしてそれぞれのスポットが獲得したいいね数は3,2,1,2,1,3なので、id=1のユーザーが獲得した総いいね数は12である。」
と考える必要があったのが、
「結合後の旧spotテーブル側において、user_id=1であるレコード数は12なので、id=1のユーザーが獲得した総いいね数は12である。」
と捉えることができるようになります。
(要は**「手順を挟まずにダイレクトに集計できるようになる」**ということです。)
あとは結合後のレコード数を数えれば良いので、countメソッド
を用いればOKです。
この記述に変更し、再度ターミナルのログを確認すると、
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
(0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "favorites" ON "favorites"."spot_id" = "spots"."id" WHERE "spots"."user_id" = ? [["user_id", 1]]
無事「N+1問題」を解決することができました。
終わりに
今回は、includesメソッド
で「N+1問題」を解決できない機能のうち、「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示機能」について、joinsメソッド
を用いてN+1問題を解決しました。
残りの5項目に関しては、joinsメソッド
の他にgroupメソッド
を使用する必要があります。それに関してはまた記事を上げたいと思っています。
使用画像素材
・いらすとや(https://www.irasutoya.com/)
・ぱくたそ(https://www.pakutaso.com/)