Chronoつくった
\* \* \* \* \* T T T T T | | | | `- wday --- 0 .. 6 | | | `--- month -- 1 .. 12 | | `----- day ---- 1 .. 31 | `------- hour --- 0 .. 23 `--------- minute - 0 .. 59
https://github.com/r7kamura/chrono
Rubyでcron形式の構文を利用するために、Chronoというライブラリをつくった。開発動機はRubotyというHubotクローンで利用するためで、チャットからcron形式でジョブを登録することで定期的に発言をしてくれるような機能をつくろうと考えてた (こういうやつ)。
既存のもの
- clockwork - A clock process to replace cron
- rufus-scheduler - Job scheduler for Ruby
- parse-cron - parse crontab syntax & determine next scheduled run
- node-cron - Cron for NodeJS
最初はこの辺の既存ライブラリを参考にした。clockworkはcron形式の構文は使えないけど、crondのように周期的に処理を実行させるような仕組みを備えてる。rufus-schedulerも似たような感じだけど、cron形式の構文が使えたり他にもいろいろな機能がたくさん備わってる。parse-cronは今回つくったものに近くて、cron形式の構文を解析するという機能だけを持ってる。node-cronはNode.js用のやつで、Node界隈だとわりとメジャーなもののように見える。正直既存のものを使えば事足りるけど、調査過程で実装を見たところもう少し理解しやすい実装にできるだろうというところが幾つかあったし、自分でも書いてみたら勉強になるだろうと思ったので、もうちょいわかりやすい実装で実現できるようなのをつくることにした。位置付けで言うとBetter parse-cronという感じ。
どう表現するか
cron形式の構文を解析しようとするとき、解析結果をどういう概念のオブジェクトで表現したら良いかという問題があると思う。ある日時を表す 単純なオブジェクトではダメで、周期的な何かを表すことが必要になる。ISO8601のTime Intervalのような表現を導入する方向性もあるが、結局最終的に日時オブジェクトをどう取り出すかという話になると思う。これは (幾つかのライブラリでもそう実装されているように) Iteratorの概念で扱うのが良さそうだと考えて、スケジュールされた日時の候補のうち現在の時刻からもっとも近い日時を返す、という概念を導入した。
iterator = Chrono::Iterator.new("30 \* \* \* \*") iterator.next #=\> 2000-01-01 00:30:00 iterator.next #=\> 2000-01-01 01:30:00 iterator.next #=\> 2000-01-01 02:30:00
どう解析するか
Chrono::Iteratorというのがそれで、最初に.nextを呼ぶと現在時刻から最も近い予定時刻が返る。更に.nextを呼ぶと次の予定時刻が返るようになってる。Chrono::Iterator自体はcron形式の文字列と前回の予定時刻を覚えておくという仕事だけしかやってなくて、実際に次の予定を計算する処理はChrono::NextTimeというクラスが担当してる。NextTimeのコードがcronの解析処理の本質なので、ここのコードを見ればどういうアルゴリズムで解析するかが書かれてる。
繰り上げ
現在時刻とcron表現を見比べて最適な時刻を割り出すのは意外と難しくて、これは繰り上げのある計算に似ている。例えば 5 22 * * 0 という表現が与えられたとして、現在日時は日曜日の22:59だったとする。曜日のところから見ていくと、日曜日なのでここはOK。次に時間のところを見ると22時なので一致していて、あとはこのとき分も一致していればOKということになるが、次は0分になるまで一致しない訳だから、少なくとも6分先まで待つ必要がある。しかし59分に6分足すということは繰り上げが発生して23時になり、今度は22時という条件から外れてしまうので、再度時間が一致するために23時間先まで待つ必要がある。しかし23時間先ということは月曜になってしまうので、日曜という条件から外れてしまう...、というように、分 時 日 月 曜というそれぞれの条件に一致するように次の予定時刻の各要素を先に進めていくと、繰り上げが発生してまた要素を再計算する必要が出てきてしまう。
繰り返し
前述の理由からある要素を固定して確定させていくという方法は難しいということが分かったので、 単位の大きな要素から調べていって一致しなければ1つ繰り上げて最初からやり直す、というふうに繰り返して調べていくという方針に変更した。最終的に全ての要素が一致する日時まで時計を進め続ければ、やがて繰り返しが止まって結果が返せる。この繰り返し処理の様子を端的に表しているコードを抜粋するとこういう感じになっている。
class Chrono::NextTime def to\_time loop do case when !scheduled\_in\_this\_month? carry\_month when !scheduled\_in\_this\_day? carry\_day when !scheduled\_in\_this\_wday? carry\_day when !scheduled\_in\_this\_hour? carry\_hour when !scheduled\_in\_this\_minute? carry\_minute else break time end end end end
クラスの関係性
コード上では幾つかのクラスが木構造状の関係性を持っている。chrono.rbを読み込むとその他の全てのファイルが読み込まれる。IteratorとNextTimeはさっき言った通りで、iterator.nextが呼ばれるたびにNextTimeオブジェクトが生成されて最適な時刻を返す。NextTimeの計算処理のためにScheduleというクラスが使われている。Scheduleは与えられた 30 * * * * とかの文字列を表現するためのオブジェクト。更に1つずつのFieldを表すために、DayクラスとかMinuteクラスとかが用意してある。前述した繰り返し処理では、現在時刻の月の部分が予定時刻と一致しているかを調べることが重要 (一致していなければ繰り上げ) になってくるので、例えばMonthクラスは "*" を [1, 2, 3, ..., 12] という候補に変換したりという便利機能を担っている。
$ wc -l lib/\*\*/\*.rb 11 lib/chrono.rb 65 lib/chrono/fields/base.rb 11 lib/chrono/fields/day.rb 11 lib/chrono/fields/hour.rb 11 lib/chrono/fields/minute.rb 11 lib/chrono/fields/month.rb 11 lib/chrono/fields/wday.rb 16 lib/chrono/iterator.rb 81 lib/chrono/next\_time.rb 35 lib/chrono/schedule.rb 37 lib/chrono/trigger.rb 3 lib/chrono/version.rb 303 total
クロノトリガー
Chronoと言えばクロノトリガーだということで、何とかしてChrono::Triggerという概念を生み出すことにした。TriggerはIteratorに眠る機能と働く機能が付いたもので、初期化時に与えた処理を予定時刻まで待ってから実行してくれる。onceを使うと1回のみ実行し、runを使うと繰り返し実行し続ける。
trigger = Chrono::Trigger.new("30 \* \* \* \*") { Time.now } trigger.once #=\> 2000-01-01 00:30:00 trigger.run #=\> 2000-01-01 01:30:00 #=\> 2000-01-01 02:30:00 #=\> 2000-01-01 03:30:00 #=\> ...
クロノトリガーにはいろいろ思い出が多い。元々RPG好きだった父親が買ってくれたゲームで、相当幼い頃から既に家にあった気がする。確か最初は父親がプレイする様子を見ていて、ボス戦の音楽が怖くて毛布から顔だけ出して父親の背中に隠れて見ていたという記憶がある。その後小学校で話が会う友達ができて、そいつとはよくスクウェアのゲームの話をしてたんだけど、中でもクロノトリガーに出てくる台詞を絡めた冗談を言い合うことが多かった。中学や高校に上がってからもそんな調子で、2人で2chのゲーム実況板に入り浸ったり、Skypeで夜中までゲームしながら延々話したりしてた。思い出すと良かった記憶がどんどん出てきて懐かしくなる一方、いまはもう連絡を取り合ってないという事実があって、物哀しさで心が少し痛む。結局のところ、どのような強い想いも、長い時間軸の中でゆっくりと変わっていってしまうのだろうか。