RubyでDSL書いてcronの設定ができるwheneverのコードリーディングをしました。バージョンは0.10.0です
まずはwheneverizeの処理ですが、以下のようにconfig/schedule.rbの設定ファイルを書き出しています
# ...
file = 'config/schedule.rb'
base = ARGV.empty? ? '.' : ARGV.shift
file = File.join(base, file)
if File.exist?(file)
warn "[skip] `#{file}' already exists"
elsif File.exist?(file.downcase)
warn "[skip] `#{file.downcase}' exists, which could conflict with `#{file}'"
else
dir = File.dirname(file)
if !File.exist?(dir)
warn "[add] creating `#{dir}'"
FileUtils.mkdir_p(dir)
end
puts "[add] writing `#{file}'"
File.open(file, "w") { |f| f.write(content) }
end
wheneverコマンドではOptionParserで引数パースしてからWhenever::CommandLine.executeを呼び出します
OptionParser.new do |opts|
# ...
end.parse!
Whenever::CommandLine.execute(options)
executeはWhenver::CommandLineをインスタンス化してから#runを呼び出します。
module Whenever
class CommandLine
def self.execute(options={})
new(options).run
end
def run
if @options[:update] || @options[:clear]
write_crontab(updated_crontab)
elsif @options[:write]
write_crontab(whenever_cron)
else
puts Whenever.cron(@options)
puts "## [message] Above is your schedule file converted to cron syntax; your crontab file was not updated."
puts "## [message] Run `whenever --help' for more options."
exit(0)
end
end
オプションを何も指定しないとWhenever.cronの戻り値を表示します
module Whenever
def self.cron(options)
Whenever::JobList.new(options).generate_cron_output
end
Whenever::JobListのコンストラクタではsetup.rbとconfig/schedule.rbをinstance_evalで評価します
module Whenever
class JobList
attr_reader :roles
def initialize(options)
# ...
setup_file = File.expand_path('../setup.rb', __FILE__)
setup = File.read(setup_file)
schedule = if options[:string]
options[:string]
elsif options[:file]
File.read(options[:file])
end
instance_eval(setup, setup_file)
instance_eval(schedule, options[:file] || '<eval>')
end
例えば以下のようなコードの場合、everyとrunnerがそれぞれ評価されることになります
every 4.days do
runner "AnotherModel.prune_old_records"
end
それぞれ以下のように定義されています
def every(frequency, options = {})
@current_time_scope = frequency
@options = options
yield
end
def job_type(name, template)
singleton_class.class_eval do
define_method(name) do |task, *args|
options = { :task => task, :template => template }
options.merge!(args[0]) if args[0].is_a? Hash
options[:mailto] ||= @options.fetch(:mailto, :default_mailto)
# :cron_log was an old option for output redirection, it remains for backwards compatibility
options[:output] = (options[:cron_log] || @cron_log) if defined?(@cron_log) || options.has_key?(:cron_log)
# :output is the newer, more flexible option.
options[:output] = @output if defined?(@output) && !options.has_key?(:output)
@jobs[options.fetch(:mailto)] ||= {}
@jobs[options.fetch(:mailto)][@current_time_scope] ||= []
@jobs[options.fetch(:mailto)][@current_time_scope] << Whenever::Job.new(@options.merge(@set_variables).merge(options))
end
end
end
job_typeはsetup.rbで呼び出されており、command, rake, script, runnerが定義されています
job_type :command, ":task :output"
job_type :rake, "cd :path && :environment_variable=:environment :bundle_command rake :task --silent :output"
job_type :script, "cd :path && :environment_variable=:environment :bundle_command script/:task :output"
job_type :runner, "cd :path && :bundle_command :runner_command -e :environment ':task' :output"
各メソッド内で@jobsのハッシュにmailtoとcurrent_time_scope(everyの後の引数)をキーとしてWhenever::Jobのインスタンスをセットしています。
最後にWhenever::JobList#generate_cron_outputメソッドを呼び出します
def generate_cron_output
[environment_variables, cron_jobs].compact.join
end
環境変数はenvメソッドで定義でき、environment_variablesメソッドで出力されます
def environment_variables
return if @env.empty?
output = []
@env.each do |key, val|
output << "#{key}=#{val.nil? || val == "" ? '""' : val}\n"
end
output << "\n"
output.join
end
cron_jobsではeveryのブロック内でセットしたジョブをcronの設定に置き換えて出力します
def cron_jobs
return if @jobs.empty?
output = []
# jobs with default mailto's must be output before the ones with non-default mailto's.
@jobs.delete(:default_mailto) { Hash.new }.each do |time, jobs|
output << cron_jobs_of_time(time, jobs)
end
@jobs.each do |mailto, time_and_jobs|
output_jobs = []
time_and_jobs.each do |time, jobs|
output_jobs << cron_jobs_of_time(time, jobs)
end
output_jobs.reject! { |output_job| output_job.empty? }
output << "MAILTO=#{mailto}\n\n" unless output_jobs.empty?
output << output_jobs
end
output.join
end
cron_jobs_of_timeはWhenever::Output::Cron.outputを呼び出します
def cron_jobs_of_time(time, jobs)
shortcut_jobs, regular_jobs = [], []
jobs.each do |job|
next unless roles.empty? || roles.any? do |r|
job.has_role?(r)
end
Whenever::Output::Cron.output(time, job, :chronic_options => @chronic_options) do |cron|
cron << "\n\n"
if cron[0,1] == "@"
shortcut_jobs << cron
else
regular_jobs << cron
end
end
end
shortcut_jobs.join + combine(regular_jobs).join
end
outputメソッドはprocess_templateによってoptionsのハッシュを使って@templateの文字列を置換します。job_templateのデフォルトは/bin/bash -l -c ‘:job’
になるようにsetup.rbでセットされています。runnerでジョブを定義した場合、template変数はcd :path && :bundle_command :runner_command -e :environment ‘:task’ :output
となっています。これらのテンプレートの:xxx
のバインド文字列を置換してcronの設定を出力しています。
module Whenever
class Job
...
def output
job = process_template(@template, @options)
out = process_template(@job_template, @options.merge(:job => job))
out.gsub(/%/, '\%')
end
...
protected
def process_template(template, options)
template.gsub(/:\w+/) do |key|
before_and_after = [$`[-1..-1], $'[0..0]]
option = options[key.sub(':', '').to_sym] || key
if before_and_after.all? { |c| c == "'" }
escape_single_quotes(option)
elsif before_and_after.all? { |c| c == '"' }
escape_double_quotes(option)
else
option
end
end.gsub(/\s+/m, " ").strip
end
標準出力のリダイレクションを指定している場合、options[:output]に値がセットされますが、このoutputはWhenever::Output::Redirectionのインスタンスが入ります。このクラスはto_sメソッドを実装していて、置き換え時に>> #{stdout}
といったようなリダイレクトの文字列に置き換えられます。
require 'shellwords'
module Whenever
class Job
attr_reader :at, :roles, :mailto
def initialize(options = {})
@options = options
@at = options.delete(:at)
@template = options.delete(:template)
@mailto = options.fetch(:mailto, :default_mailto)
@job_template = options.delete(:job_template) || ":job"
@roles = Array(options.delete(:roles))
@options[:output] = options.has_key?(:output) ? Whenever::Output::Redirection.new(options[:output]).to_s : ''
@options[:environment_variable] ||= "RAILS_ENV"
@options[:environment] ||= :production
@options[:path] = Shellwords.shellescape(@options[:path] || Whenever.path)
end
# ...
module Whenever
module Output
class Redirection
def to_s
return '' unless defined?(@output)
case @output
when String then redirect_from_string
when Hash then redirect_from_hash
when NilClass then ">> /dev/null 2>&1"
when Proc then @output.call
else ''
end
end
最後に書き込みのオプションを指定している場合は、Whenever::CommandLine#write_crontabでcrontabのコマンドを使って設定しています。出力したcron設定を標準入力としてIO.popenのcrontabプロセスに食わせています
module Whenever
class CommandLine
# ...
def write_crontab(contents)
command = [@options[:crontab_command]]
command << "-u #{@options[:user]}" if @options[:user]
# Solaris/SmartOS cron does not support the - option to read from stdin.
command << "-" unless OS.solaris?
IO.popen(command.join(' '), 'r+') do |crontab|
crontab.write(contents)
crontab.close_write
end
success = $?.exitstatus.zero?
if success
action = 'written' if @options[:write]
action = 'updated' if @options[:update]
puts "[write] crontab file #{action}"
exit(0)
else
warn "[fail] Couldn't write crontab; try running `whenever' with no options to ensure your schedule file is valid."
exit(1)
end
end