counter_cultureで合計値をキャッシュする

# 概要

親が子の数を数えることはよくある。ブログでカテゴリの横に記事の数を表示させるとか。
たかがカウントだが、大量アクセスがあるとDBのコストは馬鹿にならない。

Railsには標準で`counter_cache`なるものがあり、関連するテーブルのレコード数をキャッシュできるらしい。
だが今回は子の数を数えるだけでなく、子の持っている数値の合計値をキャッシュしたかった。

これを`counter_culture`というgemが解決してくれた。以下はそのメモ。

# 前提

本とレビューのモデルを考える。
本には複数のレビュー（コメントと評価）をつけることができる。

```ruby:book.rb
class Book < ApplicationRecord
  has_many :reviews
end
```
```ruby:review.rb
class Review < ApplicationRecord
  belongs_to :book
end
```

テーブルはこんな感じ。

```ruby:create_books.rb
class CreateBooks < ActiveRecord::Migration[5.1]
  def change
    create_table :books do |t|
      t.string :title,  comment: "タイトル"
      t.string :author, comment: "著者"

      t.timestamps
    end
  end
end
```

```ruby:create_reviews.rb
class CreateReviews < ActiveRecord::Migration[5.1]
  def change
    create_table :reviews do |t|
      t.references :book,    comment: "本"
      t.text       :comment, comment: "コメント"
      t.integer    :rate,    comment: "評価"

      t.timestamps
    end
  end
end
```

さて、ここで例えば本の情報を取得するAPIを作った場合、ついでに「コメントが何件ついているか」と「評価の平均値」も合わせて取得したくなるのが人情というもの。

まずこんな感じで書いてみる。

```ruby
book = Book.find(1)
comment_count = book.reviews.where.not(comment: nil).count
average_rate  = book.reviews.average(:rate)
```

実行するとこんなクエリが走る。

```SQL
SELECT "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
SELECT COUNT(*) FROM "reviews" WHERE "reviews"."book_id" = ? AND ("reviews"."comment" IS NOT NULL)  [["book_id", 1]]
SELECT AVG("reviews"."rate") FROM "reviews" WHERE "reviews"."book_id" = ?  [["book_id", 1]]
```
当然ながら「本」「コメント数」「評価の平均」の取得に３回のDBアクセスが発生している。

本1冊ならまだいいが、つい欲をかいて本を一覧で取得するAPIも作ったらどうだ。
そんなAPIをバカスカ叩かれる未来を想像すると、DBアクセスが詰まって死ぬんじゃないかという恐怖に苛まれて夜も眠れない。

なんとかDBアクセスを減らす方法はないかと調べたところ`counter_culture`に行き着いた。

# やったこと

## Gemを追加

```ruby:Gemfile
gem 'counter_culture'
```

## テーブルにカウンターのカラムを追加
最終的に欲しいのは「評価の平均」だが、カウンターとしてはひとまず「レビュー数」と「評価の合計」を`books`テーブル追加する。
この２つがあれば`reviews`テーブルにアクセスしなくても平均を求められるはずだ。

```ruby:add_counter_columns_to_books.rb
class AddCounterColumnsToBooks < ActiveRecord::Migration[5.1]
  def change
    add_column :books, :comments_count, :integer, null: false, default: 0, comment: "コメント数"
    add_column :books, :reviews_count,  :integer, null: false, default: 0, comment: "レビュー数"
    add_column :books, :total_rate,     :integer, null: false, default: 0, comment: "評価の合計"
  end
end
```

## モデルにカウンターの仕掛けを追加
`Review`にカウンターの仕掛けを追加する。
これで`Review`が作成/破棄/更新された際、自動的に`Book`にも反映されるようになる。

```ruby:review.rb
class Review < ApplicationRecord
  belongs_to :book
  # レビュー数
  counter_culture :book
  # コメント数
  counter_culture :book, column_name: proc {|model| model.comment.blank? ? nil : "comments_count" }
  # 評価の合計
  counter_culture :book, column_name: "total_rate", delta_column: "rate"
end
```

少し細かく見てみる。

まず最初のレビュー数。これは最もシンプルな書き方。

```ruby
counter_culture :book
```

これだけで`Book`に紐づく`Review`がカウントされて`reviews_count`に保持されるようになる。
なお、カウンターのカラム名が"テーブル名\_count"でない場合は`column_name`で指定できる。

次はコメント数。

```ruby
counter_culture :book, column_name: proc {|model| model.comment.blank? ? nil : "comments_count" }
```

カウントに条件がある場合はブロックを渡すことになる。
条件に一致した場合はカウンターのカラム名を、それ以外は`nil`を渡すようにする。

最後は評価の合計。

```ruby
counter_culture :book, column_name: "total_rate", delta_column: "rate"
```

カウンターはレコード数をキャッシュするためレコード毎に1ずつ加算されるのが普通だが、`delta_column`で加算する値のカラムを指定できる。これええな。

## 動作検証

レビューを追加したときにカウンターが加算されていることを確認してみる。

最初はレビューがない。
カウンターも全部ゼロ。

```ruby
irb(main):001:0> book = Book.first
irb(main):002:0> book.reviews_count
=> 0
irb(main):003:0> book.comments_count
=> 0
irb(main):004:0> book.total_rate
=> 0
irb(main):005:0> book.reviews.count
=> 0
```
レビューを追加してみる。

```ruby
irb(main):007:0* book.reviews.create(comment: "Good!", rate: 5)
   (0.1ms)  begin transaction
  SQL (1.4ms)  INSERT INTO "reviews" ("book_id", "comment", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_id", 1], ["comment", "Good!"], ["rate", 5], ["created_at", "2017-12-08 16:49:07.725594"], ["updated_at", "2017-12-08 16:49:07.725594"]]
  SQL (0.2ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.1ms)  UPDATE "books" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 5 WHERE "books"."id" = ?  [["id", 1]]
   (1.6ms)  commit transaction
=> #<Review id: 1, book_id: 1, comment: "Good!", rate: 5, created_at: "2017-12-08 16:49:07", updated_at: "2017-12-08 16:49:07">
irb(main):008:0> 
irb(main):009:0* book.reviews.create(comment: "Bad.", rate: 1)
   (0.1ms)  begin transaction
  SQL (1.2ms)  INSERT INTO "reviews" ("book_id", "comment", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_id", 1], ["comment", "Bad."], ["rate", 1], ["created_at", "2017-12-08 16:49:32.383319"], ["updated_at", "2017-12-08 16:49:32.383319"]]
  SQL (0.4ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
   (2.4ms)  commit transaction
=> #<Review id: 2, book_id: 1, comment: "Bad.", rate: 1, created_at: "2017-12-08 16:49:32", updated_at: "2017-12-08 16:49:32">
irb(main):010:0>
irb(main):011:0* book.reviews.create(comment: nil, rate: 3)
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "reviews" ("book_id", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["book_id", 1], ["rate", 3], ["created_at", "2017-12-08 16:53:46.922476"], ["updated_at", "2017-12-08 16:53:46.922476"]]
  SQL (0.3ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 3 WHERE "books"."id" = ?  [["id", 1]]
   (1.4ms)  commit transaction
=> #<Review id: 3, book_id: 1, comment: nil, rate: 3, created_at: "2017-12-08 16:53:46", updated_at: "2017-12-08 16:53:46">
```

`reviews`のINSERTの後、同一トランザクション内で`books`に対するUPDATEが走っている。
３つ目のレビューはコメントがないので`comments_count`のUPDATEはされていない。
`total_rate`はちゃんと`rate`の値が加算されている。

それでは`Book`を確認。

```ruby
irb(main):022:0* book.reload
  Book Load (0.3ms)  SELECT  "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Book id: 1, title: "Crime and Punishment", author: "Dostoyevsky", created_at: "2017-12-08 16:40:13", updated_at: "2017-12-08 16:40:13", reviews_count: 3, comments_count: 2, total_rate: 9>
irb(main):023:0> book.reviews_count
=> 3
irb(main):024:0> book.comments_count
=> 2
irb(main):025:0> book.total_rate
=> 9
```

無事カウンターが回っている。
最後に評価の平均を求めておしまい。

```ruby
irb(main):029:0> book.total_rate > 0 ? book.total_rate / book.reviews_count.to_f : 0 # ゼロ除算に注意
=> 3.0
```

はい、これで本の一覧を取得しても`SELECT "books".* FROM "books"`一発で全部取れるようになりました。
ようやく安心して眠れるね。おやすみ。

# 参考

- [GitHub : counter_culture](https://github.com/magnusvk/counter_culture)
- [Rails向け高機能カウンタキャッシュ gem ‘counter_culture’ README（翻訳）](https://techracho.bpsinc.jp/hachi8833/2017_08_03/43698)