欢迎光临
我们一直在努力

用代码实现 Cron 表达式解析器:从校验到下次执行时间计算

网上 Cron 表达式校验工具很多,但你想过自己实现一个吗?本文从零拆解 Cron 解析器的核心逻辑:字段解析、语法校验、中文解释生成、下次触发时间计算,每个模块配 TypeScript 完整代码。

整体架构

一个完整的 Cron 解析器需要三个核心能力:

能力函数作用
校验 validateCron() 检查语法是否合法、取值是否越界
解释 explainCron() 将表达式翻译成人类可读的中文
预测 getNextTriggerTimes() 计算未来 N 次触发时间

三个函数的关系是串行的:先校验,校验通过才解析和解释,最后计算触发时间。所有函数都是纯函数,不依赖运行时环境,方便单元测试。

先定义基础类型:

export type CronMode = '5-field' | '6-field'

export interface CronValidationResult {
valid: boolean
error?: string
errorField?: string // 出错字段名
}

5 段格式是 分 时 日 月 周,6 段格式多一个秒字段。用字段定义表统一管理取值范围:

interface FieldDef {
name: 'sec' | 'min' | 'hour' | 'day' | 'month' | 'week'
label: string
min: number
max: number
}

const FIELD_DEFS_5: FieldDef[] = [
{ name: 'min', label: '分', min: 0, max: 59 },
{ name: 'hour', label: '时', min: 0, max: 23 },
{ name: 'day', label: '日', min: 1, max: 31 },
{ name: 'month', label: '月', min: 1, max: 12 },
{ name: 'week', label: '周', min: 0, max: 7 },
]

const FIELD_DEFS_6: FieldDef[] = [
{ name: 'sec', label: '秒', min: 0, max: 59 },
{ name: 'min', label: '分', min: 0, max: 59 },
{ name: 'hour', label: '时', min: 0, max: 23 },
{ name: 'day', label: '日', min: 1, max: 31 },
{ name: 'month', label: '月', min: 1, max: 12 },
{ name: 'week', label: '周', min: 0, max: 7 },
]

周字段 max 是 7 而不是 6,因为 0 和 7 都表示周日。后续在归一化时统一把 7 转为 0。

一、校验:validateCron()

校验是第一道关卡,确保表达式语法正确。核心逻辑分三步:

1. 空值和字段数检查

export function validateCron(
expression: string,
mode: CronMode,
): CronValidationResult {
const trimmed = (expression || '').trim()
if (!trimmed) {
return { valid: false, error: '表达式不能为空', errorField: 'expression' }
}
const parts = trimmed.split(/\\s+/)
const expected = mode === '5-field' ? 5 : 6
if (parts.length !== expected) {
return {
valid: false,
error: `表达式应为 ${expected} 个字段,当前为 ${parts.length}`,
errorField: 'expression',
}
}
// 逐字段校验…
}

用 split(/\\s+/) 按空格分割,兼容多个空格的情况。字段数不匹配是最常见的错误——从 Unix crontab 迁移到 Spring 时经常少一段。

2. 特殊字符分发

每个字段可能是 *、?、L、W、# 或普通表达式。先处理特殊字符,再做通用解析:

function validateField(field: string, def: FieldDef) {
// ? 仅允许在日/周字段
if (field === '?') {
if (def.name !== 'day' && def.name !== 'week') {
return { valid: false, error: `${def.label}字段不支持 ?` }
}
return { valid: true }
}

// # 仅允许在周字段:如 6#3
if (field.includes('#')) {
if (def.name !== 'week') {
return { valid: false, error: `${def.label}字段不支持 #` }
}
const [wPart, nPart] = field.split('#')
const w = parseInt(wPart, 10)
const n = parseInt(nPart, 10)
if (n < 1 || n > 5) {
return { valid: false, error: `${def.label}字段 # 后值应在 1-5 之间` }
}
return { valid: true }
}

// L 仅允许在日/周字段
// W 仅允许在日字段
// …类似处理
}

关键设计:特殊字符有字段限制。? 只能在日和周字段,# 只能在周字段,W 只能在日字段,L 可以在日和周字段。违反限制直接报错,错误信息指明出错字段。

3. 通用项校验

去掉特殊字符后,剩下的表达式按逗号拆分列表项,每个列表项可能是:

  • * — 任意值
  • */n — 步长
  • n — 单值
  • n-m — 范围
  • n-m/s — 范围 + 步长

function validateItem(item: string, def: FieldDef): string | undefined {
if (item === '*') return undefined

// */n
const starStep = /^\\*\\/(\\d+)$/.exec(item)
if (starStep) {
const step = parseInt(starStep[1], 10)
if (step < 1) return `${def.label}字段步长应大于 0`
return undefined
}

// n-m/s
const rangeStep = /^(\\d+)-(\\d+)\\/(\\d+)$/.exec(item)
if (rangeStep) {
const lo = parseInt(rangeStep[1], 10)
const hi = parseInt(rangeStep[2], 10)
const step = parseInt(rangeStep[3], 10)
if (lo > hi) return `${def.label}字段范围起始大于结束`
if (step < 1) return `${def.label}字段步长应大于 0`
return checkValueInRange(lo, hi, def)
}

// n-m
const range = /^(\\d+)-(\\d+)$/.exec(item)
if (range) {
return checkValueInRange(parseInt(range[1], 10), parseInt(range[2], 10), def)
}

// n
const single = /^(\\d+)$/.exec(item)
if (single) {
return checkValueInRange(parseInt(single[1], 10), parseInt(single[1], 10), def)
}

return `${def.label}字段语法错误:${item}`
}

每个正则对应一种语法格式,按优先级依次匹配。取值范围检查统一交给 checkValueInRange:

function checkValueInRange(lo: number, hi: number, def: FieldDef) {
if (lo < def.min || lo > def.max) return `${def.label}字段值 ${lo} 越界`
if (hi < def.min || hi > def.max) return `${def.label}字段值 ${hi} 越界`
return undefined
}

4. 英文缩写支持

周和月字段支持英文缩写(SUN/MON/JAN/FEB 等),校验前先替换为数字:

const WEEK_NAMES: Record<string, number> = {
SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
}

function replaceWeekNames(s: string): string {
return s.replace(/SUN|MON|TUE|WED|THU|FRI|SAT/gi, (m) =>
String(WEEK_NAMES[m.toUpperCase()] ?? m)
)
}

替换后再走通用校验流程,避免为英文缩写写单独的校验逻辑。

二、中文解释:explainCron()

校验通过后,把表达式翻译成中文。这里采用"高频模式优先匹配 + 通用回退"的策略。

1. 字段解析

先把每个字段解析成结构化对象:

interface ParsedField {
any: boolean // *
unspecified: boolean // ?
values: number[] // 显式枚举值
step?: { start: number; step: number }
range?: { lo: number; hi: number }
last?: boolean // L
lastValue?: number // 5L 中的 5
wValue?: number // 15W 中的 15
hashWeek?: number // 6#3 中的 6
hashN?: number // 6#3 中的 3
}

parseField 函数根据特殊字符分发到不同的解析分支,最终把所有值展开为 values 数组。比如 */5 解析为 [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55],1-10/2 解析为 [1, 3, 5, 7, 9]。

2. 高频模式匹配

80% 的 Cron 表达式集中在几个常见模式。优先匹配这些模式,给出最自然的中文描述:

export function explainCron(expression: string, mode: CronMode): string {
const parsed = parseExpression(expression, mode)
const fields = extractFields(parsed, mode)

// 模式1:每分钟执行
if (fields.min.any && fields.hour.any && isDailyLike(fields)) {
return '每分钟执行'
}

// 模式3:每 N 分钟
if (fields.min.step?.start === 0 && fields.hour.any && isDailyLike(fields)) {
return `${fields.min.step.step} 分钟执行`
}

// 模式4:每天 HH:mm
if (isSingleValue(fields.min) && isSingleValue(fields.hour) && isDailyLike(fields)) {
return `每天 ${buildTimeStr(fields.hour.values[0], fields.min.values[0])} 执行`
}

// 模式5:工作日模式
if (isWorkdayPattern(fields) && isSingleValue(fields.min) && isSingleValue(fields.hour)) {
const t = buildTimeStr(fields.hour.values[0], fields.min.values[0])
return `${describeWeekField(fields.week)} ${t} 执行`
}

// 模式6:每月 N 日
if (isMonthlyDay(fields) && isSingleValue(fields.min) && isSingleValue(fields.hour)) {
return `每月 ${fields.day.values[0]}${buildTimeStr()} 执行`
}

// 通用回退…
}

isDailyLike 判断日和周是否都是"任意"语义(日为 *,周为 * 或 ?,月为 *)。isWorkdayPattern 判断是否是工作日模式(日为 ?,周为受限值)。

匹配顺序很重要——从最具体的模式到最宽泛的模式。如果先检查"每天"模式,0 30 9 ? * MON-FRI 会被误判为"每天 09:30 执行"。

3. 通用回退

复杂表达式(如 0 0,30 9-11 1,15 * ?)无法匹配高频模式时,按字段逐个描述拼接:

function describeField(field: ParsedField, name: string): string {
if (field.any) return ''
if (field.step?.start === 0 && field.values.length > 1) {
return `${field.step.step} ${unitMap[name]}`
}
if (field.range) {
return `${field.range.lo}${field.range.hi} ${unitMap[name]}`
}
if (field.values.length === 1) {
return `${field.values[0]} ${unitMap[name]}`
}
if (field.values.length > 1) {
return `${field.values.join('/')} ${unitMap[name]}`
}
return ''
}

拼接效果:0 0,30 9-11 1,15 * ? → “第 0 秒,0/30 分,9-11 时,每月 1/15 日 执行”。

三、下次触发时间:getNextTriggerTimes()

这是最复杂的函数。计算未来 N 次触发时间,核心思路是暴力遍历。

1. 暴力遍历法

从当前时间的下一个最小单位开始,按步长递增,逐个检查是否匹配:

export function getNextTriggerTimes(
expression: string,
mode: CronMode,
count: number = 10,
from: Date = new Date(),
maxIter: number = 5 * 365 * 24 * 3600, // 5年上限
): Date[] {
const parsed = parseExpression(expression, mode)
if (!parsed) return []

const fields = extractFields(parsed, mode)
const stepMs = mode === '6-field' ? 1000 : 60 * 1000 // 6段按秒,5段按分

// 起始时间对齐到下一秒/分
const startMs = mode === '6-field'
? Math.floor(from.getTime() / 1000) * 1000 + 1000
: Math.floor(from.getTime() / 60000) * 60000 + 60000

const results: Date[] = []
let iter = 0
let cursor = startMs

while (results.length < count && iter < maxIter) {
iter++
const date = new Date(cursor)
if (matchDate(date, fields, mode)) {
results.push(date)
}
cursor += stepMs
}
return results
}

暴力遍历简单可靠,但有性能风险——如果表达式匹配频率很低(如 0 0 0 29 2 ?,2 月 29 号才执行),需要遍历很多次才能找到匹配。maxIter 设为 5 年的秒数(约 1.5 亿次),超过就停止,防止死循环。

2. 字段匹配

matchDate 逐字段检查时间是否匹配:

function matchDate(date: Date, fields: Record<string, ParsedField>, mode: CronMode) {
if (mode === '6-field') {
if (!matchSimple(fields.sec, date.getSeconds())) return false
}
if (!matchSimple(fields.min, date.getMinutes())) return false
if (!matchSimple(fields.hour, date.getHours())) return false
if (!matchSimple(fields.month, date.getMonth() + 1)) return false

// 日和周的匹配逻辑(Quartz 风格)
return matchDayAndWeek(fields.day, fields.week, date)
}

matchSimple 最简单——检查值是否在枚举集合中:

function matchSimple(field: ParsedField, value: number): boolean {
if (field.any) return true
if (field.unspecified) return false
return field.values.includes(value)
}

3. 日和周的匹配

日和周的匹配规则是 Cron 中最 tricky 的部分。Quartz 的规则是:

  • 如果日是 ?,只匹配周
  • 如果周是 ?,只匹配日
  • 如果两者都指定具体值,是"或"的关系——日匹配或周匹配都算通过

function matchDayAndWeek(dayField: ParsedField, weekField: ParsedField, date: Date) {
if (dayField.unspecified) return matchWeek(weekField, date)
if (weekField.unspecified) return matchDay(dayField, date)
if (dayField.any && weekField.any) return true
if (dayField.any) return matchWeek(weekField, date)
if (weekField.any) return matchDay(dayField, date)
// 两者都指定 → "或"关系
return matchDay(dayField, date) || matchWeek(weekField, date)
}

matchDay 处理 L(月末)和 W(最近工作日):

function matchDay(field: ParsedField, date: Date): boolean {
if (field.any) return true
if (field.unspecified) return false

// L = 当月最后一天
if (field.last && field.lastValue === undefined) {
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
return date.getDate() === lastDay
}

// 普通值匹配
return field.values.includes(date.getDate())
}

matchWeek 处理 #(第几个周几)和 L(最后一个周几):

function matchWeek(field: ParsedField, date: Date): boolean {
if (field.any) return true
if (field.unspecified) return false

// 6#3 = 每月第三个周六
if (field.hashWeek !== undefined && field.hashN !== undefined) {
if (date.getDay() !== field.hashWeek) return false
const nth = Math.floor((date.getDate() 1) / 7) + 1
return nth === field.hashN
}

// 6L = 每月最后一个周六
if (field.last && field.lastValue !== undefined) {
if (date.getDay() !== field.lastValue) return false
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
return date.getDate() + 7 > lastDay // 本月最后7天内
}

return field.values.includes(date.getDay())
}

# 的匹配逻辑:先判断星期是否匹配,再计算当前日期是本月第几个该星期。Math.floor((day – 1) / 7) + 1 这个公式计算"第几个"——1 号到 7 号是第一个,8 号到 14 号是第二个,以此类推。

L 在周字段中的匹配:判断当前日期加 7 天是否超过本月最后一天,如果是,说明这是本月最后一个该星期几。

四、单元测试设计

纯函数架构的好处是测试友好。核心测试用例覆盖三类场景:

describe('validateCron', () => {
// 正常用例
it('5段标准表达式', () => {
expect(validateCron('0 2 * * *', '5-field').valid).toBe(true)
})
it('6段工作日表达式', () => {
expect(validateCron('0 30 9 ? * MON-FRI', '6-field').valid).toBe(true)
})

// 异常用例
it('字段数不匹配', () => {
const r = validateCron('0 2 * * *', '6-field')
expect(r.valid).toBe(false)
expect(r.error).toContain('6 个字段')
})
it('值越界', () => {
const r = validateCron('0 0 25 * * ?', '6-field')
expect(r.valid).toBe(false)
expect(r.error).toContain('越界')
})

// 边界用例
it('周字段7等于周日', () => {
expect(validateCron('0 0 0 ? * 7', '6-field').valid).toBe(true)
})
it('? 只能在日/周字段', () => {
expect(validateCron('0 ? 0 * * ?', '6-field').valid).toBe(false)
})
})

describe('explainCron', () => {
it('每分钟', () => {
expect(explainCron('* * * * *', '5-field')).toBe('每分钟执行')
})
it('工作日9:30', () => {
expect(explainCron('0 30 9 ? * MON-FRI', '6-field'))
.toBe('每周一到周五 09:30 执行')
})
it('每月最后一天', () => {
expect(explainCron('0 0 0 L * ?', '6-field'))
.toContain('每月最后一天')
})
})

describe('getNextTriggerTimes', () => {
it('每分钟应返回连续分钟', () => {
const times = getNextTriggerTimes('* * * * *', '5-field', 3, new Date('2026-01-01T00:00:00'))
expect(times).toHaveLength(3)
expect(times[0].getMinutes()).toBe(1)
expect(times[1].getMinutes()).toBe(2)
})
it('2月29号触发时间正确', () => {
const times = getNextTriggerTimes('0 0 0 29 2 ?', '6-field', 1, new Date('2026-01-01'))
expect(times[0].getMonth()).toBe(1) // 2月
expect(times[0].getDate()).toBe(29)
expect(times[0].getFullYear()).toBe(2028) // 2028是闰年
})
})

边界场景是最有价值的测试——2 月 29 号、月末 31 号、L 在不同月份的实际值、# 的"第几个"计算是否正确。这些边界用例是手动调试最容易遗漏的地方。

五、性能优化思考

暴力遍历法在大多数场景下够用,但极端场景有性能问题。0 0 0 29 2 ?(2 月 29 号执行)需要遍历约 4 年才能找到一次匹配,6 段模式下每秒一次迭代,4 年约 1.2 亿次循环。

优化方向:

  • 字段级跳过:如果当前月份不匹配,直接跳到下个月 1 号 0 点,不逐秒遍历整个月
  • 优先检查大粒度字段:先检查月、日,再检查时、分、秒,大粒度不匹配就跳过小粒度
  • 缓存解析结果:同一个表达式多次调用时复用 ParsedField 对象
  • 实际项目中,前端工具调用频率不高,5 年上限的暴力遍历已足够。如果用于后端调度引擎,建议实现字段级跳过优化。

    完整代码与在线体验

    本文拆解的代码完整实现在 PanziTool GitHub 仓库 的 frontend/utils/tools/cron.ts 文件中,约 900 行 TypeScript 代码,无外部依赖。Vue 组件 CronTool.vue 负责交互层,调用这三个纯函数完成校验、解释和触发时间预览。

    如果你想体验最终效果,访问 盘子工具站 Cron 表达式生成器,输入任意表达式即可看到:

    • 实时校验结果,出错时指明具体字段和原因
    • 中文解释,覆盖工作日、月末、每 N 分钟等高频模式
    • 未来 5 次触发时间预览(可调次数),验证 0 0 0 29 2 ? 等边界场景 在这里插入图片描述 这个系列从基础语法到特殊字符、跨框架差异、生产模板、调试指南,最后回到代码实现,形成"用工具 → 懂原理 → 造工具"的完整闭环。希望这个系列能帮你真正搞懂 Cron 表达式。
    赞(0)
    未经允许不得转载:171主机测评 » 用代码实现 Cron 表达式解析器:从校验到下次执行时间计算
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址