Skip to content

Instantly share code, notes, and snippets.

@treby
Last active November 30, 2016 03:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save treby/78a1c6bf9c145970ea8f1e4af2cbc73d to your computer and use it in GitHub Desktop.
Save treby/78a1c6bf9c145970ea8f1e4af2cbc73d to your computer and use it in GitHub Desktop.
Shinjuku.rb#43 research -- javan/whenever

Wheneverの調査

Shinjuku.rb #43

Wheneverとは、明快なシンタックスとcron jobを提供するgemである。

使い方

インストール

グローバルにinstall

$ gem install whenever

or Gemfileに書く

+ gem 'whenever', require: false

DSLを記述

$ wheneverize .

config/schedule.rb に設定ファイルができる

書き方

set :output, "/path/to/my/cron_log.log"

every 2.hours do
  command "/usr/bin/some_great_command"
  runner "MyModel.some_method"
  rake "some:great:rake:task"
end

みたいなDSLで書ける。

cronの記述に変換 / 実際にcrontabを更新する

$ wheneverize --update-crontab

直感的なDSLでcronを書けるよ!

実装

大体以下のようにできている

DSLの解釈

(A)の部分にて、 https://github.com/javan/whenever/blob/c7675c81d34131f7107b2930c207c9b994895a08/lib/whenever/job_list.rb#L24

instance_eval(setup, setup_file)

とある。

読み込んだ中身をそのままevalしている -> lib/whenever/job_list.rbのメソッドがそのまま、DSLとして使える

  • set
  • env
  • every
  • job_type

この中でもset, job_typeはメソッドの中でさらにメソッドを定義するというメタプロなことをしている

job_type はどういうテンプレートを使うか定義できるものだが、defaultのDSLsetup.rb内で下記4つが動的に定義されている

  • command
  • rake
  • script
  • runner

everyメソッドはブロックをスコープのように扱うことでcronの時間情報を閉じ込めている(@current_time_scopeに時間情報を持っている) つまり、

every 2.days do
  command 'echo "hello, world!!"'
end

のようなDSLは、@current_time_scope = 2.daysとして、その後のparseが行われる。

crontabの更新

  • lib/whenever/command_line.rb内でやっている  - 特定のマジックコメントでどの範囲を更新するか制御している

wheneverの使い所

Schedulingツールを使う上で考慮すべきこと:処理の重複(ロック)

  • プロセス内ロック  - 単一のプロセス内では重複しないことが保証されている
  • プロセス間ロック・サーバ内ロック  - 同一のサーバ内では処理が重複しないことが保証されている
  • サーバ間ロック  - サーバ群の中で処理が重複しないことが保証されている

wheneverはあくまでcrontabを更新するためのgem。上の内では「プロセス間ロック・サーバ内ロック」にあたる。単一のサーバでのみ稼働するシステムでは有用だが、複数サーバを使うようなケースでは、どのサーバで稼働させるかを考慮しなければならない。

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::JobListを見てみる

@treby
Copy link
Author

treby commented Nov 27, 2016

    def initialize(options)
      @jobs, @env, @set_variables, @pre_set_variables = {}, {}, {}, {}

      if options.is_a? String
        options = { :string => options }
      end

      pre_set(options[:set])

      @roles = options[:roles] || []

      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

@treby
Copy link
Author

treby commented Nov 27, 2016

    def pre_set(variable_string = nil)
      return if variable_string.nil? || variable_string == ""

      pairs = variable_string.split('&')
      pairs.each do |pair|
        next unless pair.index('=')
        variable, value = *pair.split('=')
        unless variable.nil? || variable == "" || value.nil? || value == ""
          variable = variable.strip.to_sym
          set(variable, value.strip)
          @pre_set_variables[variable] = value
        end
      end
    end
  • pre_set methodによって、--setで指定されたvariableがセットされる key1=value1&key2=value2...の形式
    • set methodは何の用途に使われているのだろう
  • roleで何やら指定している
  • setup.rbを読んでinstance_evalしている → ここでsetが出て来る。

@treby
Copy link
Author

treby commented Nov 27, 2016

set -> instance_variableとして設定されている

@treby
Copy link
Author

treby commented Nov 27, 2016

かつ、attr_readerが設定されている

@treby
Copy link
Author

treby commented Nov 27, 2016

set methodとjob_typeメソッドが肝か?

@treby
Copy link
Author

treby commented Nov 27, 2016

job_type:4つある

  • command
  • rake
  • script
  • runner

@treby
Copy link
Author

treby commented Nov 27, 2016

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"

@treby
Copy link
Author

treby commented Nov 27, 2016

setup を読み込んだ後にschedule (file or string)を読み込んでいる(by instance_eval)

@treby
Copy link
Author

treby commented Nov 27, 2016

instance_evalの第二引数にファイル名を与えられるっぽい
https://docs.ruby-lang.org/ja/latest/method/BasicObject/i/instance_eval.html

@treby
Copy link
Author

treby commented Nov 27, 2016

つまり、job_listのpublicメソッドがそのまま、wheneverのDSLとして使える

@treby
Copy link
Author

treby commented Nov 27, 2016

singleton_classってなんだ?

http://ref.xaio.jp/ruby/classes/object/singleton_class

singleton_classメソッドは、オブジェクトの特異クラスを返します。

特異クラスとは

http://qiita.com/fukumone/items/95117f418dec590ebbc8
特異メソッドのためのクラス:特異メソッド → オブジェクトに直接生やしたやつ、つまりsetで拡張したクラスのことかしら

@treby
Copy link
Author

treby commented Nov 27, 2016

ファイルを解釈したら、generate_cron_outputを実行する

def generate_cron_output
  [environment_variables, cron_jobs].compact.join
end

@treby
Copy link
Author

treby commented Nov 27, 2016

environment_variables

cronの環境変数類

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_job

def cron_jobs
  return if @jobs.empty?

  shortcut_jobs = []
  regular_jobs = []

  output_all = roles.empty?
  @jobs.each do |time, jobs|
    jobs.each do |job|
      next unless output_all || roles.any? do |r|
        job.has_role?(r)
      end
      Whenever::Output::Cron.output(time, job) do |cron|
        cron << "\n\n"

        if cron[0,1] == "@"
          shortcut_jobs << cron
        else
          regular_jobs << cron
        end
      end
    end
  end

  shortcut_jobs.join + combine(regular_jobs).join
end

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::Output::Cron がcronの変換処理の実体か
その前に@jobsの実体を調べる

@treby
Copy link
Author

treby commented Nov 27, 2016

@jobsについて

  • job_type methodの中定義されているメソッド内でセットされている(デフォルトでcommand, rake, script, runner)。
  • current_time_scopeごとに登録されている→ 同一時間内でまとめるためだろう

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::Job.newなるものが出てきたぞ

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::Job#initialize

    def initialize(options = {})
      @options = options
      @at                               = options.delete(:at)
      @template                         = options.delete(:template)
      @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

@treby
Copy link
Author

treby commented Nov 27, 2016

一旦戻ってWhenever::Output::Cron を見る (lib/whenever/cron.rb)

@treby
Copy link
Author

treby commented Nov 27, 2016

outputメソッド / timesにはcurrent_time_scopeが入る

def self.output(times, job)
  enumerate(times).each do |time|
    enumerate(job.at, false).each do |at|
      yield new(time, job.output, at).output
    end
  end
end

@treby
Copy link
Author

treby commented Nov 27, 2016

まず、enumerateから / timeを解釈しているのかな

def self.enumerate(item, detect_cron = true)
  if item and item.is_a?(String)
    items =
      if detect_cron && item =~ REGEX
        [item]
      else
        item.split(',')
      end
  else
    items = item
    items = [items] unless items and items.respond_to?(:each)
  end
  items
end

@treby
Copy link
Author

treby commented Nov 27, 2016

次に#initializeをみる

def initialize(time = nil, task = nil, at = nil)
  @at_given = at
  @time = time
  @task = task
  @at     = at.is_a?(String) ? (Chronic.parse(at) || 0) : (at || 0)
end

つまり、少なくとも組み込みクラスの範囲において、===演算子が==演算子よりも厳密な同値判定であるケースは皆無というわけです。Rubyにおける===演算子は、他の言語における===演算子とは全く別物と割り切るべきである、と言えます。

へー

@treby
Copy link
Author

treby commented Nov 27, 2016

timeはcurrent_time_scopeが、atはjobが持っているパラメータ

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::Output::Cron#outputをみる

def output
  [time_in_cron_syntax, task].compact.join(' ').strip
end

@treby
Copy link
Author

treby commented Nov 27, 2016

      def time_in_cron_syntax
        @time = @time.to_i if @time.is_a?(Numeric) # Compatibility with `1.day` format using ruby 2.3 and activesupport
        case @time
          when REGEX  then @time # raw cron syntax given
          when Symbol then parse_symbol
          when String then parse_as_string
          else parse_time
        end
      end

こんなcase文の書き方できるんや
http://qiita.com/giiko_/items/c75833cd47e41c9b8605

@treby
Copy link
Author

treby commented Nov 27, 2016

@treby
Copy link
Author

treby commented Nov 27, 2016

基本的には、Whenever::Output::Cronで組み立てられているのか

@treby
Copy link
Author

treby commented Nov 27, 2016

Whenever::JobList#generate_cron_output

cron_jobs

@treby
Copy link
Author

treby commented Nov 27, 2016

shortcut_jobs / regular_jobsの違い?

@treby
Copy link
Author

treby commented Nov 30, 2016

  • プロセス内ロック
    • それぞれのプロセスで実行したい
  • プロセス間ロック
    • サーバ内でユニークにしたい(crontab)
    • file lock
  • サーバ間ロック
    • サーバ群でユニークにしたい

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment