使用Rails、Redis和whenever来实时显示PV数的方法

初次见面。我是丰川,今天开始在 Qiita 上出道。平时我习惯在自己的博客上写文章,但是我想作为一名工程师,也想在 Qiita 上发布一些文章,所以下定决心尝试了一下出道。

日常生活中,我也在编写Rails的应用程序。在其中,我使用Rails、Redis和whenever实现了保存每日PV数量的功能。我想以备忘录的形式记录下这个方法。

我想要实施这个功能的原因是什么?

我希望能够在我自己开发的Rails应用程序中实时显示页面浏览量,并且自从开始学习Rails以来,我就知道了Redis这个存在,但实际上从来没有用Redis实现过任何东西,所以我想将其作为练习的对象来实现这个功能。

我曾经尝试过各种方式去搜索文章,因为我想说不可能没有一篇相关的文章存在。然而,大部分的结果都是关于使用Redis来实现每日页面浏览量排名的,与我的初衷有些不符合。因此,我决定写这篇文章。

为什么仅使用Redis保存PV的方法行不通?

如果按照之前提到的,使用每日PV排名逻辑将PV数据积累到Redis中,似乎很容易实现,但为什么不行呢?

正如您所知,Redis是一种内存数据库,因此具有高性能的同时,持久性较低,因此很难永久保存过去的PV数据。对于像每日排名这样只需保存当天数据并显示的情况,数据的使用期限是相对固定的情况下,Redis是最佳选择。但对于像我现在尝试做的这样需要回溯到过去的PV数据进行使用的情况,传统的数据库更加适合。

尽管如此,我认为将PV保存在DB中的方法也是毫无意义的。原因是每当用户浏览文章时,都会运行SQL并进行保存,如果访问量集中,负载会变得相当大的原因。(不考虑实际上是否要创建这样高流量的服务)

因此我想到的方法是使用Redis持续积累每天的PV数据,并且利用whenever定期将PV数据保存到数据库中。这样做既能保持Redis的性能,又能利用数据库的持久性来长期保存PV数据(而且还是按日保存)。

实施步骤

尽管前面部分有些冗长,但我希望写下令人关注的实际实施步骤。

Redis相关的实现

首先,我们将继续在Redis上实现临时存储PV的功能。如果Redis未在您的环境中安装,请使用brew install等方法安装Redis。

准备使用 Redis

本次在使用Redis进行实现时,我们引入了以下两个gem。

gem "redis"
gem "redis-objects"

这次我们决定使用redis-objects将Redis实施为类似ActiveRecord的方式。如果熟悉Rails的编写方式,你会发现这样的实施相当直观,我强烈推荐它。

接下来,我们将对各种配置文件进行设置。

ENV["REDIS"] = "localhost:6379"
require "redis"

uri = URI.parse(ENV["REDIS"])
REDIS = Redis.new(host: uri.host, port: uri.port)

现在Redis已经准备完毕了。

模型的实现

因为我想将PV与名为Plan的Model相关联,所以我会在这里写下如下代码。

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

在这里有几个重要点需要强调。

第一个目标是将Redis的键与日期关联起来。

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

通过这个方法,可以按照日期对Redis对象进行管理。对象的示意图如下:

Keymemberscorepv2018_02_09plan_id8pv2018_02_10plan_id4

将Redis对象分成前一天用和当天用的原因将在稍后解释。

将Redis对象分为存储用的Redis对象和用于画面显示的Redis对象。

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

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

稍后我会发布有关View侧代码的内容,简单解释一下,就是通过View来显示“保存在数据库中的PV + 保存在Redis中的当天PV”,然而,如果仅仅通过每日的Redis对象来实现的话,当日期发生变化时,值会切换到第二天的Redis对象,这会导致在日期变化后到当日PV保存到数据库的这段时间内,PV数量看起来减少了。(用文字解释很难理解呢。。)

为了防止这种情况,创建了用于屏幕显示的Redis对象,即使日期发生变化,也能够继承前一天的页面浏览量并进行显示。

第三个目标是在日期更改时能够生成新的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的数据库。

创建一个用于保存PV的数据库。

虽然有一些困难,但我们将按照通常的Rails应用程序开发流程进行实现,没有什么特别的。

创建模型

使用通常的 bin/rails g 命令创建 PageView 模型,并直接执行 db:migrate,无需修改任何迁移文件。

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

我們將實現一個關聯,使計劃與頁面瀏覽相關聯。

has_many :page_views, dependent: :destroy
belongs_to :plan

嗯,就这样,Model的实现顺利完成了,没有遇到什么困难。

为了能够定期将PV保存到数据库中,需要进行相应的设置。

由于已经完成了用于保存PV的数据库的准备工作,我们现在想要实现本次实施的关键功能,即定期保存PV。请注意,我们将按照日期变化的时间(午夜0点)进行定期保存的假设来进行实现进程。

ActiveJob的实现方式

我们将使用ActiveJob来实现定期执行的Job。代码如下所示:
定期执行的Job将会使用ActiveJob进行实现。以下是代码示例:

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

这个工作主要有两个主要步骤。

以下是对原文的中文翻译:

这是保存前一天PV的处理。在日期更改时,会保存前一天的PV。在实现Redis部分时,会出现前一天的Key。保存Redis对象的成员和分数,然后删除该Redis对象。

这个处理是将当天的PV复制到显示用的PV中。执行这个处理的意图是,在午夜0点进行批处理并保存到数据库的时间差期间,将当天增加的PV同步到显示用PV上,以消除当天PV和显示用PV之间的差异。

现在可以使用这个来完全地实现将PV保存到数据库的工作。

无论何时进行定期执行的设定

只是实现了工作(Job),并不能实现定期执行,所以我们要确保这个工作在日期变化的时候能够执行,这就需要设置。为了实现这一点,我们需要用到一个叫做whenever的gem。

gem "whenever", require: false

如果您阅读官方的README文件,您就可以了解关于这个gem的详细信息。简单来说,这个gem可以生成一个很方便的工具,用于在Linux的后台进行定期处理,称之为cron。

安装这个gem后,执行以下命令即可生成配置文件。

$ bundle exec wheneverize .

只需按照以下方式追加生成的设置文件,即可轻松完成定期处理的“前期准备”。

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

控制器和视图的实现

由于保存PV所需的实现已经全部完成,我们将进入最后阶段。让我们来看一下Controller和View的实现。这次我们将在Plan的一览(index)和详细(show)页面中展示PV。

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
- @plan.each do |plan|
  - pv = plan.show_pv + @plan.page_views.sum(:count)
  = pv
= @pv

尽管我以简单的方式进行了写作,但是如果按照这种形式去实施,就可以显示累积PV。

总结

我介绍了使用Rails、Redis和whenever来实时显示页面浏览量的方法。通过写一篇Qiita的文章,我整理了自己的实现,这是一个很好的机会,我希望以后能定期写文章。

如果記載的內容有任何不完善之處,請給予意見,不勝感激。謝謝。

bannerAds