21
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-02-09

はじめまして。今日からQiitaデビューをしました豊川と申します。普段は自分のブログで記事を書いているのですが、そろそろエンジニアとして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つです。

Gemfile
gem "redis"
gem "redis-objects"

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

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

config/environments/development.rb
ENV["REDIS"] = "localhost:6379"
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を関連付けていきたいので、コチラに下記のようなコードを書いていきます。

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を日付と連動させることです。

# デイリーで切り替わる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オブジェクトを分けることです。

# 日付別で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オブジェクトを生成できるようにすることです。

# 日付が変わった際に新たに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します。

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

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

app/models/plan.rb
has_many :page_views, dependent: :destroy
app/models/page_view.rb
belongs_to :plan

とまぁ、こんな感じで特に難なくModelの実装が完了しました。

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

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

ActiveJobの実装

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

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です。

Gemfile
gem "whenever", require: false

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

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

$ bundle exec wheneverize .

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

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に設定が反映されないためです。忘れずに下記のコマンドを実行しましょう。

$ bundle exec whenever --update-crontab

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

$ bundle exec whenever

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

$ bundle exec whenever --clear-crontab

Controller & Viewの実装

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

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
app/views/plans/index.html.haml
- @plan.each do |plan|
  - pv = plan.show_pv + @plan.page_views.sum(:count)
  = pv
app/views/plans/show.html.haml
= @pv

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

まとめ

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

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

21
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?