1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

cron 式を GUI で組み立てるツールを作った — 「逆方向」は意外と書きやすい

1
Posted at

きっかけ

ポートフォリオ 1 件目の Cron TZ Viewer は「cron 式 → 次回実行時刻」を各 TZ で並べるツールでした。使ってるうちに気づいたのは、多くの人が困っているのはその前の段階だということ:

「毎週月曜 9:00 に実行したい。cron でどう書く?」

これを手打ちで書けるのは cron に慣れている人だけで、「あれ、曜日は左から何番目だっけ」「日曜は 0 それとも 7?」と毎回調べ直します。

構造(UI)から cron 式を組み立てる方向のツールが欲しい。というわけで姉妹作を作りました。入力と出力を逆にしただけ、と思いきや、こっちは cron パーサよりもずっと書きやすいという発見付き。

作ったもの

Cron Builderhttps://sen.ltd/portfolio/cron-builder/

スクリーンショット

5 フィールド(分 / 時 / 日 / 月 / 曜日)それぞれに 6 モード:

  • 毎回 (*)
  • 〜ごと (*/5)
  • 特定の値 (30)
  • リスト (0,15,30,45)
  • 範囲 (9-17)
  • 範囲+間隔 (9-17/2)

入力を変えるたびに、cron 式日本語/英語の説明が即時更新されます。出来上がったら "[次回実行時刻を見る →]" ボタンで Cron TZ Viewer に渡して実際の発火時刻を確認。

vanilla JS + HTML + CSS、ゼロ依存、ビルドツール不要。ロジックは 160 行(大半は日英の label テーブル)。node --test で 14 ケース。

パースより構築のほうが圧倒的に簡単

cron のパーサを書くと、*/5, 1-10, 1,3,5, 1-20/3, *, 数字 の 6 パターンを文字列から判別する必要があります。正規表現のオンパレードで、エラーメッセージも多い。

構築の場合、ユーザーが明示的にモードを選ぶので、switch 1 発で済みます:

export function buildField(config) {
  if (!config || typeof config !== 'object') return '*'
  switch (config.mode) {
    case 'any':      return '*'
    case 'every':    return `*/${config.step}`
    case 'value':    return String(config.value)
    case 'list':     return config.values.join(',')
    case 'range':    return `${config.from}-${config.to}`
    case 'rangeStep':return `${config.from}-${config.to}/${config.step}`
    default:         return '*'
  }
}

入力の型が構造化されているので、「これは範囲なのか毎回なのか」を推測する必要がない。パーサの仕事が全部 UI 側のモード選択に移ったことで、ロジックがここまで短くなる。

これは双方向ツールを書くと気づく構造で、「入力が自然言語なら難しい、入力が構造化 AST なら簡単」という、パーサ vs ジェネレータの対称性の話。同じ問題を逆から解くと、だいぶ景色が違います。

buildCron は buildField を 5 回呼ぶだけ

トップレベル関数はただの合成:

export function buildCron({ minute, hour, dom, month, dow }) {
  return [
    buildField(minute),
    buildField(hour),
    buildField(dom),
    buildField(month),
    buildField(dow),
  ].join(' ')
}

引数オブジェクトの destructuring で 5 つのフィールドを受け取って、それぞれ buildField して space で結合。cron 式がフィールド独立な設計になっているから、ここまで素直に書ける。

これが例えば AWS Events のスケジュール式のように「日と曜日のどちらかしか使えない」というクセがあると、もうちょっと条件分岐が要ります。crontab は純粋な直積なのが救い。

説明テキストはフィールド × モードの二次元テーブル

cron 式の隣に「毎週月曜 9:00」みたいな日本語説明を出すと、組み立てた直後にフィードバックが得られるので安心感が違います。この説明を生成するのは、フィールドとモードの二次元テーブルを引くだけ:

const LABELS = {
  ja: {
    minute: {
      any: '毎分',
      every: (n) => `${n}分ごと`,
      value: (n) => `${n}分に 1 回`,
      list: (vals) => `${vals.join(', ')}分`,
      range: (a, b) => `${a}${b}分の間`,
      rangeStep: (a, b, s) => `${a}${b}分の間、${s}分ごと`,
    },
    hour: { /* ... */ },
    dom: { /* ... */ },
    month: { /* ... */ },
    dow: { /* ... */ },
  },
  en: { /* ... */ },
}

export function explainField(config, field, lang = 'ja') {
  const L = LABELS[lang]
  switch (config.mode) {
    case 'any':       return L[field].any
    case 'every':     return L[field].every(config.step)
    case 'value':     return L[field].value(config.value)
    case 'list':      return L[field].list(config.values)
    case 'range':     return L[field].range(config.from, config.to)
    case 'rangeStep': return L[field].rangeStep(config.from, config.to, config.step)
  }
}

月は 1月 2月 みたいに名前付きで出すので、value モードの実装だけ専用:

const MONTH_JA = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
month: {
  value: (n) => `${MONTH_JA[n - 1]}`,
  // ...
}

曜日も同じ (, , ...)。cron の数字をそのまま見せるのでなく人間語に変換するのは、入門者が cron を覚える道具としての価値を底上げします。

姉妹ツール連動

Builder で組み立てた式を、Viewer 側に URL クエリで渡せる:

const viewerUrl = new URL('https://sen.ltd/portfolio/cron-tz-viewer/')
viewerUrl.searchParams.set('cron', cronString)
window.open(viewerUrl.toString(), '_blank')

Cron TZ Viewer 側は entry 001 の時点で ?cron= クエリをサポートしていたので、何の改修もなしにリンク連動が動きます。URL クエリベースでツールを繋ぐのは、静的サイト同士の軽い協業として強力。状態の共有に JSON API もサーバも要らない。

テスト

node --test で 14 ケース。入出力の対が主:

test('every 5 minutes', () => {
  assert.equal(
    buildCron({
      minute: { mode: 'every', step: 5 },
      hour:   { mode: 'any' },
      dom:    { mode: 'any' },
      month:  { mode: 'any' },
      dow:    { mode: 'any' },
    }),
    '*/5 * * * *'
  )
})

test('weekdays 9 to 17', () => {
  assert.equal(
    buildCron({
      minute: { mode: 'value', value: 0 },
      hour:   { mode: 'range', from: 9, to: 17 },
      dom:    { mode: 'any' },
      month:  { mode: 'any' },
      dow:    { mode: 'range', from: 1, to: 5 },
    }),
    '0 9-17 * * 1-5'
  )
})

test('explain range in ja', () => {
  assert.equal(explainField({ mode: 'range', from: 9, to: 17 }, 'hour', 'ja'), '9〜17時の間')
})

おわりに

SEN 合同会社の ポートフォリオシリーズ 100+ の 12 件目です。シリーズ 1 件目の Cron TZ Viewer と合わせて「cron 双方向ツールセット」として機能します。

バグ報告・改善案、歓迎です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?