有一类工程问题,乍看不起眼,却会在系统复杂度增长后造成真实的用户伤害。
Cron 表达式解析就是这样一个问题。
Cloudflare 在为 Workers 开发 Cron Triggers 功能时,遇到了一个典型的多语言栈困境:前端、API 层、后端运行时各自使用了不同的解析器,而 Cron 表达式本身没有统一的规范,各个解析器对扩展语法的支持各有不同。最终的结果是:四套解析器,四种行为,用户体验一团糟,甚至出现了一个"差一位"的静默 Bug,让用户填的周一到周五,在后端变成了周日到周四。
这篇文章记录了他们如何用 Rust 和一个叫 Saffron 的自研库,一步步把四个解析器收归为一个。
Cron 表达式:看起来简单,实则是个无主之地
Cron 表达式是一种用于描述定时规则的格式,由若干字段组成,分别表示分钟、小时、月份中的某天、月份、星期几。写法类似这样:
*/5 * * * * 每 5 分钟执行一次
0 9 * * MON-FRI 每个工作日早上 9 点执行
0 0 L * * 每月最后一天午夜执行
基础语法只有几种构造:星号(所有值)、具体数值、范围(0-30)、集合(0,15,30,45)。
但在基础语法之上,各个平台和工具衍生出了大量扩展:
L:在月份字段中表示"本月最后一天",在星期字段中表示"本月最后一个某星期X"W:表示"距某日期最近的工作日",如15W表示距 15 号最近的工作日/:步长语法,如*/5表示每隔 5 个单位,30-59/5表示从 30 到 59 每隔 5 个#:表示"第 N 个星期X",如5#3表示本月第 3 个周四
除此之外,还有 Jenkins 的 H(随机化执行时间以分散负载)、部分实现里的 ?(表示启动时间)等。
关键在于,这些扩展没有任何标准化组织来统一规范。每个库爱支持哪些就支持哪些,爱怎么解释就怎么解释。这在单一语言栈里已经够烦了,在多语言栈里会直接变成系统性灾难。
四个解析器是怎么凑在一起的
Cloudflare 的 Cron Triggers 系统横跨三层:
- 前端 UI:用 JavaScript 编写,需要展示"这个 Cron 表达式是什么意思"的人类可读描述,以及"接下来五次执行时间"
- API 层:用 Go 编写,负责接收用户提交的 Cron 表达式并验证合法性
- 后端运行时:用 Rust 编写,实际调度和执行定时任务
在时间压力下,团队对每一层分别找了现成的解决方案:
前端为了显示"描述"和"未来执行时间",用了两个不同的 JavaScript 库,因为没有哪一个库同时把这两件事都做好。API 层用了一个现成的 Go Cron 库做验证。后端因为找不到满足需求的 Rust crate,自己用 nom 写了一个,命名为 Saffron。
结果:四套解析器同时运行在同一个功能上。
问题随之而来。两个前端 JS 库各自支持不同的扩展语法,用户有时能在 UI 里成功填写一条表达式,提交后却被 API 拒绝;另一些时候,UI 因为某个库不支持某个扩展而拒绝了用户的输入,但这条表达式其实完全合法,API 和后端都能接受。
API 层使用的 Go 库接受范围比后端运行时更宽,导致部分表达式能成功保存,但调度时静默失败——触发器存在,但从不执行。
为了临时修复,团队做了两件事:在后端运行时增加了一个专门读取 stdin 来验证表达式的入口,API 调用它来确保双方行为一致;同时暴露了一个验证接口给前端调用。
这个方案能用,但代价不小:每次前端验证一条表达式,要在 JavaScript 里解析两次(一次算描述,一次算未来时间),再发一个网络请求到 API,API 再启动一次调度器进程来解析,整个链路又长又贵。
差一位的 Bug:星期几从 0 开始还是从 1 开始
在这团混乱里,还藏着一个更具体的问题。
Saffron 的实现参考了 Quartz 调度器的 Cron 解析规范,Quartz 规定星期几从 1 开始(1 表示周日,7 表示周六)。而前端两个 JS 库遵循的是原始 Unix Cron 的约定,从 0 开始(0 和 7 都表示周日,1 到 6 表示周一到周六)。
用户在 UI 里输入 1-5,按照 JS 库的逻辑,这是周一到周五。界面上也是这么告诉用户的。
但提交到后端之后,Saffron 按照自己的规范来解释,1 表示周日,5 表示周四——实际执行的是周日到周四。
这个 Bug 在测试阶段被漏掉了,上线后由论坛里细心的社区成员发现并反馈。
修复起来有些棘手:提供描述功能的 JS 库支持切换星期编号方案,但提供未来时间计算的那个库不支持。恰好此时 Saffron 的开发已经进行了一半,正好可以用来替换掉那个不够灵活的 JS 库——但 Saffron 还没有前端可用的 WebAssembly 绑定,没时间立刻写完整的 wasm-bindgen 封装。
Workers 自己救了自己
这时候,Cloudflare Workers 本身成了解决问题的工具。
团队花了一周时间,把 Saffron 编译为 WebAssembly,加上几个简单的 wasm 入口函数,部署成一个 Worker,前端直接调用。不需要回到遥远的数据中心,最近的 PoP 节点就能响应,延迟极低。
验证逻辑全部写在 Rust 里:
#[wasm_bindgen]
pub fn validate(crons: JsArray) -> ValidationResult {
// 解析每个 cron 表达式
// 检测重复项
// 返回错误信息或成功结果
}
HTTP 请求的处理逻辑写在 JavaScript 里,两部分各司其职,通过 wasm-bindgen 胶水代码连接。
这个方案一周内上线,解决了前端与后端之间的解析行为不一致问题,同时顺带支持了 L 和 W 等之前因为旧 JS 库不支持而无法使用的扩展语法。
目标:全栈只用一个解析器
修完这个问题后,系统里还剩两个解析器:Saffron 覆盖了后端运行时和前端 Worker,另一个 JS 库仍然负责生成人类可读描述。
Cloudflare 的最终目标是把这个数字降到一——整个栈只有 Saffron,一个真正的单一事实来源。
实现路径已经清晰:
- 把 Saffron 编译为 WASM 并通过
wasm-pack打包,直接在浏览器本地运行,不需要任何网络请求。前端验证、未来时间计算,全部在本地完成。 - 通过 Rust 的 C FFI 能力,将 Saffron 暴露为一个 C 接口,Go 的 API 层通过
cgo调用,彻底替换掉原来的 Go Cron 库。 - 在 Saffron 内部补充 Cron 描述生成功能,替换掉前端剩余的那个 JS 库。
到那时,无论是浏览器里的实时验证、API 层的合法性检查,还是后端的实际调度执行,都由同一份 Rust 代码处理,不可能出现解析行为不一致的情况。
为什么这个问题值得认真对待
Cron 表达式的不一致性问题,在大多数系统里都被低估了。
如果系统规模小,或者用 Cron 的功能只是内部工具,差异往往不会被发现。但当这个功能是用户直接操作的产品入口时——用户写 Cron 表达式,UI 实时反馈,API 验证,调度器执行——每一层的解析行为都必须完全一致,否则用户看到的和实际发生的就会出现偏差,而这种偏差往往以最隐蔽的方式出现:任务静默不执行,或者执行时间和预期不符。
这篇博客真正说明的问题,不只是"如何解析 Cron 表达式",而是在多语言栈里维护同一套业务逻辑的代价。每引入一个新的解析器,就引入了一个潜在的差异源。而 Rust 的跨语言生态——WebAssembly 绑定(面向前端和 Workers)、C FFI(面向 Go 等语言)——提供了一条真正意义上的"写一次,到处复用"的路径,而不是让工程师在不同语言里反复实现同一套逻辑,然后祈祷它们行为一致。
Saffron 已开源
目前 Saffron 已经在 GitHub 开源,支持通过 crates.io 作为 Rust 库使用,也可以通过 npm 使用 WebAssembly 绑定版本。