手写一个 cron 表达式解析器:六个必须自己验证的边界
为什么要自己写一个 cron 解析器
给一个内部小工具加定时能力时,我先用了现成的 cron 库,结果遇到两个问题:表达式写错时只返回空数组,完全不知道错在哪;*/7 这种不能整除的步长,实际命中时间和我以为的不一样。后来干脆自己写了一个解析器,五百行不到,反而更清楚。下面把其中六个容易出错的边界整理出来。
边界一:步长的起点不是零
*/7 在分钟位上,含义是从 0 开始每 7 分钟,命中 0、7、14、21、28、35、42、49、56,共 9 个值,而不是 60 除以 7 之后取整得到的 8 个。最后一组只剩 4 个,所以总数是 9。
换成 5-30/7,起点变成 5,命中 5、12、19、26。很多人写 1-31/7 期待每 7 天,实际会命中 1、8、15、22、29 共 5 天,多出来的那两天常被忽略。
我的处理方式是把字段展开成具体的取值集合再交给调度器,而不是把表达式原样透传。集合一旦确定,后续判断就是数组包含,不会有歧义。
边界二:日与星期同时指定时的语义分歧
标准 Vixie cron 里,日和星期都不是星号时,两者是或的关系:每月 1 号或每周一都触发。但很多开发者按并且理解,结果任务跑得比预期频繁得多,而 Quartz 又是第三套语义。
我的做法是在界面上直接写清楚当前实现用的是哪一种,并且对两者都指定的情况加一条醒目说明。歧义不可怕,悄悄歧义才可怕。
边界三:不会命中的表达式
0 0 30 2 * 意思是 2 月 30 日 0 点,永远不可能命中。朴素实现会每分钟往前推,一直推到时间溢出才返回空,页面上表现为卡死。
我加了循环上限,60 万次,约等于 416 天,超过就判定为无匹配时间点并提示用户检查日期组合。这个上限不是拍脑袋:一年最多 525600 分钟,取 60 万足够覆盖闰年和跨年,又不会让浏览器失去响应。
边界四:月末与闰日
每月 31 号在 2、4、6、9、11 月都不会触发。这不算 bug,但用户往往期待本月最后一天。我的做法是不偷偷改写语义,而是在解析明细里标出该字段一年中只有 7 个月能命中,让写表达式的人自己决定要不要拆成多条。
闰日同理,0 0 29 2 * 四年才触发一次,界面上直接显示下一次执行时间,一眼就能看出问题。
边界五:时区
cron 表达式本身不带时区。同一个 0 9 * * *,在 UTC 服务器上跑出来是北京时间 17 点。我踩过一次:容器里 TZ 只设了环境变量、系统时区还是 UTC,结果每天 9 点的任务在下午 5 点执行。后来在镜像里同时设 TZ 和 /etc/localtime,并在解析器里显式要求传入时区才解决。
所以解析器对外暴露的接口是 nextRuns(expr, count, timezone),不接受默认本地时区。
边界六:错误提示要指向具体字段
最初的实现是统一抛表达式格式错误,用户只能一个字段一个字段试。改成逐字段校验后,提示变成这样的形式:
- 小时:超出范围 0-23,字段名加取值范围;
- 分钟:无法识别的取值 bad,带上原始输入;
- 日:起点不能大于终点,说明具体规则。
配合把出错的输入框标红,定位成本从猜降到看。
小结
解析阶段把每个字段展开成有序数组,后续全部基于数组判断;时间推算按字段跳跃而不是逐分钟,月份不匹配直接跳到下月 1 号,日不匹配跳到次日 0 点,小时不匹配跳到下一个整点;加循环上限防住永不命中的表达式;时区显式传入,不依赖运行环境;错误提示携带字段名、取值范围和原始输入。
如果只是自己用,一个几百行的解析器足够;要在生产环境跑调度,建议用成熟库,但至少要自己验证一遍上面这六个边界,因为它们的表现往往是任务跑了,但不是你要的那一次。
- 点赞
- 收藏
- 关注作者
评论(0)