counter_cacheの導入と使い方

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

## 記事投稿の背景
この記事ではXのクローンサイトを作成中にBulletで検知したN+1問題を解決するために実装した内容を紹介します。
機能としては投稿に対するいいねとコメントの数を集計して表示させる機能に関する内容です。
実装当初の内容と修正後の内容を掲載したいと思います。

## 実装当初のコード
```ruby:tweet.rb
class Tweet < ApplicationRecord
  belongs_to :user
  validates :content, presence: true, length: { in: 1..140 }
  has_many_attached :images
  has_many :favorites, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_many :retweets, dependent: :destroy
end
```
```ruby:favorite.rb
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :tweet
end
```
```ruby:comments.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :tweet
  belongs_to :parent, class_name: 'Comment', optional: true # 親コメント(返信先) Nillも許容
  has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :comment # 返信
  validates :sentence, presence: true, length: { in: 1..140 }
  has_many_attached :images
end
```
```ruby:index.html.slim
.icons
  .comment
    = link_to image_tag('comment.png', size: '26x26', class: "balloon"), tweet_path(tweet.id)
    = tweet.comments.count # 今回の記事で肝となる部分
  .repost
    = image_tag('repost.png', size: '26x26')
  .favorite
    - if current_user.present? && tweet.favorites.exists?(user: current_user)
      = link_to image_tag('heart.png', size: '32x32'), tweet_favorite_path(tweet_id: tweet.id, id: tweet.favorites.find_by(user: current_user).id), data: {turbo_method: :delete}
      = tweet.favorites.count # 今回の記事で肝となる部分
    - else
      = link_to image_tag('heart_with_hole.png', size: '32x32'), tweet_favorites_path(tweet_id: tweet.id), data: {turbo_method: :post}
      = tweet.favorites.count # 今回の記事で肝となる部分
```

## 遭遇した検知文と実装当初の状況
``` :Bulletの警告文
user: root
GET /home/index?recommend=9&tab=recommend
Need Counter Cache with Active Record size
  Tweet => [:comments]

user: root
GET /home/index?recommend=9&tab=recommend
Need Counter Cache with Active Record size
  Tweet => [:favorites]
```
Bulletからcounter_cacheの使用を促されました。

tweet, comment, favoriteの３モデル間についてアソシエーションは、`tweet has many comments and favorites` となります。
直訳でtweetモデルが沢山の(複数の)commentsとfavoritesを持っている

実装当初では1つのtweetに関わるcommentやfavoriteを取得する場合、
`tweet.comments.count` や `tweet.favorites.count` と実装しています。
この場合、毎回commentsテーブルやfavoritesテーブルにクエリが走ってパフォーマンスは良くないです。
その結果、Bulletから指摘されています。

## counter_cacheの導入と変更後のコード
- counter_cacheとは？
関連するモデルの関連数(ここではcommentとfavorite)を効率的に管理するための機能です。
関連するデータの数をキャッシュとして保持することで、N+1クエリ発生を防ぎパフォーマンスを向上させます。

- counter_cacheの導入
関連する親モデル(ここではtweetモデル)のテーブルに数をキャッシュするカラムを追加します。
```ruby:comment.rb - 変更後
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :tweet, counter_cache: :count_of_comments # このカラムに集計した数を保管
  belongs_to :parent, class_name: 'Comment', optional: true # 親コメント(返信先) Nillも許容
  has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :comment # 返信
  validates :sentence, presence: true, length: { in: 1..140 }
  has_many_attached :images
end
```
`count_of_comments`というカラムをtweetテーブルに作成して、tweetに関連するcommentsの数をこのカラムに保管します。
```ruby:favorite.rb
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :tweet, counter_cache: :count_of_favorites # このカラムに集計した数を保管
end
```
`count_of_favorites`というカラムをtweetテーブルに作成して、tweetに関連するfavoritesの数をこのカラムに保管します。
- データベースにカウンタ用のカラムを実際に追加していきます。
```ruby:マイグレーションファイル - カラムの追加
 def change
   add_column :tweets, :count_of_comments, :integer, default: 0, null: false
   add_column :tweets, :count_of_favorites, :integer, default: 0, null: false
 end
end
```
これで各tweetに関するcommentとfavoriteの数を自動で集計してくれるようになりました。
新しくfavoriteやcommentが作成されるとRailsの中で自動的にcount_of_favoritesとcount_of_commentsの数を増やします。逆に削除されれば数を減らします。
これにより`tweet.comments.count`や`tweet.favorites.count`ではなく、
`tweet.count_of_comments`や`tweet.count_of_favorites`でデータベースにクエリを発行せずにfavoriteやcommentの数を取得できるようになりました。

- カウントのリセット
既存のデータに対して`count_of_comments`と`count_of_favorites`の数をリセットするため、`reset_counters`メソッドを使います。
```ruby:マイグレーションファイル - counterのリセット
def up
  Tweet.find_each do |tweet|
    Tweet.reset_counters(tweet.id, :comments) # commentsのカウントをリセット
    Tweet.reset_counters(tweet.id, :favorites) # favoritesのカウントをリセット
  end
end

def down
  # 元に戻す処理は不要
end
```
これでそれぞれのcounterが全てのtweetに対して正しくリセットされます。

以上までの実装したコードでN+1問題を回避し、Bulletからの指摘も無くなりました。

最後まで読んで頂き有難うございます。

## 今回参考にさせて頂いた記事
[Railsガイド](https://railsguides.jp/association_basics.html)
[api.rubyonrails.org](https://api.rubyonrails.org/v7.2/classes/ActiveRecord/CounterCache/ClassMethods.html#method-i-reset_counters)
[DBパフォーマンスを意識したRailsの書き方まとめ](https://qiita.com/Marusoccer/items/a903c5efad14314c0f2a)
[余計なクエリ数を減らしたい。counter_cacheの検討からcounter_cultureの導入まで。](https://qiita.com/sazumy/items/e5399f113360e05032e0)