一个服务于宝塔定时任务的周末小项目,全程真实踩坑记录。代码已完整跑通,可直接部署。
为什么要写它
在宝塔面板配置定时任务时,几乎每个 PHP 开发者都经历过这样的瞬间:
30 2 * * *到底是啥意思?5 个字段分别代表什么?- 为什么我写的表达式"看起来对",却从没执行过?
- 我想"每 5 分钟跑一次备份",
*/5 * * * *和5 * * * *差在哪?
于是我花了一个周末,用 PHP + Vue 写了一个能解析、能校验、能预测下次执行时间的 Cron 解析器。工具本身很轻(两个文件),但核心解析逻辑值得拆开讲透------本文把实现完整展开,所有代码都是真实跑通的。
先说结论:Cron 的 5 个字段
| 字段 | 含义 | 取值范围 | 备注 |
|---|---|---|---|
| 分 | 一小时中的第几分钟 | 0-59 | |
| 时 | 一天中的第几个小时 | 0-23 | |
| 日 | 一月中的第几天 | 1-31 | |
| 月 | 一年中的第几个月 | 1-12 | |
| 周 | 一周中的第几天 | 0-7 | 0 和 7 都代表周日 |
示例:30 2 * * * = 每天凌晨 2:30 执行;0 9 * * 1 = 每周一早上 9 点。
支持的语法(以任意一个字段为例):
| 写法 | 含义 | 示例 |
|---|---|---|
* |
全部取值 | * = 0-59 |
*/n |
每 n 个单位 | */5 = 0,5,10,...,55 |
a-b |
区间 | 9-18 = 9,10,...,18 |
a-b/n |
区间内按步长 | 8-18/2 = 8,10,...,18 |
a,b,c |
枚举列表 | 1,15,30 |
| 组合 | 以上混用 | 1,15-30/2 |
核心一:字段展开器(把表达式变成数值集合)
解析的第一步,是把 */5 这种"语法糖"展开成一个具体的合法值数组。核心就是一个递归式的分支判断:
php
private function expandField(string $field, int $min, int $max): array
{
$values = [];
foreach (explode(',', $field) as $item) {
// 1. * 表示全部
if ($item === '*') {
$values = array_merge($values, range($min, $max));
continue;
}
// 2. */n 或 a-b/n(步长语法)
if (preg_match('/^(\*|\d+-\d+)\/(\d+)$/', $item, $m)) {
$start = $m[1] === '*' ? $min : (int) explode('-', $m[1])[0];
$end = $m[1] === '*' ? $max : (int) explode('-', $m[1])[1];
$step = (int) $m[2];
if ($step <= 0) {
throw new InvalidArgumentException("步长必须大于 0:{$item}");
}
for ($i = $start; $i <= $end; $i += $step) {
$values[] = $i;
}
continue;
}
// 3. a-b 区间
if (preg_match('/^(\d+)-(\d+)$/', $item, $m)) {
$values = array_merge($values, range((int) $m[1], (int) $m[2]));
continue;
}
// 4. 纯数字
if (ctype_digit($item)) {
$values[] = (int) $item;
continue;
}
throw new InvalidArgumentException("无法解析字段片段:{$item}");
}
// 去重、排序、校验范围(防止 99 这种越界值混进来)
$values = array_values(array_unique($values));
sort($values);
foreach ($values as $v) {
if ($v < $min || $v > $max) {
throw new InvalidArgumentException("值 {$v} 超出范围 {$min}-{$max}");
}
}
return $values;
}
几个容易忽略的细节:
- 先按
,拆分,再逐段处理 ------这样1,15-30/2这种组合才能正确展开; - 范围校验放在最后统一做 ,让非法值(如
99 * * * *)得到清晰报错而不是静默失败; - 周字段做归一化:cron 里 0 和 7 都是周日,解析完把 7 统一映射成 0。
核心二:下次执行时间(简单可靠的逐分钟遍历)
算"下次执行时间",网上很多方案用复杂的日历算法。但这个场景有个朴素解法:从当前时间的下一个整分开始,一分钟一分钟往后走,命中就记下来。配合一个守卫上限防止死循环,代码量小、逻辑好验证、正确性靠得住:
php
public function nextRuns(string $expr, int $count = 5, ?int $from = null): array
{
$sets = $this->parse($expr);
$from = $from ?? time();
$t = $from - ($from % 60) + 60; // 对齐到下一个整分
$out = [];
$guard = 0;
while (count($out) < $count && $guard < 2000000) {
$guard++;
$y = (int) date('Y', $t);
$mo = (int) date('n', $t);
$d = (int) date('j', $t);
$h = (int) date('G', $t);
$mi = (int) date('i', $t);
$w = (int) date('w', $t); // 0 = 周日
$match = in_array($mi, $sets['minute'])
&& in_array($h, $sets['hour'])
&& in_array($d, $sets['day'])
&& in_array($mo, $sets['month'])
&& in_array($w, $sets['week']);
if ($match) {
$out[] = $t;
}
$t += 60;
}
return $out;
}
关于这个方案的取舍:
- 优点:逻辑直观,不会在闰年、月末、夏令时上翻车;
- 代价:每次计算要扫描若干分钟。但"预测下次 5 次执行"最多也就扫到几年后,200 万分钟的守卫上限绰绰有余;
- 如果你的场景是"一次性算出未来 1000 次",再考虑换日历算法优化,一般场景没必要。
真实踩坑:注释里藏着语法错误
这个项目开发时,我栽过一个很蠢的跟头,值得写出来:
最初我在文件头注释里写了这样一行:
php
* GET /?api=parse&expr=*/5 * * * *
结果 PHP 直接白屏报 Parse error。原因:*/5 里的 */ 把块注释提前闭合了 ,后面的 5 * * * * 变成了裸代码。
排错用了两轮:第一轮以为是正则写错,把错误定位到注释行才反应过来。这个坑提醒两件事:
- 注释里别写
*/开头的文本 (*/n、*/5都是雷); - 报错信息里的行号永远优先看------它说哪行,通常就是哪行,别先怀疑人生。
前端与部署
前端用 Vue 3 + CDN ,一个 index.html 搞定:顶部预设了 6 个常用表达式(每5分钟、每天2:30、每周一9点......),点击即解析;下方实时展示各字段含义和接下来 5 次执行时间。
部署到宝塔只需要三步:
- 新建站点,根目录指向项目文件夹;
- PHP 版本选 8.x(用了箭头函数和短数组语法);
- 伪静态不用配,默认即可。
本地调试更简单:
bash
php -S 127.0.0.1:8080 -t cron-tool
打开 http://127.0.0.1:8080 就能用。
项目结构
bash
cron-tool/
├── index.php # 后端:解析器类 + API(约 190 行)
└── index.html # 前端:Vue 3 单页(CDN 引入,零构建)
完整源码就两个文件,一个 PHP 类 + 一个页面。核心逻辑不到 100 行------"实现一个解析器"这件事,远没有想象中难。
尾巴
这个项目的完整代码我已经整理好,需要完整源码(含前端页面)的可以留言或私信。也欢迎在评论区贴出你见过最变态的 cron 表达式,我看看解析器能不能扛住。
如果你也在用宝塔、也被定时任务坑过,希望这个工具和这篇文章能帮你省下几分钟------顺便说一句,如果它真的有用,请我喝杯咖啡我也很高兴(逃)。