問題
Herokuでスケジュール実行のために用意されているアドオンHeroku Schedulerでは「10分毎」「1時間毎」「1日毎」のオプションが用意されているものの、「1週間毎」に実行したいときのオプションはないので、週次でタスクを実行したい場合はRails側で実装する必要があります。さて、それでは週次で実行するための判定処理はどこに書くべきでしょうか?
よくあるコード
こんなときよく見かけるのがパターンAとパターンBのコードですが、双方ともデメリットが大きいです。
パターンA: Rakeタスク側に判定コードを書く
DAY_OF_WEEK = %i[sun mon tue wed thu fri sat].freeze
namespace :awesome_check do
desc 'Invoke AwesomeChecker'
task run: :environment do
if DAY_OF_WEEK[Date.current.wday] == :sat
AwesomeChecker.call
end
end
end
シンプルではありますが、Rakeタスクのテストは書きづらいので、このコードの例で言えば本当に土曜日だけに実行されるように担保したい場合に困ります。
【参考】Rakeタスクのテストを書きたい人はこちらの記事が参考になります
RailsでRakeタスクをシンプルかつ効果的にテストする手法
パターンB: AwesomeChecker側に判定コードを書く
class AwesomeChecker
# ...(中略)...
def call
return false unless DAY_OF_WEEK[Date.current.wday] == :sat
# (処理の中身を書く)
end
# ...(中略)...
end
「週次実行するビジネスロジック」と「AwesomeCheckerのそもそものビジネスロジック」は目的が異なるので、コードとしていびつです。そもそも、所定の曜日にしか実行できない関数が存在するのは不気味ですよね。
解決策: 週次実行を管理するクラスを作る
どちらにロジックを書いても不自然になるのであれば、新しくクラスを作ってロジックを分離するのが策になります。Rakeタスクにこんな感じのコードを書けるとすれば、Rakeタスクにロジックを書かなくても済むし、AwesomeChecker上に週次実行のロジックを盛り込まなくても済みそうです。
namespace :awesome_check do
desc 'Invoke AwesomeChecker'
task run: :environment do
# NOTE:
# Procで実行したいコードを渡しておけば、
# 即座に実行されてしまうことはありません
DayOfWeekInvoker.call(:sat, -> { AwesomeChecker.call })
end
end
このような「指定された曜日にだけ第二引数の関数が実行される」クラスの実装はこんな感じになるかと思います。
class DayOfWeekInvoker
DAY_OF_WEEK = %i[sun mon tue wed thu fri sat].freeze
def self.call(*args)
new(*args).call
end
def initialize(day_of_week, func)
@day_of_week = day_of_week
@func = func
unless valid_day_of_week?
raise ArgumentError, "無効な引数です: #{day_of_week}"
end
end
def call
unless DAY_OF_WEEK[Date.current.wday] == @day_of_week
Rails.logger.info '[DayOfWeekInvoker] Invocation Skipped'
return
end
@func.call
end
private
def valid_day_of_week?
@day_of_week.in? DAY_OF_WEEK
end
end
Heroku Schedulerのオプションで「日毎」が選択できるため、1日1回だけ実行されるというロジックはHeroku Schedulerにお任せしている実装にはなりますが、1日1回だけ実行されることをRails側で担保したい場合もこのような発想でクラスを分離すればテストも簡単になります。
「何だかロジックが入り組んでテストを書きづらくなったなー」というときにご参考ください。