2018-01-06

wheneverコードリーディング

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
このエントリーをはてなブックマークに追加