项目目标
本项目解析完整的五字段 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');
});