Rails×Redis×wheneverでリアルタイムなPV数を表示させる方法

はじめまして。今日からQiitaデビューをしました豊川と申します。普段は[自分のブログ](http://toyokappa.hatenablog.com/)で記事を書いているのですが、そろそろエンジニアとしてQiitaに投稿をしたいなぁと思い、思い切ってデビューしてみました。

普段プライベートでもRailsのアプリケーションを作っているのですが、その中でRailsとRedisとwheneverを使ってデイリーのPV数を保存する機能を実装したので、そちらの方法を備忘録的な感じで書いていきたいと思います。

# この機能を実装したかった理由

純粋に自分の作っているRailsアプリケーションにリアルタイムなPVを表示させたいと思ったのと、Railsの勉強を始めてからRedisというものの存在は知っていたものの、実際にRedisを使って何かを実装したことがなかったので、その練習台として実装したいと思ったのがキッカケです。

どこかに記事が落ちているだろうと思い色々と調べては見たものの、殆どがRedisを用いてデイリーのPVランキングを実装する、というものばかで若干趣旨が違うなぁという感じだったので、この記事を書くことにしました。

## なぜRedisのみでPVを保存する方法が駄目なのか？

先程の通り、デイリーのPVランキングのロジックを使ってRedisに毎日のPVを溜め込んでいけば、簡単に実装できそうな気がしますが、何故だめなのでしょうか？

それは、ご存知の通りRedisはインメモリDBなのでパフォーマンスは高い反面、永続性は低いため過去のPVを永久に保存をすることが難しいからです。デイリーランキングのようにその日1日のデータを溜め込んで表示させるといったような、データの利用期間がある程度定まっている様なものであれば最適なのですが、今回自分がやろうとしている様に、過去のPVまで遡って利用する様な場合は通常のDBの方が適していると言えます。

かといって、DBにPVを都度保存するという方法もナンセンスだと思いました。理由はユーザーが記事を閲覧するたびにSQLを走らせて保存させることとなるので、アクセスが集中した場合、かなりの高負荷になってしまうと考えたためです。（実際にそんな高トラフィックのサービスを作るかどうかは別として）

そこで思いついた方法が、RedisでデイリーのPVを溜め込みつつ、wheneverで定期的にDBにPVのデータを保存するというものでした。これであればRedisのパフォーマンスを維持しながら、DBの永続性を利用して長期的にPVを保存できる（しかも日次で）と考えました。

# 実装手順

前段が長くなりましたが、気になる実際の実装手順を書いていきたいと思います。

## Redis周りの実装

まずはRedisでPVを一時的に保存できるように実装を進めていきます。Redisが手元の環境に入っていない場合は`brew install`等でRedisをインストールするようにしてください。

### Redisを使う準備

今回Redisを用いて実装するにあたり導入したgemは下記の2つです。

```ruby:Gemfile
gem "redis"
gem "redis-objects"
```

今回はRedisをActiveRecordのように実装できる`redis-objects`を使うことにしました。Railsの書き方に慣れていれば直感的に実装ができるのでかなりおすすめです。

続いて各種設定ファイルの設定をしていきます。

```ruby:config/environments/development.rb
ENV["REDIS"] = "localhost:6379"
```
```ruby:config/initializers/redis.rb
require "redis"

uri = URI.parse(ENV["REDIS"])
REDIS = Redis.new(host: uri.host, port: uri.port)
```
これにてRedisを使い準備は完了です。

### Modelの実装

私の場合はPlanというModelにPVを関連付けていきたいので、コチラに下記のようなコードを書いていきます。

```ruby:app/models/plan.rb
class Plan < ApplicationRecord
  include Redis::Objects

  # デイリーで切り替わるRedisのKey
  @@yesterday = "pv#{Date.yesterday.strftime('%Y_%m_%d')}"
  @@today = "pv#{Date.today.strftime('%Y_%m_%d')}"

  # 日付別でDBに保存するようのRedisオブジェクト
  sorted_set @@yesterday, global: true
  sorted_set @@today, global: true

  # 画面表示用のRedisオブジェクト
  sorted_set :display_pv, global: true

  def increment_pv
    set_pv_keys
    self.class.send(@@today).increment(id)
    self.class.display_pv.increment(id)
  end

  def show_pv
    set_pv_keys
    self.class.display_pv[id].to_i
  end

  private
    # 日付が変わった際に新たにRedisオブジェクトを生成
    def set_pv_keys 
      if @@today != "pv#{Date.today.strftime('%Y_%m_%d')}"
        @@yesterday = "pv#{Date.yesterday.strftime('%Y_%m_%d')}"
        @@today = "pv#{Date.today.strftime('%Y_%m_%d')}"
        Plan.sorted_set @@today, global: true
      end
    end
end
```

ここで重要になってっくるポイントが3つほどあります。

1つ目がRedisのKeyを日付と連動させることです。

```ruby
# デイリーで切り替わるRedisのKey
@@yesterday = "pv#{Date.yesterday.strftime('%Y_%m_%d')}"
@@today = "pv#{Date.today.strftime('%Y_%m_%d')}"
```

これにより、日付別でRedisオブジェクトを管理することができるようになります。オブジェクトのイメージは以下の通りです。

|Key         |member |score|
|---         |---    |---  |
|pv2018_02_09|plan_id|8    |
|pv2018_02_10|plan_id|4    |

前日用のRedisオブジェクトと当日用のRedisオブジェクトに分けている理由は後ほど説明します。

2つ目が保存用のRedisオブジェクトと画面表示用のRedisオブジェクトを分けることです。

```ruby
# 日付別でDBに保存するようのRedisオブジェクト
sorted_set @@yesterday, global: true
sorted_set @@today, global: true

# 画面表示用のRedisオブジェクト
sorted_set :display_pv, global: true
```

View側のコードについては後ほど掲載するのですが、ザクッと説明をすると「DBに保存されているPV + Redisに保存されている当日分のPV」をView側で表示をさせるのですが、日付別のRedisオブジェクトのみで実装をした場合、日付が変わったタイミングで値が翌日分のRedisオブジェクトに切り替わってしまうため、日付が変わってから当日分のPVがDBに保存されるまでの期間、PVが減ったように見えてしまいます。（文章で説明するとわかりづらいですね。。）

それを防ぐために、画面表示用のRedisオブジェクトを作成し、日付が変わったとしても前日のPVを引き継いで表示できる様になっています。

3つ目は、日付が変わった際に新たにRedisオブジェクトを生成できるようにすることです。

```ruby
# 日付が変わった際に新たにRedisオブジェクトを生成
def set_pv_keys 
  if @@today != "pv#{Date.today.strftime('%Y_%m_%d')}"
    @@yesterday = "pv#{Date.yesterday.strftime('%Y_%m_%d')}"
    @@today = "pv#{Date.today.strftime('%Y_%m_%d')}"
    Plan.sorted_set @@today, global: true
  end
end
```

これをやらないと日付が変わったとしても新たに当日分のRedisオブジェクトが生成されずに前日分のRedisオブジェクトがそのまま使われてしまうことになってしまいます。その為、このメソッドを作成しておきPV表示かPV増加のタイミングで宣言し直せるようにしておきます。

これにてModelの実装は完了です。また、Redis周りの実装もこれで一旦完了なのでPVを保存するためのDBづくりに入っていきます。

## PVを保存するようのDB作成

といっても難しいことは特になく、通常のRailsアプリを作っていく流れで実装を進めていきます。

### Modelの作成

通常の`bin/rails g`コマンドでPageView Modelを作成し、特にマイグレーションファイルはいじらずにそのまま`db:migrate`します。

```bash
$ bin/rails g model PageView count:integer date:date plan_id:integer
$ bin/rails db:migrate
```

PlanにPageViewが紐づくようにリレーションを実装していきます。

```ruby:app/models/plan.rb
has_many :page_views, dependent: :destroy
```
```ruby:app/models/page_view.rb
belongs_to :plan
```
とまぁ、こんな感じで特に難なくModelの実装が完了しました。

## PVをDBに定期的に保存できるようにする

PVを保存するためのDBの準備が出来たので、今回の実装の要となるPVの定期保存の機能を実装していきたいと思います。なお、定期保存は日付が変わったタイミング（午前0時）行われるという想定で実装を進めます。

### ActiveJobの実装

定期実行するためのJobをActiveJobで実装していきます。コードは下記のとおりです。

```ruby:app/jobs/save_page_views_job.rb
class SavePageViewsJob < ApplicationJob
  queue_as :default

  def perform(*args)
    yesterday = "pv#{Date.yesterday.strftime('%Y_%m_%d')}"
    yesterday_pv = Plan.send(yesterday)
    today = "pv#{Date.today.strftime('%Y_%m_%d')}"
    today_pv = Plan.send(today)
    display_pv = Plan.display_pv

    # 前日のPVをDBに保存
    yesterday_pv.value(with_scores: true).each do |value, score|
      PageView.create(count: score.to_i, date: Date.yesterday, plan_id: value.to_i)
    end
    yesterday_pv.clear

    # 当日のPVを表示用PVにコピー
    display_pv.clear
    if today_pv.present?
      display_pv.merge today_pv.value(with_scores: true)
    end
  end
end
```

このJobは大きく2つの処理を行っています。

1つ目が前日のPVを保存する処理です。日付が変わったタイミングで前日のPVを保存するようになっているのですが、ここでRedisの部分を実装をしていた時に出てきた前日のKeyが登場します。前日のKeyを持ったRedisオブジェクトのmemberとscoreを保存し、その後にそのRedisオブジェクトを削除します。

2つ目が表示用のPVに当日分のPVをコピーする処理です。この処理を行っている意図としては、午前0時にバッチ処理を実行するもののDBへの保存までのラグ中に増加した当日分のPVを表示用に同期することで、当日分のPVと表示用のPVの差分をなくすことにあります。

これで差分なくDBにPVを保存するJobの実装が出来ました。

### wheneverによる定期実行の設定

Jobを実装しただけでは定期実行が出来ないので、しっかりとこのJobが日付が変わったタイミングで実行されるように設定をしていきます。それに必要なのが`whenever`というgemです。

```ruby:Gemfile
gem "whenever", require: false
```

このgemの詳細はgemの公式のREADMEを読んで頂ければと思うのですが、ザクッと説明をすると`cron`というLinuxのバックグラウンドで定期処理を行ってくれる便利なものを生成してくれるgemです。

このgemをインストールしたら、下記のコマンドを実行することで設定ファイルが生成されます。

```bash
$ bundle exec wheneverize .
```
生成された設定ファイルを下記のように書き足せば、簡単に定期処理の「下準備」が完了です。

```ruby:config/schedule.rb
rails_env = ENV["RAILS_ENV"] || :development
set :environment, rails_env
set :output, "log/crontab.log"

every 1.day, at: "0:00 am" do
  runner "SavePageViewsJob.perform_now"
end
```
さきほど「下準備」と言ったのは下記のコマンドを実行しないとcronに設定が反映されないためです。忘れずに下記のコマンドを実行しましょう。

```bash
$ bundle exec whenever --update-crontab
```

登録ができたか確認する際は下記のコマンドを実行してください。

```bash
$ bundle exec whenever
```

ちなみに、cronから設定を取り消す場合は下記のコマンドを実行すれば取り消すことが出来ます。

```bash
$ bundle exec whenever --clear-crontab
```

## Controller & Viewの実装

これにてPVを保存するための実装が全て完了したので、仕上げに入っていきます。ControllerとViewの実装を見ていきましょう。今回はPlanの一覧（index）と詳細（show）ページにPVを表示させる様にしていきます。

```ruby:app/controllers/plans_controller.rb
class PlansController < ApplicationController
  def index
    @plans = Plan.all
  end

  def show
    @plan = Plan.find(params[:id])
    @pv = @plan.show_pv + @plan.page_views.sum(:count)
    @plan.increment_pv
  end
```

```haml:app/views/plans/index.html.haml
- @plan.each do |plan|
  - pv = plan.show_pv + @plan.page_views.sum(:count)
  = pv
```
```haml:app/views/plans/show.html.haml
= @pv
```

Viewは簡単な感じに書きましたが、このような形で実装をすれば累計のPVが表示できるようになります。

# まとめ
ということでRailsとRedisとwheneverを使ってリアルアイムなPVを表示させる方法をご紹介しました。Qiitaの記事を書いてみることで自分の実装したものを整理するいい機会になったので、今後も定期的に書い期待と思いました。

記載の内容に不備等ございましたら、コメントを頂けると幸いです。ありがとうございました。