バッチ処理の実装

今回は記事投稿アプリにバッチ処理の実装をしてみます。
最終的にはこんなことをやりたいです。

やりたいこと

記事の状態が「下書き」「公開」「公開待ち」と3種類あります。
例えば記事の公開日を1日後にすると記事の状態は「公開待ち」になります。
時間が経って公開日を過ぎたら自動で記事が公開されるようにしたいです。

必要なもの

・Rakeタスク
・cron
・whenever
これらを使って自動で記事が公開される仕組みを作っていきます。
具体的に言うと1時間毎に記事の公開日時が現在時刻を過ぎているか判定し、過ぎていれば記事の状態を「公開」に変更します。
このように一定の間隔で処理を行うことをバッチ処理と言います。

Rakeとは

Rakeとは、rubyで処理内容を定義できるビルドツールです。
このRakeが実行する処理内容をRakeタスクと呼び、Rakefileに定義します。

cronとは

cronとは、Unix系のOSに標準で備わっている仕組みです。
「この時間にこのプログラムを実行」「毎日のこの時間になったらこのプログラムの実行」という感じで定期的にコマンドを実行するためにメモリ場で常に命令を待機しているプロセス(=デーモンプロセス)です。

wheneverとは

cronの設定を、rubyの簡単な文法で扱えるようにしたライブラリです。

Rakeタスクファイルを作成

では進めていきましょう。
まずはRakeファイルを作成し、そこに実現したい処理を書いていきます。
記事の状態に関する処理なので名前はarticle_stateとしておきます。

rails g task article_state

こちらのコマンドを実行するとlib/tasks以下にarticle_state.rakeが作成されます。

namespace :article_state do

end

この中に処理を書いていきます。

namespace :article_state do
  desc '公開日時が過去の日付の「公開待ち」記事があれば、ステータスを「公開」に変更する' # desc = description(説明)
  task update_article_state: :environment do # environmentはDBとのやりとりが必要な際に記述します
     # ここに処理を書きます
  end
end

こんな感じです。
で、処理なんですが、

Article.where(state: :publish_wait).each do |article|
  if article.publishd_at <= Time.current
    article.state = 'published'
  end
end

のようにしてもいいのですが、モデルのscopeを使うとスッキリ書けるようなので使ってみます。

models/article.rb

enum state: { draft: 0, published: 1, publish_wait: 2 }

scope :past_published, -> { where('published_at <= ?', Time.current) }

現在時刻と比べて公開日が過去になっている記事を取得して、past_publishedという名前を付けています。
これを使って

namespace :article_state do
  desc '公開待ちの中で、公開日時が過去になっているものがあれば、ステータスを「公開」に変更されるようにする'
  task update_article_state: :environment do
    Article.publish_wait.past_published.find_each(&:published!)
  end
end

このようにしました。
publish_waitenumの定義により使えるようになったものでstateがpublish_waitの記事を取得します。
past_publishedは先ほど定義したscopeです。
これを連結して公開日が過去かつ公開待ちの記事を取得しています。

データの取得には、eachだとデータが大量にあった場合にメモリを圧迫するのでfind_eachを使っています。
こちらはデフォルトでは最大1000件のデータを取得して、処理が終わると次の1000件、という感じでメモリへの負荷が少ないです。

(&:published!)の部分はまだ理解できていないのですが、{ |article| article.published! }を省略した形のようです。
article.published!が記事のstateを公開に変更しているのは分かるのですがあとがちょっと分かりません。
学習用に、あるアプリの改修という形で実装しているため分からないところが出てきますがご容赦ください
参考になりそうなサイトだけ貼っておきます。
Ruby: アンパサンドとコロン`&:`記法について調べてみた|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

gem wheneverの導入

Rakeタスクが出来たのでcronの設定をするのですが、まずはgemwheneverをインストールします。
Gemfileに

gem 'whenever', require: false

としてbundle installします。
require: falseとするのはこのGem自体がRailsアプリケーションに反映するものではなく、ターミナル(言わばOS)に反映させるものだから、だそうです。

bundle exec wheneverize .

このコマンドでconfig/schedule.rbというファイルが作成されるのでここにcronの設定を記述します。

# Rails.rootを使用するために必要
require File.expand_path(File.dirname(__FILE__) + '/environment')
# cronを実行する環境変数
rails_env = ENV['RAILS_ENV'] || :development
# cronを実行する環境変数をセット
set :environment, rails_env
# cronのログの吐き出し場所
set :output, "#{Rails.root}/log/cron.log"
# 1時間毎にrakeタスクを実行
every 1.hour do
  rake 'article_state:update_article_state'
end

cronに設定を反映させる

schedule.rbに設定の記述はしましたがまだ反映がされていません。

bundle exec whenever 

で設定にエラーがないかを確認し、

bundle exec whenever --update-crontab 

でcronに設定を反映します。

clone -l

で設定されているcronを確認できます。

最後に

難しかったですが使いこなせればできることの幅が広がりそうです。
間違いなどあればご指摘いただければと思います。
今回は以上です。ありがとうございました。

参考サイト

Railsでwheneverを使ってバッチを回す!(rakeタスク、cron) - Qiita

RakeタスクとCronを使って1時間ごとにタスクを実行する - WeBlog

Rails のコマンドラインツール - Railsガイド

library rake (Ruby 3.0.0 リファレンスマニュアル)

Railsのモデルのscopeを理解しよう - Qiita

【Rails】 | Pikawaka - ピカ1わかりやすいプログラミング用語サイト

GitHub - javan/whenever: Cron jobs in Ruby