手写 cron 解析器:五段、并集规则,以及 2 月 30 日
cron 的语法半小时能写完,难的是语义:日与星期同时限定时是并集而非交集,不存在的时间要有终止条件,7 与 0 都是星期天。
cron 工具 要回答的问题是「这个表达式接下来五次什么时候跑」。解析语法只占一小半工作量,剩下的全在语义细节上。
语法:五段,每段一个集合
分 时 日 月 星期,每段支持 *、单值、区间 a-b、步长 */s 与 a/s、逗号列表,月与星期还认名字(JAN、MON)。7 等价于 0(都是星期天),这是 POSIX 留下的兼容项。
实现上不要保留表达式树,直接把每段展开成数字集合 —— 后面查「下一次」时只需要 has():
function parseField(text, min, max, names) {
const out = new Set();
for (const part of text.split(',')) {
const [range, stepText] = part.split('/');
const step = stepText === undefined ? 1 : Number(stepText);
if (!Number.isInteger(step) || step <= 0) return { error: 'step' };
let from = min;
let to = max;
if (range !== '*') {
const [a, b] = range.split('-');
from = value(a, names);
to = b === undefined ? (stepText === undefined ? from : max) : value(b, names);
}
if (from < min || to > max || from > to) return { error: 'range' };
for (let v = from; v <= to; v += step) out.add(v);
}
return { values: out };
}
注意 a/s 的语义:5/20 表示「从 5 开始,每 20 步,到该段上限为止」,也就是 5, 25, 45,而不是「每 20 个里取第 5 个」。这两种解释在不同实现里都出现过,POSIX 是前者。
日与星期:并集,不是交集
这是 cron 最反直觉的一条:日与星期都有限定时,任一匹配即执行(并集)。只有当其中一个写成 * 时,另一个才起约束作用。写成交集是很常见的错误实现,会让任务比预期少跑很多次。
不存在的时间
0 0 30 2 *(2 月 30 日)永远不会有下一次。实现里必须有终止条件 —— 我的做法是按天推进,最多推进 366×5 天,超过就返回空数组。没有这个上限,界面会卡在那里转圈,而不是诚实地告诉你「永不」。
夏令时也会制造不存在和重复的时刻,本站按本地时钟语义处理,并且不尝试去重。
推进策略
按天推进比按分钟推进简单得多:先找到匹配的日期(日、月、星期三个集合都 has),再看当天第一个匹配的时刻;如果那个时刻已经过去,就跳到下一天。按分钟推进要循环几十万次,按天只需要几十次。
顺带做的一件小事
历史记录:最近用过的表达式存在 localStorage,最多八条,新的插到最前并去重。这不是 cron 的功能,但它把「再查一次刚才那个表达式」从十秒变成了两次点击。
表达式解析是半小时的事;语义细节(并集、步长起点、不存在的时间)才是这个工具的全部价值。

评论
…