← 中文资源

简体中文版本

构建 Cron 表达式解析器与作业运行器

解析五字段 cron 表达式、计算下次运行时间、按计划执行注册作业并打印未来若干次运行。

阅读英文原文

项目目标

本项目解析完整的五字段 cron 语法并计算可核对的下次运行时间,避免范围、步长与列表组合带来的常见陷阱。运行器调度已注册作业并记录每次执行,干跑模式可打印任意表达式的未来若干次运行,便于在信任计划前先行确认。

核心特性

  • 解析含范围、步长、列表与通配符的五字段表达式
  • 从给定时刻计算下次匹配时间
  • 校验表达式并对畸形字段给出清晰消息
  • 打印任意表达式的未来 N 次运行

架构与分层

字段解析器把五个字段各自转成允许值集合;匹配器用这些集合检验时间戳;下次运行计算从一个起点逐分钟推进;运行器据计算出的时间调度注册作业。全部时间逻辑接受显式起点,使测试确定。

实施阶段

六个阶段为:字段解析、计划匹配、下次运行计算、作业运行器、CLI、测试与加固。跨月边界推进是日期算术最容易出错之处。

测试与验收

用 vitest 运行。解析测试覆盖普通值、范围、步长、列表与通配符以及畸形字段;下次运行测试对固定起点断言若干表达式的下次时间,含跨月翻页;一个永不匹配的表达式应返回空而非挂起。

延伸方向

秒与年字段、时区与持久化作业注册都是解析器与运行器外围的增量。匹配是日期上的纯谓词,因此新字段同时扩展解析器与匹配器。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function nextRun(schedule, from = new Date()) {
  const candidate = new Date(from.getTime() + 60_000 - (from.getTime() % 60_000));
  for (let guard = 0; guard < 366 * 24 * 60; guard++) {
    if (schedule.matches(candidate)) return candidate;
    candidate.setTime(candidate.getTime() + 60_000);
  }
  return null;
}
mkdir cron-runner && cd cron-runner
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "cron-runner",
  "type": "module",
  "scripts": {
    "cron": "node src/cli.js",
    "test": "vitest run"
  }
}
function parseField(field, { min, max }) {
  const values = new Set();
  for (const part of field.split(',')) {
    const [range, stepRaw] = part.split('/');
    const step = stepRaw ? Number(stepRaw) : 1;
    if (!Number.isInteger(step) || step < 1) throw new Error('bad step: ' + part);
    let lo; let hi;
    if (range === '*') { lo = min; hi = max; }
    else if (range.includes('-')) { [lo, hi] = range.split('-').map(Number); }
    else { lo = hi = Number(range); }
    if (Number.isNaN(lo) || Number.isNaN(hi) || lo < min || hi > max || lo > hi) throw new Error('bad range: ' + part);
    for (let v = lo; v <= hi; v += step) values.add(v);
  }
  return values;
}
import { parseField } from './parse.js';

export function parseSchedule(expression) {
  const fields = expression.trim().split(/\s+/);
  if (fields.length !== 5) throw new Error('expected 5 fields, got ' + fields.length);
  const [minute, hour, day, month, weekday] = fields;
  const sets = {
    minute: parseField(minute, { min: 0, max: 59 }),
    hour: parseField(hour, { min: 0, max: 23 }),
    day: parseField(day, { min: 1, max: 31 }),
    month: parseField(month, { min: 1, max: 12 }),
    weekday: parseField(weekday, { min: 0, max: 6 }),
  };
  return {
    expression,
    matches(date) {
      return sets.minute.has(date.getMinutes()) && sets.hour.has(date.getHours()) &&
        sets.day.has(date.getDate()) && sets.month.has(date.getMonth() + 1) &&
        sets.weekday.has(date.getDay());
    },
  };
}
export function nextRun(schedule, from = new Date()) {
  const start = new Date(from.getTime());
  start.setSeconds(0, 0);
  start.setTime(start.getTime() + 60_000);
  for (let guard = 0; guard < 366 * 24 * 60; guard++) {
    if (schedule.matches(start)) return new Date(start.getTime());
    start.setTime(start.getTime() + 60_000);
  }
  return null;
}

export function nextRuns(schedule, count, from = new Date()) {
  const runs = [];
  let cursor = from;
  for (let i = 0; i < count; i++) {
    const next = nextRun(schedule, cursor);
    if (!next) break;
    runs.push(next);
    cursor = next;
  }
  return runs;
}
import { nextRun } from './nextRun.js';

export function createRunner({ now = () => new Date(), setTimeoutImpl = setTimeout, log = console.log }) {
  const jobs = [];
  return {
    register(name, expression, task) { jobs.push({ name, expression, task, schedule: null }); },
    async runDue(at = now()) {
      const executed = [];
      for (const job of jobs) {
        if (job.schedule.matches(at)) { await job.task(); executed.push(job.name); log('ran ' + job.name + ' at ' + at.toISOString()); }
      }
      return executed;
    },
    armAll() {
      for (const job of jobs) {
        const next = nextRun(job.schedule, now());
        const delay = Math.max(0, next.getTime() - now().getTime());
        setTimeoutImpl(() => this.runDue(next), delay);
      }
    },
  };
}
import { parseSchedule } from './schedule.js';
import { nextRuns } from './nextRun.js';

const [command, expression, countRaw] = process.argv.slice(2);
try {
  if (command === 'next') {
    const schedule = parseSchedule(expression);
    for (const run of nextRuns(schedule, Number(countRaw ?? 5))) console.log(run.toISOString());
  } else {
    console.error('usage: cron next "<expr>" [count]');
    process.exit(1);
  }
} catch (err) {
  console.error('invalid expression: ' + err.message);
  process.exit(1);
}
import { parseSchedule } from '../src/schedule.js';
import { nextRun } from '../src/nextRun.js';

test('daily at 09:30 resolves to the next 09:30', () => {
  const schedule = parseSchedule('30 9 * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:00:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-02T09:30');
});

test('every 15 minutes crosses an hour boundary', () => {
  const schedule = parseSchedule('*/15 * * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:52:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-01T11:00');
});