[Ruby on Rails] N+1問題の解決法(joinsメソッド編)

# はじめに

今回ポートフォリオ(以後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では、ユーザー詳細画面でそのユーザーが獲得した総いいね数を表示しています。
<img width="581" alt="スクリーンショット 2021-06-19 16 52 01" src="https://user-images.githubusercontent.com/75191225/122635411-c2103c00-d11e-11eb-88bd-4d8e4398df99.png">
<br>
そのカウントの仕組みを模式的に表したのが下図です。
<img width="957" alt="スクリーンショット 2021-06-19 18 01 56" src="https://user-images.githubusercontent.com/75191225/122637245-87ab9c80-d128-11eb-95d3-3f5e1c1c521d.png">

この図の場合、id = 1のユーザーが投稿したスポットのidを外部キーとして持ついいねの数を合計した値を
「1人のユーザーが獲得した総いいね数」として表示しています。

この機能をN+1問題を考慮しないで実装する場合、usersコントローラーおよびshowビューファイルでの記述は以下の通りになります。
(もちろん、記述方法は他にもあると思いますので、あくまで一例とお考えいただければと思います。)

```ruby:users_controller.rb
# 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
```

```html:users/show.html.erb
<!--今回の機能に関係のある部分のみ抜粋-->
<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件投稿していたとしたら...
想像するだけで頭が痛くなってしまいますね。

それでは、この問題を解決する記述方法を紹介したいと思います。
<br>

### 3. 解決法

```ruby:users_controller.rb
# 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の後ろに記述した名前)**ですのでご注意ください。

「結合」とは、一言で言えば**「結合条件に従って複数のテーブルを１つのテーブルとして結合させること」**です。
そして結合も「内部結合」および「外部結合」の2種類に大別されますが、今回のテーマからそれてしまうため、
詳しい説明は省略させていただきます。
(簡単に言えば、複数のテーブルを1つにした際に、
・「内部結合」・・・片方のテーブルに情報が入ってない場合、もう片方のテーブルに情報があっても表示されない
・「外部結合」・・・片方のテーブルに情報が入ってない場合でも、もう片方のテーブルの情報があれば、それを表示する
です。機会があれば取り上げたいと思います。)

ひとまず今回は、下図のようなイメージを持っていただければと思います。

<img width="952" alt="スクリーンショット 2021-06-20 11 58 35" src="https://user-images.githubusercontent.com/75191225/122660770-e3c10000-d1be-11eb-9d2f-f8b7b3980d54.png">

ここで、「`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/)