从 0 到 1 用 PHP 实现 Cron 表达式解析器(附完整源码)

一个服务于宝塔定时任务的周末小项目,全程真实踩坑记录。代码已完整跑通,可直接部署。

为什么要写它

在宝塔面板配置定时任务时,几乎每个 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. 先按 , 拆分,再逐段处理 ------这样 1,15-30/2 这种组合才能正确展开;
  2. 范围校验放在最后统一做 ,让非法值(如 99 * * * *)得到清晰报错而不是静默失败;
  3. 周字段做归一化: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 * * * * 变成了裸代码。

排错用了两轮:第一轮以为是正则写错,把错误定位到注释行才反应过来。这个坑提醒两件事:

  1. 注释里别写 */ 开头的文本*/n*/5 都是雷);
  2. 报错信息里的行号永远优先看------它说哪行,通常就是哪行,别先怀疑人生。

前端与部署

前端用 Vue 3 + CDN ,一个 index.html 搞定:顶部预设了 6 个常用表达式(每5分钟、每天2:30、每周一9点......),点击即解析;下方实时展示各字段含义和接下来 5 次执行时间。

部署到宝塔只需要三步:

  1. 新建站点,根目录指向项目文件夹;
  2. PHP 版本选 8.x(用了箭头函数和短数组语法);
  3. 伪静态不用配,默认即可。

本地调试更简单:

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 表达式,我看看解析器能不能扛住。

如果你也在用宝塔、也被定时任务坑过,希望这个工具和这篇文章能帮你省下几分钟------顺便说一句,如果它真的有用,请我喝杯咖啡我也很高兴(逃)。

相关推荐
JSON_L2 小时前
Fastadmin关联查询报错 method not exist:think\db\Query->XXX
php·fastadmin
DFT计算杂谈1 天前
SCIENTIFIC REPORTS 非 van der Waals可调二维磁性材料设计平台
开发语言·php
凡尘——雨落凡尘1 天前
PHP实战复盘:高并发限流、防刷、验证码、会话安全、CSRF防护导致站点稳定性与安全加固实战
php·csrf 防护·csrf 攻击
2601_965798471 天前
Bostami Portfolio Template Review: Speed, Code & SEO Test
php
AC赳赳老秦1 天前
开源组件版本数据监控:OpenClaw 抓取公开版本信息,自动提醒更新与安全风险
前端·python·安全·开源·github·php·openclaw
漏刻有时1 天前
PHP GeoJSON转PNG地图渲染程序开发笔记、源码解读、问题复盘与整改方案
android·笔记·php
always_TT2 天前
【Python 日志记录:logging 模块入门】
开发语言·python·php
Immortal__y2 天前
Upload-Labs 关卡防御方式
php
JonLee20202 天前
适配 Yii 3.0,php-casbin/yii-permission 3.0 正式发布
安全·php·rbac·yii·权限·casbin