ea
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/vendor/
|
||||||
|
.gstack/
|
||||||
|
*.pid
|
||||||
|
storage/
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# kiri-crontab 任务调度系统 架构设计文档
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
基于 Redis + Swoole 的定时任务调度系统,支持:
|
||||||
|
- 定时执行:指定具体时间点执行任务
|
||||||
|
- 间隔执行:每隔 N 秒/min/hour 重复执行
|
||||||
|
- cron 表达式:标准 5 字段 cron 表达式调度
|
||||||
|
- 一次性任务:执行后自动移除
|
||||||
|
- 任务暂停/恢复:通过 Redis 标记控制任务启停
|
||||||
|
- **注解驱动**: 使用 `#[Crontab]` 注解声明任务,支持自动扫描发现
|
||||||
|
|
||||||
|
## 二、核心组件
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ kiri-crontab │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │CrontabCommand │───▶│ CrontabScheduler │ │
|
||||||
|
│ │ (控制台命令) │ │ (调度引擎) │ │
|
||||||
|
│ └──────────────┘ └────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ TaskRegistry │ │
|
||||||
|
│ │ (任务注册中心) │ │
|
||||||
|
│ └─────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ TaskInterface │ │
|
||||||
|
│ │ (任务接口) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────────────▼──────────────────────┐ │
|
||||||
|
│ │ Redis │ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────────────────┐ │ │
|
||||||
|
│ │ │ Sorted Set │ │ Hash (任务元数据) │ │ │
|
||||||
|
│ │ │ (调度队列) │ │ crontab:task:{key} │ │ │
|
||||||
|
│ │ │ crontab:queue │ └──────────────────────────┘ │ │
|
||||||
|
│ │ └──────────────┘ │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、Redis 数据结构设计
|
||||||
|
|
||||||
|
### 3.1 调度队列 (Sorted Set)
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: crontab:queue
|
||||||
|
Type: ZSET
|
||||||
|
Score: 下次执行时间戳 (Unix timestamp)
|
||||||
|
Member: 任务标识符 (task key)
|
||||||
|
```
|
||||||
|
|
||||||
|
ZSET 按时间戳排序,调度器只需 `ZRANGEBYSCORE` 获取到期任务。
|
||||||
|
|
||||||
|
### 3.2 任务元数据 (Hash)
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: crontab:task:{taskKey}
|
||||||
|
Type: Hash
|
||||||
|
Fields:
|
||||||
|
class - 任务处理类完整路径 (如 App\Task\CleanLogTask)
|
||||||
|
name - 任务显示名称
|
||||||
|
expression - 调度表达式 (every:60 | cron:*\/5 * * * * | daily:03:00 | at:1234567890)
|
||||||
|
next_run - 下次执行时间戳 (秒)
|
||||||
|
last_run - 上次执行时间戳 (秒)
|
||||||
|
status - 状态: active / paused / disabled
|
||||||
|
interval - 执行间隔描述 (可读)
|
||||||
|
created_at - 创建时间戳
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 任务执行锁
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: crontab:lock:task:{taskKey}
|
||||||
|
Type: String
|
||||||
|
Value: Swoole Worker ID + Timestamp
|
||||||
|
TTL: 任务超时时间 (默认 300s)
|
||||||
|
```
|
||||||
|
|
||||||
|
防止同一任务重复执行(前次未完成则跳过本次)。
|
||||||
|
|
||||||
|
### 3.4 调度器主锁
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: crontab:lock:master
|
||||||
|
Type: String
|
||||||
|
Value: Worker PID
|
||||||
|
TTL: 60s (定期续期)
|
||||||
|
```
|
||||||
|
|
||||||
|
防止多实例同时调度,用 SET NX EX + Lua 脚本实现。
|
||||||
|
|
||||||
|
### 3.5 任务执行状态集合
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: crontab:running
|
||||||
|
Type: SET
|
||||||
|
Member: 当前正在执行的任务 key 列表
|
||||||
|
```
|
||||||
|
|
||||||
|
用于监控哪些任务正在运行中。
|
||||||
|
|
||||||
|
## 四、调度表达式
|
||||||
|
|
||||||
|
| 格式 | 示例 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| `every:{秒}` | `every:60` | 每 60 秒执行 |
|
||||||
|
| `every:{秒}s` | `every:30s` | 每 30 秒执行 |
|
||||||
|
| `every:{分}m` | `every:5m` | 每 5 分钟执行 |
|
||||||
|
| `every:{时}h` | `every:1h` | 每 1 小时执行 |
|
||||||
|
| `daily:{HH:MM}` | `daily:03:00` | 每天凌晨 3 点 |
|
||||||
|
| `hourly:{MM}` | `hourly:30` | 每小时第 30 分 |
|
||||||
|
| `cron:{表达式}` | `cron:*\/5 * * * *` | 标准 5 字段 cron |
|
||||||
|
| `at:{时间戳}` | `at:1719590400` | 指定时间戳一次性 |
|
||||||
|
|
||||||
|
## 五、调度流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 调度器启动 (process) │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 1. 扫描 TaskRegistry 注册的所有任务 │
|
||||||
|
│ 将未注册到 Redis 的任务写入 Hash │
|
||||||
|
│ 将任务加入 ZSET 调度队列 │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 主循环 (每 1 秒 tick) │◀────────────────────┐
|
||||||
|
└──────────┬───────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────┐ │
|
||||||
|
│ 2. 获取主锁 │ │
|
||||||
|
│ SET NX EX 60s │── 失败 ──────────────┘
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│ 成功
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 3. ZRANGEBYSCORE │
|
||||||
|
│ score <= now │── 空 ───────────────┐
|
||||||
|
│ 获取到期任务列表 │ │
|
||||||
|
└──────────┬───────────┘ │
|
||||||
|
│ 有任务 │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────┐ │
|
||||||
|
│ 4. 遍历到期任务 │ │
|
||||||
|
│ - 获取任务锁 │ │
|
||||||
|
│ - 执行任务 handle() │ │
|
||||||
|
│ - 记录日志/更新状态 │◀─────────────────────┘
|
||||||
|
│ - 计算下次执行时间 │
|
||||||
|
│ - 更新 ZSET 分数 │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 5. sleep(1) 等待下个 │
|
||||||
|
│ tick │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六、任务生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
[注册] ──▶ [待调度] ──▶ [获取锁] ──▶ [执行中] ──▶ [完成]
|
||||||
|
▲ │ │
|
||||||
|
│ │ 锁获取失败 │
|
||||||
|
│ ▼ │
|
||||||
|
│ [跳过本轮] │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
(等待下次调度)
|
||||||
|
|
||||||
|
状态流转:
|
||||||
|
active ──▶ paused ──▶ active (暂停/恢复)
|
||||||
|
active ──▶ disabled (禁用,从 ZSET 移除)
|
||||||
|
一次性任务执行完成后: 从 ZSET 和 Redis 中移除
|
||||||
|
```
|
||||||
|
|
||||||
|
## 七、与 kiri-core 集成方式
|
||||||
|
|
||||||
|
### 7.1 作为 Composer 包引入
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"game-worker/kiri-crontab": "^v1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 通过 Provider 注册到框架
|
||||||
|
```php
|
||||||
|
// config/servers.php 中添加
|
||||||
|
'process' => [
|
||||||
|
\Kiri\Crontab\CrontabProcess::class,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 独立运行模式
|
||||||
|
```bash
|
||||||
|
php bin/crontab start
|
||||||
|
php bin/crontab stop
|
||||||
|
php bin/crontab restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 注解驱动注册方式
|
||||||
|
```php
|
||||||
|
use Kiri\Crontab\Annotate\Crontab;
|
||||||
|
use Kiri\Crontab\TaskInterface;
|
||||||
|
|
||||||
|
#[Crontab(name: '清理日志', expression: 'daily:03:00')]
|
||||||
|
class CleanLogTask implements TaskInterface
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// 清理逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在配置中指定扫描路径:
|
||||||
|
```php
|
||||||
|
// config/crontab.php
|
||||||
|
'scan_paths' => [
|
||||||
|
'app/Task',
|
||||||
|
'app/Crontab',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## 八、注解扫描流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ CrontabScanner::scan($directory) │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 1. 递归遍历目录下所有 PHP 文件 │
|
||||||
|
│ 跳过 vendor/tests/cache/storage │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 2. require_once 加载文件 │
|
||||||
|
│ get_declared_classes() 获取新类 │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 3. 检查类是否 implements TaskInterface│
|
||||||
|
│ ──否──▶ 跳过 │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│ 是
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 4. ReflectionClass::getAttributes( │
|
||||||
|
│ Crontab::class) 读取注解 │
|
||||||
|
│ ──无──▶ 跳过 │
|
||||||
|
└────────────────┬─────────────────────┘
|
||||||
|
│ 有
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 5. $registry->register([ │
|
||||||
|
│ 'class' => $className, │
|
||||||
|
│ 'name' => $crontab->name, │
|
||||||
|
│ 'expression' => $crontab->expr, │
|
||||||
|
│ 'status' => $crontab->status, │
|
||||||
|
│ ]); │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 九、依赖关系
|
||||||
|
|
||||||
|
```
|
||||||
|
kiri-crontab
|
||||||
|
├── PHP >= 8.5
|
||||||
|
├── ext-swoole (协程/进程)
|
||||||
|
├── ext-redis (Redis 客户端)
|
||||||
|
├── psr/log (PSR-3 日志)
|
||||||
|
└── symfony/console (可选,如集成 kiri-core 则复用项目已有的)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 十、目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
kiri-crontab/
|
||||||
|
├── composer.json
|
||||||
|
├── DESIGN.md # 本文档
|
||||||
|
├── README.md
|
||||||
|
├── bin/
|
||||||
|
│ └── crontab # 独立运行入口脚本
|
||||||
|
├── config/
|
||||||
|
│ └── crontab.php # 默认配置
|
||||||
|
├── src/
|
||||||
|
│ ├── TaskInterface.php # 任务接口
|
||||||
|
│ ├── TaskConfig.php # 任务配置值对象
|
||||||
|
│ ├── TaskRegistry.php # 任务注册中心
|
||||||
|
│ ├── CrontabScheduler.php # 核心调度引擎
|
||||||
|
│ ├── CrontabProcess.php # Swoole 进程适配器
|
||||||
|
│ ├── CrontabCommand.php # 控制台命令
|
||||||
|
│ ├── CrontabScanner.php # 注解任务扫描器
|
||||||
|
│ ├── CrontabProviders.php # kiri-core Provider 集成
|
||||||
|
│ ├── CronExpression.php # Cron 表达式解析器
|
||||||
|
│ ├── Annotate/
|
||||||
|
│ │ └── Crontab.php # #[Crontab] 注解类
|
||||||
|
│ └── Events/
|
||||||
|
│ ├── OnTaskBeforeExecute.php
|
||||||
|
│ ├── OnTaskExecuted.php
|
||||||
|
│ └── OnTaskFailed.php
|
||||||
|
└── tests/
|
||||||
|
└── CronExpressionTest.php
|
||||||
|
```
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# kiri-crontab
|
||||||
|
|
||||||
|
基于 Redis + Swoole 的 PHP 定时任务调度系统。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **闹钟模式**: 像设置闹钟一样声明任务 `hour: 3, minute: 0, loop: true`
|
||||||
|
- **间隔执行**: `tick: 30` 每30秒、`tickMinute: 5` 每5分钟、`tickHour: 1` 每1小时
|
||||||
|
- **一次性任务**: `year: 2026, month: 12, day: 22, hour: 11` 指定时刻执行一次
|
||||||
|
- **Cron 兜底**: 复杂场景用 `cron: '*/5 * * * *'`
|
||||||
|
- **动态投递**: 运行时通过 `submitToCrontab()` 随时提交、`cancelCrontabTask()` 取消
|
||||||
|
- **类注解**: `#[Crontab]` 在类上声明调度参数,CrontabProcess 启动时自动发现
|
||||||
|
- **协程并发**: 到期任务通过 Swoole 协程并发执行
|
||||||
|
- **分布式锁**: Redis 主锁 + 任务锁防止重复执行
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require game-worker/kiri-crontab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注解模式
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace App\Task;
|
||||||
|
|
||||||
|
use Kiri\Crontab\Annotate\Crontab;
|
||||||
|
use Kiri\Crontab\TaskInterface;
|
||||||
|
|
||||||
|
#[Crontab(name: '清理日志', hour: 3, minute: 0, loop: true)]
|
||||||
|
class CleanLogTask implements TaskInterface
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
echo "清理日志..." . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Crontab(name: '心跳', tick: 30, loop: true)]
|
||||||
|
class HeartbeatTask implements TaskInterface
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
echo "心跳检测..." . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动态投递
|
||||||
|
|
||||||
|
```php
|
||||||
|
use function Kiri\Crontab\submitToCrontab;
|
||||||
|
use function Kiri\Crontab\cancelCrontabTask;
|
||||||
|
|
||||||
|
// 每秒检查匹配结果,匹配成功后在 handle() 中自停
|
||||||
|
class MatchCheckTask implements TaskInterface
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if ($this->isMatched()) {
|
||||||
|
// 执行完后自动移除,不再调度
|
||||||
|
CrontabScheduler::getInstance()?->cancelCurrentTask();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 未匹配,继续检查
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务代码中动态投递
|
||||||
|
$taskKey = submitToCrontab(MatchCheckTask::class, 'every:1', '匹配检查 #123');
|
||||||
|
|
||||||
|
// 条件满足时也可以外部取消
|
||||||
|
cancelCrontabTask($taskKey);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/crontab.php
|
||||||
|
return [
|
||||||
|
'redis' => ['host' => '127.0.0.1', 'port' => 6379],
|
||||||
|
'scheduler' => ['tick_interval' => 1],
|
||||||
|
'tasks' => [
|
||||||
|
['class' => App\Task\CleanLogTask::class, 'name' => '清理日志', 'expression' => 'daily:03:00'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/crontab start / stop / restart / status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成到 kiri-core
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/servers.php
|
||||||
|
'process' => [\Kiri\Crontab\CrontabProcess::class],
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调度参数一览
|
||||||
|
|
||||||
|
| 参数 | 示例 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| `tick` | `tick: 30` | 每 30 秒循环 |
|
||||||
|
| `tickMinute` | `tickMinute: 5` | 每 5 分钟循环 |
|
||||||
|
| `tickHour` | `tickHour: 1` | 每 1 小时循环 |
|
||||||
|
| `hour` `minute` `second` | `hour: 3, minute: 0, loop: true` | 每天 03:00 |
|
||||||
|
| `minute` | `minute: 30, loop: true` | 每小时 :30 |
|
||||||
|
| `year` `month` `day` ... | `year: 2026, month: 12, day: 22` | 指定时刻一次性 |
|
||||||
|
| `cron` | `cron: '*\/5 * * * *'` | cron 表达式 |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
详见 [DESIGN.md](./DESIGN.md)
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* kiri-crontab 独立运行入口脚本
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* php bin/crontab start 启动调度器
|
||||||
|
* php bin/crontab stop 停止调度器
|
||||||
|
* php bin/crontab restart 重启调度器
|
||||||
|
* php bin/crontab status 查看状态
|
||||||
|
*
|
||||||
|
* 任务定义方式:
|
||||||
|
* 1. 配置文件: config/crontab.php 的 tasks 中声明
|
||||||
|
* 2. 注解自动扫描: 在任务类上使用 #[Crontab] 注解,配置 scan_paths 目录
|
||||||
|
*
|
||||||
|
* 确保项目根目录有 config/crontab.php 配置文件
|
||||||
|
* 并在其中定义 Redis 连接 (redis)、调度参数 (scheduler) 和扫描路径 (scan_paths)
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 自动加载
|
||||||
|
$autoloadFiles = [
|
||||||
|
__DIR__ . '/../vendor/autoload.php',
|
||||||
|
__DIR__ . '/../../../vendor/autoload.php',
|
||||||
|
__DIR__ . '/../../autoload.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$autoloadPath = null;
|
||||||
|
foreach ($autoloadFiles as $file) {
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$autoloadPath = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($autoloadPath === null) {
|
||||||
|
fwrite(STDERR, "未找到 autoload.php,请先执行 composer install" . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
require $autoloadPath;
|
||||||
|
|
||||||
|
use Kiri\Crontab\CrontabCommand;
|
||||||
|
use Kiri\Crontab\TaskRegistry;
|
||||||
|
|
||||||
|
// 解析命令行参数
|
||||||
|
$args = $argv;
|
||||||
|
$script = array_shift($args);
|
||||||
|
$action = array_shift($args) ?: 'status';
|
||||||
|
|
||||||
|
// 查找配置文件
|
||||||
|
$configFiles = [
|
||||||
|
getcwd() . '/config/crontab.php',
|
||||||
|
__DIR__ . '/../config/crontab.php',
|
||||||
|
];
|
||||||
|
$configPath = null;
|
||||||
|
foreach ($configFiles as $file) {
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$configPath = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configPath === null) {
|
||||||
|
fwrite(STDERR, "未找到配置文件 config/crontab.php" . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = require $configPath;
|
||||||
|
|
||||||
|
if (!is_array($config)) {
|
||||||
|
fwrite(STDERR, "配置文件格式错误" . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化任务注册中心 (静态注册表)
|
||||||
|
// 1. 注册配置文件中的任务
|
||||||
|
$taskList = $config['tasks'] ?? [];
|
||||||
|
foreach ($taskList as $taskConfig) {
|
||||||
|
try {
|
||||||
|
TaskRegistry::register($taskConfig);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
fwrite(STDERR, "任务注册失败: {$throwable->getMessage()}" . PHP_EOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 发现注解任务 (检查类上的 #[Crontab] 注解)
|
||||||
|
foreach (get_declared_classes() as $className) {
|
||||||
|
try {
|
||||||
|
$reflect = new ReflectionClass($className);
|
||||||
|
if ($reflect->isAbstract()) continue;
|
||||||
|
if (!in_array(Kiri\Crontab\TaskInterface::class, class_implements($className), true)) continue;
|
||||||
|
|
||||||
|
$attributes = $reflect->getAttributes(Kiri\Crontab\Annotate\Crontab::class);
|
||||||
|
if (empty($attributes)) continue;
|
||||||
|
|
||||||
|
$instance = $attributes[0]->newInstance();
|
||||||
|
$expr = $instance->buildExpression();
|
||||||
|
if ($expr === '') continue;
|
||||||
|
|
||||||
|
Kiri\Crontab\TaskRegistry::register([
|
||||||
|
'class' => $className,
|
||||||
|
'name' => $instance->name !== '' ? $instance->name : $className,
|
||||||
|
'expression' => $expr,
|
||||||
|
'status' => $instance->status,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[Crontab] 任务总数: " . TaskRegistry::count() . PHP_EOL;
|
||||||
|
|
||||||
|
// PID 文件和日志文件
|
||||||
|
$pidFile = $config['scheduler']['pid_file'] ?? (getcwd() . '/storage/crontab.pid');
|
||||||
|
$logFile = $config['scheduler']['log_file'] ?? '';
|
||||||
|
|
||||||
|
// 创建命令实例
|
||||||
|
$command = new CrontabCommand($pidFile, $logFile);
|
||||||
|
|
||||||
|
// 路由到对应操作
|
||||||
|
try {
|
||||||
|
match ($action) {
|
||||||
|
'start' => $command->start($config),
|
||||||
|
'stop' => $command->stop(),
|
||||||
|
'restart' => $command->restart($config),
|
||||||
|
'status' => $command->status(),
|
||||||
|
default => fwrite(STDERR, "未知操作: {$action} (支持: start|stop|restart|status)" . PHP_EOL),
|
||||||
|
};
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
fwrite(STDERR, "执行失败: {$throwable->getMessage()}" . PHP_EOL);
|
||||||
|
fwrite(STDERR, "{$throwable->getTraceAsString()}" . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "game-worker/kiri-crontab",
|
||||||
|
"description": "基于 Redis + Swoole 的定时任务调度系统,支持定时执行、间隔重复、cron 表达式",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "XiangLin",
|
||||||
|
"email": "as2252258@163.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.5",
|
||||||
|
"ext-swoole": "*",
|
||||||
|
"ext-redis": "*",
|
||||||
|
"psr/log": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/console": "如需集成 kiri-core 或使用命令行管理,建议安装 ^v8.0",
|
||||||
|
"game-worker/kiri-core": "如需集成到 kiri-core 框架,建议安装 kiri-core"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Kiri\\Crontab\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/crontab"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* kiri-crontab 默认配置
|
||||||
|
* 可通过环境变量或应用配置覆盖
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
// Redis 连接配置
|
||||||
|
'redis' => [
|
||||||
|
'host' => env('CRONTAB_REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => (int)env('CRONTAB_REDIS_PORT', 6379),
|
||||||
|
'auth' => env('CRONTAB_REDIS_AUTH', ''),
|
||||||
|
'prefix' => env('CRONTAB_REDIS_PREFIX', ''),
|
||||||
|
'databases' => (int)env('CRONTAB_REDIS_DB', 0),
|
||||||
|
'timeout' => (int)env('CRONTAB_REDIS_TIMEOUT', 30),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 调度器配置
|
||||||
|
'scheduler' => [
|
||||||
|
// 调度器 tick 间隔 (秒)
|
||||||
|
'tick_interval' => (int)env('CRONTAB_TICK_INTERVAL', 1),
|
||||||
|
// 任务执行超时时间 (秒)
|
||||||
|
'task_timeout' => (int)env('CRONTAB_TASK_TIMEOUT', 300),
|
||||||
|
// 主锁 TTL (秒)
|
||||||
|
'lock_ttl' => (int)env('CRONTAB_LOCK_TTL', 60),
|
||||||
|
// 主锁续期间隔 (秒)
|
||||||
|
'lock_renew_interval' => (int)env('CRONTAB_LOCK_RENEW_INTERVAL', 15),
|
||||||
|
// 是否启用协程并发执行多个到期任务
|
||||||
|
'concurrent_tasks' => (bool)env('CRONTAB_CONCURRENT', true),
|
||||||
|
// 并发任务最大数量
|
||||||
|
'max_concurrent' => (int)env('CRONTAB_MAX_CONCURRENT', 10),
|
||||||
|
// PID 文件路径 (独立模式)
|
||||||
|
'pid_file' => env('CRONTAB_PID_FILE', ''),
|
||||||
|
// 日志文件路径 (独立模式)
|
||||||
|
'log_file' => env('CRONTAB_LOG_FILE', ''),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 注册的任务列表 (配置模式,expression 字符串格式)
|
||||||
|
// 注解模式通过 #[Crontab] 在 handle() 方法上声明,kiri-core Scanner 自动发现
|
||||||
|
// 表达式: every:60 | every:5m | every:1h | daily:03:00 | hourly:30 | cron:*\/5 * * * * | at:时间戳
|
||||||
|
// 每个任务需实现 TaskInterface 接口
|
||||||
|
'tasks' => [
|
||||||
|
// 示例:
|
||||||
|
// [
|
||||||
|
// 'class' => App\Task\CleanLogTask::class,
|
||||||
|
// 'name' => '清理日志',
|
||||||
|
// 'expression' => 'daily:03:00', // 每天 03:00
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// 'class' => App\Task\HeartbeatTask::class,
|
||||||
|
// 'name' => '心跳检测',
|
||||||
|
// 'expression' => 'every:60', // 每 60 秒
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab\Annotate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务注解 — 标记一个类为定时任务
|
||||||
|
*
|
||||||
|
* 放在实现了 TaskInterface 的类上,由 CrontabProcess 启动时自动发现并注册
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* #[Crontab(name: '清理日志', hour: 3, minute: 0, loop: true)]
|
||||||
|
* class CleanLogTask implements TaskInterface { ... }
|
||||||
|
*
|
||||||
|
* #[Crontab(name: '心跳', tick: 30, loop: true)]
|
||||||
|
* class HeartbeatTask implements TaskInterface { ... }
|
||||||
|
*/
|
||||||
|
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||||
|
class Crontab
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name 任务显示名称(为空则使用类名)
|
||||||
|
* @param bool $loop 是否循环执行,默认 false (一次性)
|
||||||
|
* @param int|null $tick 循环间隔: 秒
|
||||||
|
* @param int|null $tickMinute 循环间隔: 分钟
|
||||||
|
* @param int|null $tickHour 循环间隔: 小时
|
||||||
|
* @param int|null $year 执行时间: 年
|
||||||
|
* @param int|null $month 执行时间: 月 (1-12)
|
||||||
|
* @param int|null $day 执行时间: 日 (1-31)
|
||||||
|
* @param int|null $hour 执行时间: 时 (0-23)
|
||||||
|
* @param int|null $minute 执行时间: 分 (0-59)
|
||||||
|
* @param int|null $second 执行时间: 秒 (0-59)
|
||||||
|
* @param string|null $cron 标准 5 字段 cron 表达式
|
||||||
|
* @param int|string|null $every 旧版兼容: 每N秒(int) 或 '5m'/'1h'
|
||||||
|
* @param string|null $dailyAt 旧版兼容: 每天 HH:MM
|
||||||
|
* @param int|null $hourlyAt 旧版兼容: 每小时第N分钟
|
||||||
|
* @param int|null $at 旧版兼容: Unix时间戳一次性
|
||||||
|
* @param string|null $expression 旧版兼容: 完整表达式字符串
|
||||||
|
* @param string $status 任务状态: active / paused / disabled
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $name = '',
|
||||||
|
public bool $loop = false,
|
||||||
|
public ?int $tick = null,
|
||||||
|
public ?int $tickMinute = null,
|
||||||
|
public ?int $tickHour = null,
|
||||||
|
public ?int $year = null,
|
||||||
|
public ?int $month = null,
|
||||||
|
public ?int $day = null,
|
||||||
|
public ?int $hour = null,
|
||||||
|
public ?int $minute = null,
|
||||||
|
public ?int $second = null,
|
||||||
|
public ?string $cron = null,
|
||||||
|
public int|string|null $every = null,
|
||||||
|
public ?string $dailyAt = null,
|
||||||
|
public ?int $hourlyAt = null,
|
||||||
|
public ?int $at = null,
|
||||||
|
public ?string $expression = null,
|
||||||
|
public string $status = 'active',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将调度参数转换为标准 expression 字符串供底层引擎使用
|
||||||
|
*
|
||||||
|
* 转换优先级:
|
||||||
|
* expression → cron → tick* → 时间字段(loop) → 时间字段(once) → 旧版快捷参数
|
||||||
|
*/
|
||||||
|
public function buildExpression(): string
|
||||||
|
{
|
||||||
|
if ($this->expression !== null && $this->expression !== '') {
|
||||||
|
return $this->expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->cron !== null && $this->cron !== '') {
|
||||||
|
return 'cron:' . $this->cron;
|
||||||
|
}
|
||||||
|
|
||||||
|
$everyExpr = $this->buildTickExpression();
|
||||||
|
if ($everyExpr !== '') {
|
||||||
|
return $everyExpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasAnyTimeField()) {
|
||||||
|
return $this->buildClockExpression();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->every !== null) {
|
||||||
|
return is_int($this->every) ? 'every:' . $this->every : 'every:' . $this->every;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->dailyAt !== null) {
|
||||||
|
return 'daily:' . $this->dailyAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hourlyAt !== null) {
|
||||||
|
return 'hourly:' . $this->hourlyAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->at !== null) {
|
||||||
|
return 'at:' . $this->at;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 tick 间隔表达式
|
||||||
|
*/
|
||||||
|
private function buildTickExpression(): string
|
||||||
|
{
|
||||||
|
if ($this->tick !== null) {
|
||||||
|
return 'every:' . $this->tick;
|
||||||
|
}
|
||||||
|
if ($this->tickMinute !== null) {
|
||||||
|
return 'every:' . $this->tickMinute . 'm';
|
||||||
|
}
|
||||||
|
if ($this->tickHour !== null) {
|
||||||
|
return 'every:' . $this->tickHour . 'h';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建闹钟模式表达式
|
||||||
|
*/
|
||||||
|
private function buildClockExpression(): string
|
||||||
|
{
|
||||||
|
$h = $this->hour ?? 0;
|
||||||
|
$m = $this->minute ?? 0;
|
||||||
|
$s = $this->second ?? 0;
|
||||||
|
|
||||||
|
if ($this->loop) {
|
||||||
|
if ($this->isDailyPattern()) {
|
||||||
|
return sprintf('daily:%02d:%02d:%02d', $h, $m, $s);
|
||||||
|
}
|
||||||
|
if ($this->isHourlyPattern()) {
|
||||||
|
return sprintf('hourly:%02d', $m);
|
||||||
|
}
|
||||||
|
return $this->buildCronFromClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildAtFromClock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否匹配 daily 模式 (只有 hour/minute/second,无 year/month/day)
|
||||||
|
*/
|
||||||
|
private function isDailyPattern(): bool
|
||||||
|
{
|
||||||
|
return $this->year === null
|
||||||
|
&& $this->month === null
|
||||||
|
&& $this->day === null
|
||||||
|
&& $this->hour !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否匹配 hourly 模式 (只有 minute/second,无 year/month/day/hour)
|
||||||
|
*/
|
||||||
|
private function isHourlyPattern(): bool
|
||||||
|
{
|
||||||
|
return $this->year === null
|
||||||
|
&& $this->month === null
|
||||||
|
&& $this->day === null
|
||||||
|
&& $this->hour === null
|
||||||
|
&& $this->minute !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将时间字段转为标准 5 字段 cron 表达式
|
||||||
|
*/
|
||||||
|
private function buildCronFromClock(): string
|
||||||
|
{
|
||||||
|
$min = $this->minute !== null ? (string)$this->minute : '*';
|
||||||
|
$h = $this->hour !== null ? (string)$this->hour : '*';
|
||||||
|
$d = $this->day !== null ? (string)$this->day : '*';
|
||||||
|
$mon = $this->month !== null ? (string)$this->month : '*';
|
||||||
|
|
||||||
|
return "cron:{$min} {$h} {$d} {$mon} *";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算一次性执行的 Unix 时间戳
|
||||||
|
*/
|
||||||
|
private function buildAtFromClock(): string
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$timeInfo = getdate($now);
|
||||||
|
|
||||||
|
$y = $this->year ?? (int)$timeInfo['year'];
|
||||||
|
$mon = $this->month ?? (int)$timeInfo['mon'];
|
||||||
|
$d = $this->day ?? (int)$timeInfo['mday'];
|
||||||
|
$h = $this->hour ?? (int)$timeInfo['hours'];
|
||||||
|
$m = $this->minute ?? (int)$timeInfo['minutes'];
|
||||||
|
$s = $this->second ?? (int)$timeInfo['seconds'];
|
||||||
|
|
||||||
|
$timestamp = mktime($h, $m, $s, $mon, $d, $y);
|
||||||
|
|
||||||
|
if ($timestamp <= $now) {
|
||||||
|
if ($this->year === null && $this->month === null && $this->day === null) {
|
||||||
|
$timestamp = mktime($h, $m, $s, $mon, $d + 1, $y);
|
||||||
|
} elseif ($this->day === null) {
|
||||||
|
$timestamp = mktime($h, $m, $s, $mon + 1, $d, $y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'at:' . $timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否设置了任意时间字段
|
||||||
|
*/
|
||||||
|
private function hasAnyTimeField(): bool
|
||||||
|
{
|
||||||
|
return $this->year !== null
|
||||||
|
|| $this->month !== null
|
||||||
|
|| $this->day !== null
|
||||||
|
|| $this->hour !== null
|
||||||
|
|| $this->minute !== null
|
||||||
|
|| $this->second !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易 Cron 表达式解析器 — 支持标准 5 字段 cron + 自定义表达式
|
||||||
|
*
|
||||||
|
* 标准 cron 格式: 分 时 日 月 周
|
||||||
|
* * 匹配任意值
|
||||||
|
* *\/N 每 N 单位执行一次
|
||||||
|
* N 精确匹配
|
||||||
|
* N,M 枚举匹配
|
||||||
|
* N-M 范围匹配
|
||||||
|
*
|
||||||
|
* 自定义表达式:
|
||||||
|
* every:{秒}
|
||||||
|
* every:{秒}s
|
||||||
|
* every:{分}m
|
||||||
|
* every:{时}h
|
||||||
|
* daily:{HH:MM}
|
||||||
|
* hourly:{MM}
|
||||||
|
* at:{时间戳}
|
||||||
|
*/
|
||||||
|
class CronExpression
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算给定时间戳之后的下次执行时间
|
||||||
|
*
|
||||||
|
* @param string $expression 调度表达式
|
||||||
|
* @param int $afterTimestamp 起始时间戳 (不含)
|
||||||
|
* @return int 下次执行时间戳,一次性任务过期返回 0
|
||||||
|
*/
|
||||||
|
public function getNextRunTime(string $expression, int $afterTimestamp = 0): int
|
||||||
|
{
|
||||||
|
if ($afterTimestamp <= 0) {
|
||||||
|
$afterTimestamp = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
str_starts_with($expression, 'every:') => $this->parseEvery($expression, $afterTimestamp),
|
||||||
|
str_starts_with($expression, 'daily:') => $this->parseDaily($expression, $afterTimestamp),
|
||||||
|
str_starts_with($expression, 'hourly:') => $this->parseHourly($expression, $afterTimestamp),
|
||||||
|
str_starts_with($expression, 'at:') => $this->parseAt($expression, $afterTimestamp),
|
||||||
|
str_starts_with($expression, 'cron:') => $this->parseCron(substr($expression, 5), $afterTimestamp),
|
||||||
|
default => $this->parseCron($expression, $afterTimestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达式的可读间隔描述
|
||||||
|
*/
|
||||||
|
public function getIntervalDescription(string $expression): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
str_starts_with($expression, 'every:') => $this->describeEvery($expression),
|
||||||
|
str_starts_with($expression, 'daily:') => '每天 ' . substr($expression, 6),
|
||||||
|
str_starts_with($expression, 'hourly:') => '每小时第 ' . substr($expression, 7) . ' 分',
|
||||||
|
str_starts_with($expression, 'at:') => '一次性任务',
|
||||||
|
str_starts_with($expression, 'cron:') => 'Cron: ' . substr($expression, 5),
|
||||||
|
default => 'Cron: ' . $expression,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为一次性任务
|
||||||
|
*/
|
||||||
|
public function isOneShot(string $expression): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($expression, 'at:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 every:{N}[s|m|h] 表达式
|
||||||
|
*/
|
||||||
|
private function parseEvery(string $expression, int $afterTimestamp): int
|
||||||
|
{
|
||||||
|
$value = substr($expression, 6);
|
||||||
|
|
||||||
|
$seconds = match (true) {
|
||||||
|
str_ends_with($value, 's') => (int)$value,
|
||||||
|
str_ends_with($value, 'm') => (int)$value * 60,
|
||||||
|
str_ends_with($value, 'h') => (int)$value * 3600,
|
||||||
|
default => (int)$value,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从上次调度时间累加,避免时间漂移和积压
|
||||||
|
// 如果 afterTimestamp 距离上次调度超过 n 个周期,只加一个周期(按第一次调度时间对齐)
|
||||||
|
return $afterTimestamp + $seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 描述 every 表达式
|
||||||
|
*/
|
||||||
|
private function describeEvery(string $expression): string
|
||||||
|
{
|
||||||
|
$value = substr($expression, 6);
|
||||||
|
return match (true) {
|
||||||
|
str_ends_with($value, 's') => '每 ' . ((int)$value) . ' 秒',
|
||||||
|
str_ends_with($value, 'm') => '每 ' . ((int)$value) . ' 分钟',
|
||||||
|
str_ends_with($value, 'h') => '每 ' . ((int)$value) . ' 小时',
|
||||||
|
default => '每 ' . ((int)$value) . ' 秒',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 daily:{HH:MM} 表达式
|
||||||
|
*/
|
||||||
|
private function parseDaily(string $expression, int $afterTimestamp): int
|
||||||
|
{
|
||||||
|
$timeStr = substr($expression, 6);
|
||||||
|
$parts = explode(':', $timeStr);
|
||||||
|
$hour = (int)($parts[0] ?? 0);
|
||||||
|
$minute = (int)($parts[1] ?? 0);
|
||||||
|
$second = (int)($parts[2] ?? 0);
|
||||||
|
|
||||||
|
$currentDate = getdate($afterTimestamp);
|
||||||
|
$targetTime = mktime($hour, $minute, $second, $currentDate['mon'], $currentDate['mday'], $currentDate['year']);
|
||||||
|
|
||||||
|
if ($targetTime <= $afterTimestamp) {
|
||||||
|
// 今天的时间已过,推到明天
|
||||||
|
$targetTime = $targetTime + 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targetTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 hourly:{MM} 表达式
|
||||||
|
*/
|
||||||
|
private function parseHourly(string $expression, int $afterTimestamp): int
|
||||||
|
{
|
||||||
|
$minute = (int)substr($expression, 7);
|
||||||
|
$currentDate = getdate($afterTimestamp);
|
||||||
|
$targetTime = mktime($currentDate['hours'], $minute, 0, $currentDate['mon'], $currentDate['mday'], $currentDate['year']);
|
||||||
|
|
||||||
|
if ($targetTime <= $afterTimestamp) {
|
||||||
|
$targetTime = $targetTime + 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targetTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 at:{时间戳} 表达式 — 一次性任务
|
||||||
|
*/
|
||||||
|
private function parseAt(string $expression, int $afterTimestamp): int
|
||||||
|
{
|
||||||
|
$timestamp = (int)substr($expression, 3);
|
||||||
|
if ($timestamp <= $afterTimestamp) {
|
||||||
|
// 已过期,返回 0 表示不再调度
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return $timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析标准 5 字段 cron 表达式: 分 时 日 月 周
|
||||||
|
*/
|
||||||
|
private function parseCron(string $cronExpression, int $afterTimestamp): int
|
||||||
|
{
|
||||||
|
$fields = preg_split('/\s+/', trim($cronExpression));
|
||||||
|
if (count($fields) !== 5) {
|
||||||
|
// 格式无效,返回 0
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$minute = $fields[0];
|
||||||
|
$hour = $fields[1];
|
||||||
|
$day = $fields[2];
|
||||||
|
$month = $fields[3];
|
||||||
|
$weekday = $fields[4];
|
||||||
|
|
||||||
|
// 从 afterTimestamp 下一秒开始逐分钟搜索,最多搜索 2 年
|
||||||
|
$searchStart = $afterTimestamp + 60;
|
||||||
|
$searchEnd = $afterTimestamp + 365 * 2 * 86400;
|
||||||
|
|
||||||
|
for ($ts = $searchStart; $ts <= $searchEnd; $ts += 60) {
|
||||||
|
$t = getdate($ts);
|
||||||
|
$matched = true;
|
||||||
|
|
||||||
|
$matched = $matched && $this->matchCronField($minute, $t['minutes'], 0, 59);
|
||||||
|
$matched = $matched && $this->matchCronField($hour, $t['hours'], 0, 23);
|
||||||
|
$matched = $matched && $this->matchCronField($day, $t['mday'], 1, 31);
|
||||||
|
$matched = $matched && $this->matchCronField($month, $t['mon'], 1, 12);
|
||||||
|
$matched = $matched && $this->matchCronField($weekday, $t['wday'], 0, 6);
|
||||||
|
|
||||||
|
if ($matched) {
|
||||||
|
return $ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配单个 cron 字段值,支持 *、*\/N、N、N,M、N-M
|
||||||
|
*
|
||||||
|
* @param string $fieldValue cron 字段原始值
|
||||||
|
* @param int $current 当前时间单位的值
|
||||||
|
* @param int $min 该字段的最小值
|
||||||
|
* @param int $max 该字段的最大值
|
||||||
|
*/
|
||||||
|
private function matchCronField(string $fieldValue, int $current, int $min, int $max): bool
|
||||||
|
{
|
||||||
|
// * 匹配所有值
|
||||||
|
if ($fieldValue === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// *\/N 每隔 N 步进
|
||||||
|
if (str_starts_with($fieldValue, '*/')) {
|
||||||
|
$step = (int)substr($fieldValue, 2);
|
||||||
|
if ($step <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ($current - $min) % $step === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逗号分隔的枚举值
|
||||||
|
if (str_contains($fieldValue, ',')) {
|
||||||
|
$values = explode(',', $fieldValue);
|
||||||
|
foreach ($values as $val) {
|
||||||
|
if ($this->matchCronField(trim($val), $current, $min, $max)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-M 范围值
|
||||||
|
if (str_contains($fieldValue, '-')) {
|
||||||
|
$parts = explode('-', $fieldValue);
|
||||||
|
$rangeStart = (int)$parts[0];
|
||||||
|
$rangeEnd = (int)$parts[1];
|
||||||
|
return $current >= $rangeStart && $current <= $rangeEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确值
|
||||||
|
return (int)$fieldValue === $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crontab 控制台命令 — 独立模式下的启动/停止/管理命令
|
||||||
|
*
|
||||||
|
* 支持的命令:
|
||||||
|
* php bin/crontab start 启动调度器
|
||||||
|
* php bin/crontab stop 停止调度器
|
||||||
|
* php bin/crontab restart 重启调度器
|
||||||
|
* php bin/crontab status 查看调度状态
|
||||||
|
*/
|
||||||
|
class CrontabCommand
|
||||||
|
{
|
||||||
|
|
||||||
|
/** @var string PID 文件路径 */
|
||||||
|
private string $pidFile;
|
||||||
|
|
||||||
|
/** @var string 日志文件路径 */
|
||||||
|
private string $logFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $pidFile PID 文件路径
|
||||||
|
* @param string $logFile 日志文件路径
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
string $pidFile = '',
|
||||||
|
string $logFile = '',
|
||||||
|
) {
|
||||||
|
$this->pidFile = $pidFile !== '' ? $pidFile : sys_get_temp_dir() . '/crontab.pid';
|
||||||
|
$this->logFile = $logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动调度器进程
|
||||||
|
*/
|
||||||
|
public function start(array $config): void
|
||||||
|
{
|
||||||
|
if ($this->isRunning()) {
|
||||||
|
echo "[Crontab] 调度器已在运行中, PID: " . $this->getPid() . PHP_EOL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = TaskRegistry::count();
|
||||||
|
if ($count === 0) {
|
||||||
|
echo "[Crontab] 警告: 没有注册任何任务" . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[Crontab] 启动调度器..." . PHP_EOL;
|
||||||
|
echo "[Crontab] 已注册 {$count} 个任务" . PHP_EOL;
|
||||||
|
|
||||||
|
$process = new \Swoole\Process(function (\Swoole\Process $worker) use ($config) {
|
||||||
|
file_put_contents($this->pidFile, (string)$worker->pid);
|
||||||
|
|
||||||
|
$crontabProcess = new CrontabProcess($config);
|
||||||
|
$crontabProcess->run();
|
||||||
|
}, false, 0, true);
|
||||||
|
|
||||||
|
$pid = $process->start();
|
||||||
|
|
||||||
|
echo "[Crontab] 调度器已启动, PID: {$pid}" . PHP_EOL;
|
||||||
|
echo "[Crontab] PID 文件: {$this->pidFile}" . PHP_EOL;
|
||||||
|
|
||||||
|
\Swoole\Process::wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止调度器进程
|
||||||
|
*/
|
||||||
|
public function stop(): void
|
||||||
|
{
|
||||||
|
if (!$this->isRunning()) {
|
||||||
|
echo "[Crontab] 调度器未运行" . PHP_EOL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = $this->getPid();
|
||||||
|
|
||||||
|
if ($pid > 0 && \Swoole\Process::kill($pid, 0)) {
|
||||||
|
\Swoole\Process::kill($pid, SIGTERM);
|
||||||
|
echo "[Crontab] 已发送停止信号, PID: {$pid}" . PHP_EOL;
|
||||||
|
|
||||||
|
$timeout = 10;
|
||||||
|
while ($timeout > 0 && \Swoole\Process::kill($pid, 0)) {
|
||||||
|
usleep(200000);
|
||||||
|
$timeout--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\Swoole\Process::kill($pid, 0)) {
|
||||||
|
\Swoole\Process::kill($pid, SIGKILL);
|
||||||
|
echo "[Crontab] 进程未响应, 已强制终止" . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($this->pidFile)) {
|
||||||
|
unlink($this->pidFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[Crontab] 调度器已停止" . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启调度器进程
|
||||||
|
*/
|
||||||
|
public function restart(array $config): void
|
||||||
|
{
|
||||||
|
echo "[Crontab] 重启调度器..." . PHP_EOL;
|
||||||
|
$this->stop();
|
||||||
|
usleep(500000);
|
||||||
|
$this->start($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看调度器状态
|
||||||
|
*/
|
||||||
|
public function status(): void
|
||||||
|
{
|
||||||
|
if ($this->isRunning()) {
|
||||||
|
$pid = $this->getPid();
|
||||||
|
echo "[Crontab] 状态: 运行中" . PHP_EOL;
|
||||||
|
echo "[Crontab] PID: {$pid}" . PHP_EOL;
|
||||||
|
} else {
|
||||||
|
echo "[Crontab] 状态: 未运行" . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查调度器是否在运行
|
||||||
|
*/
|
||||||
|
private function isRunning(): bool
|
||||||
|
{
|
||||||
|
$pid = $this->getPid();
|
||||||
|
if ($pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return \Swoole\Process::kill($pid, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PID 文件读取进程 ID
|
||||||
|
*/
|
||||||
|
private function getPid(): int
|
||||||
|
{
|
||||||
|
if (!file_exists($this->pidFile)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$pid = (int)file_get_contents($this->pidFile);
|
||||||
|
return $pid > 0 ? $pid : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Swoole\Coroutine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crontab Swoole 进程 — 作为独立进程运行任务调度器
|
||||||
|
*
|
||||||
|
* 两种运行模式:
|
||||||
|
* 1. 独立模式: 直接调用 run() 启动,自建 Swoole 事件循环
|
||||||
|
* 2. kiri-core 集成模式: 注册为自定义 Process,由 kiri-http-server 管理生命周期
|
||||||
|
*
|
||||||
|
* 任务注册方式:
|
||||||
|
* - 配置模式: config/crontab.php 的 tasks 中声明
|
||||||
|
* - 注解模式: 任务类上使用 #[Crontab] 注解 + CrontabScanner 自动扫描
|
||||||
|
* 两种方式可同时使用
|
||||||
|
*
|
||||||
|
* 独立模式示例:
|
||||||
|
* $process = new CrontabProcess($config, $registry);
|
||||||
|
* $process->run();
|
||||||
|
*
|
||||||
|
* kiri-core 集成模式:
|
||||||
|
* 在 config/servers.php 的 process 中添加 CrontabProcess::class 即可
|
||||||
|
*/
|
||||||
|
class CrontabProcess
|
||||||
|
{
|
||||||
|
|
||||||
|
private ?CrontabScheduler $scheduler = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $config 配置数组 (crontab.php 的完整内容)
|
||||||
|
* @param LoggerInterface|null $logger 日志记录器
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private array $config,
|
||||||
|
private ?LoggerInterface $logger = null,
|
||||||
|
) {
|
||||||
|
if ($this->logger === null) {
|
||||||
|
$this->logger = new NullLogger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描注解任务并启动进程 (独立模式)
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->logger->info('[CrontabProcess] 进程启动中...');
|
||||||
|
|
||||||
|
$this->discoverAnnotationTasks();
|
||||||
|
|
||||||
|
Coroutine::run(function () {
|
||||||
|
$this->startScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->logger->info('[CrontabProcess] 进程已退出');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 kiri-core 自定义进程中启动调度器
|
||||||
|
* 由框架的 AbstractProcess 生命周期调用
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->logger->info('[CrontabProcess] 在 kiri-core 进程内启动...');
|
||||||
|
|
||||||
|
$this->discoverAnnotationTasks();
|
||||||
|
|
||||||
|
$this->startScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前调度器实例
|
||||||
|
*/
|
||||||
|
public function getScheduler(): ?CrontabScheduler
|
||||||
|
{
|
||||||
|
return $this->scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已声明的类中发现 #[Crontab] 类注解并注册到 TaskRegistry
|
||||||
|
* CrontabProcess 启动时自动调用,检查所有已加载的类
|
||||||
|
*/
|
||||||
|
private function discoverAnnotationTasks(): void
|
||||||
|
{
|
||||||
|
$beforeCount = TaskRegistry::count();
|
||||||
|
|
||||||
|
foreach (get_declared_classes() as $className) {
|
||||||
|
if (!in_array(TaskInterface::class, class_implements($className), true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflect = new \ReflectionClass($className);
|
||||||
|
if ($reflect->isAbstract()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取类上的 #[Crontab] 注解
|
||||||
|
$attributes = $reflect->getAttributes(Annotate\Crontab::class);
|
||||||
|
if (empty($attributes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Annotate\Crontab $instance */
|
||||||
|
$instance = $attributes[0]->newInstance();
|
||||||
|
|
||||||
|
$scheduleExpression = $instance->buildExpression();
|
||||||
|
if ($scheduleExpression === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskRegistry::register([
|
||||||
|
'class' => $className,
|
||||||
|
'name' => $instance->name !== '' ? $instance->name : $className,
|
||||||
|
'expression' => $scheduleExpression,
|
||||||
|
'status' => $instance->status,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// 跳过无法反射或注册失败的类
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$afterCount = TaskRegistry::count();
|
||||||
|
if ($afterCount > $beforeCount) {
|
||||||
|
$this->logger->info("[CrontabProcess] 发现注解任务 " . ($afterCount - $beforeCount) . " 个");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化并启动调度器
|
||||||
|
*/
|
||||||
|
private function startScheduler(): void
|
||||||
|
{
|
||||||
|
$redis = $this->createRedisConnection();
|
||||||
|
$cronExpression = new CronExpression();
|
||||||
|
|
||||||
|
$schedulerConfig = $this->config['scheduler'] ?? [];
|
||||||
|
|
||||||
|
$this->scheduler = new CrontabScheduler(
|
||||||
|
redis: $redis,
|
||||||
|
cronExpression: $cronExpression,
|
||||||
|
logger: $this->logger,
|
||||||
|
tickInterval: (int)($schedulerConfig['tick_interval'] ?? 1),
|
||||||
|
taskTimeout: (int)($schedulerConfig['task_timeout'] ?? 300),
|
||||||
|
lockTtl: (int)($schedulerConfig['lock_ttl'] ?? 60),
|
||||||
|
lockRenewInterval: (int)($schedulerConfig['lock_renew_interval'] ?? 15),
|
||||||
|
concurrentTasks: (bool)($schedulerConfig['concurrent_tasks'] ?? true),
|
||||||
|
maxConcurrent: (int)($schedulerConfig['max_concurrent'] ?? 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->scheduler->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Redis 连接
|
||||||
|
* 在独立模式下直接创建连接,不通过 kiri-core 的连接池
|
||||||
|
*/
|
||||||
|
private function createRedisConnection(): \Redis
|
||||||
|
{
|
||||||
|
$redisConfig = $this->config['redis'] ?? [];
|
||||||
|
|
||||||
|
$host = $redisConfig['host'] ?? '127.0.0.1';
|
||||||
|
$port = (int)($redisConfig['port'] ?? 6379);
|
||||||
|
$auth = $redisConfig['auth'] ?? '';
|
||||||
|
$databases = (int)($redisConfig['databases'] ?? 0);
|
||||||
|
$timeout = (int)($redisConfig['timeout'] ?? 30);
|
||||||
|
$prefix = $redisConfig['prefix'] ?? '';
|
||||||
|
|
||||||
|
$redis = new \Redis();
|
||||||
|
if (!$redis->connect($host, $port, $timeout)) {
|
||||||
|
throw new \RuntimeException("Redis 连接失败: {$host}:{$port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($auth) && !$redis->auth($auth)) {
|
||||||
|
throw new \RuntimeException("Redis 认证失败: {$redis->getLastError()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$redis->select($databases);
|
||||||
|
|
||||||
|
if (!empty($prefix)) {
|
||||||
|
$redis->setOption(\Redis::OPT_PREFIX, $prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabProcess] Redis 已连接: {$host}:{$port} DB:{$databases}");
|
||||||
|
|
||||||
|
return $redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
use Kiri\Abstracts\Providers;
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kiri-core 框架集成 Provider — 将 CrontabCommand 注册到 Symfony Console
|
||||||
|
*
|
||||||
|
* 在 kiri-core 项目的 config/servers.php 中配置:
|
||||||
|
* ```php
|
||||||
|
* 'process' => [
|
||||||
|
* \Kiri\Crontab\CrontabProcess::class,
|
||||||
|
* ],
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 在应用 Kernel 的 getCommands() 中添加:
|
||||||
|
* ```php
|
||||||
|
* public function getCommands(): array
|
||||||
|
* {
|
||||||
|
* return [
|
||||||
|
* \Kiri\Crontab\CrontabCommand::class,
|
||||||
|
* ];
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class CrontabProviders extends Providers
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册 CrontabCommand 到 Console Application
|
||||||
|
*/
|
||||||
|
public function onImport(): void
|
||||||
|
{
|
||||||
|
$command = $this->container->get(CrontabCommand::class);
|
||||||
|
$console = $this->container->get(Application::class);
|
||||||
|
$console->addCommand($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Swoole\Coroutine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心调度引擎 — 基于 Redis ZSET 的定时任务调度器
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 将 TaskRegistry 中的任务同步到 Redis (Hash + ZSET)
|
||||||
|
* 2. 主循环轮询 ZSET 获取到期任务
|
||||||
|
* 3. 通过任务锁防止并发重复执行
|
||||||
|
* 4. 执行任务并更新下次调度时间
|
||||||
|
* 5. 支持协程并发执行多个到期任务
|
||||||
|
* 6. 支持运行时动态投递和取消任务
|
||||||
|
*
|
||||||
|
* Redis 数据结构:
|
||||||
|
* crontab:queue — ZSET, score=下次执行时间戳, member=taskKey
|
||||||
|
* crontab:task:{key} — Hash, 任务元数据
|
||||||
|
* crontab:lock:master — String, 调度器主锁
|
||||||
|
* crontab:lock:task:{key} — String, 任务执行锁
|
||||||
|
* crontab:running — SET, 当前执行中的任务
|
||||||
|
*/
|
||||||
|
class CrontabScheduler
|
||||||
|
{
|
||||||
|
|
||||||
|
/** @var string Redis key 前缀 */
|
||||||
|
private const KEY_PREFIX = 'crontab';
|
||||||
|
|
||||||
|
/** @var string ZSET 调度队列 key */
|
||||||
|
private const QUEUE_KEY = 'crontab:queue';
|
||||||
|
|
||||||
|
/** @var string 主锁 key */
|
||||||
|
private const MASTER_LOCK_KEY = 'crontab:lock:master';
|
||||||
|
|
||||||
|
/** @var string 运行中任务 SET key */
|
||||||
|
private const RUNNING_SET_KEY = 'crontab:running';
|
||||||
|
|
||||||
|
/** @var int 默认 tick 间隔 (秒) */
|
||||||
|
private const DEFAULT_TICK_INTERVAL = 1;
|
||||||
|
|
||||||
|
/** @var int 默认任务执行超时 (秒) */
|
||||||
|
private const DEFAULT_TASK_TIMEOUT = 300;
|
||||||
|
|
||||||
|
/** @var int 默认主锁 TTL (秒) */
|
||||||
|
private const DEFAULT_LOCK_TTL = 60;
|
||||||
|
|
||||||
|
private bool $running = false;
|
||||||
|
|
||||||
|
/** @var string|null 当前正在执行的任务 key (协程安全) */
|
||||||
|
private ?string $currentTaskKey = null;
|
||||||
|
|
||||||
|
/** @var self|null 全局实例引用,供任务内部访问 */
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Redis $redis Redis 客户端
|
||||||
|
* @param TaskRegistry $registry 任务注册中心
|
||||||
|
* @param CronExpression $cronExpression Cron 表达式解析器
|
||||||
|
* @param LoggerInterface $logger 日志记录器
|
||||||
|
* @param int $tickInterval tick 间隔 (秒)
|
||||||
|
* @param int $taskTimeout 任务执行超时 (秒)
|
||||||
|
* @param int $lockTtl 主锁 TTL (秒)
|
||||||
|
* @param int $lockRenewInterval 主锁续期间隔 (秒)
|
||||||
|
* @param bool $concurrentTasks 是否协程并发执行
|
||||||
|
* @param int $maxConcurrent 最大并发数
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private \Redis $redis,
|
||||||
|
private CronExpression $cronExpression,
|
||||||
|
private LoggerInterface $logger = new NullLogger(),
|
||||||
|
private int $tickInterval = self::DEFAULT_TICK_INTERVAL,
|
||||||
|
private int $taskTimeout = self::DEFAULT_TASK_TIMEOUT,
|
||||||
|
private int $lockTtl = self::DEFAULT_LOCK_TTL,
|
||||||
|
private int $lockRenewInterval = 15,
|
||||||
|
private bool $concurrentTasks = true,
|
||||||
|
private int $maxConcurrent = 10,
|
||||||
|
) {
|
||||||
|
self::$instance = $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全局调度器实例
|
||||||
|
* 在任务 handle() 内部可通过此方法获取调度器引用,用于取消当前任务等操作
|
||||||
|
*/
|
||||||
|
public static function getInstance(): ?self
|
||||||
|
{
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动调度器主循环
|
||||||
|
* 阻塞运行,直到 stop() 被调用或收到退出信号
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
$this->running = true;
|
||||||
|
|
||||||
|
$this->logger->info('[CrontabScheduler] 调度器启动,注册 ' . TaskRegistry::count() . ' 个任务');
|
||||||
|
|
||||||
|
$this->syncTasks();
|
||||||
|
|
||||||
|
$nextTickTime = $this->calculateNextTickTime();
|
||||||
|
|
||||||
|
while ($this->running) {
|
||||||
|
$now = microtime(true);
|
||||||
|
$sleepSeconds = max(0, $nextTickTime - $now);
|
||||||
|
|
||||||
|
if ($sleepSeconds > 0) {
|
||||||
|
usleep((int)($sleepSeconds * 1000000));
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextTickTime = $this->calculateNextTickTime();
|
||||||
|
|
||||||
|
if (!$this->running) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tick();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->logger->error('[CrontabScheduler] tick 异常: ' . $throwable->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$instance = null;
|
||||||
|
$this->logger->info('[CrontabScheduler] 调度器已停止');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止调度器
|
||||||
|
*/
|
||||||
|
public function stop(): void
|
||||||
|
{
|
||||||
|
$this->running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否运行中
|
||||||
|
*/
|
||||||
|
public function isRunning(): bool
|
||||||
|
{
|
||||||
|
return $this->running;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 动态任务投递 API
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态投递任务 — 运行时向调度系统提交一个即时任务
|
||||||
|
*
|
||||||
|
* @param string $className 实现了 TaskInterface 的任务类
|
||||||
|
* @param string $expression 调度表达式 (every:1, at:时间戳, daily:03:00 等)
|
||||||
|
* @param string $name 任务显示名称 (可选)
|
||||||
|
* @return string 返回生成的 taskKey,可用于后续 cancel
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* $scheduler = CrontabScheduler::getInstance();
|
||||||
|
* $taskKey = $scheduler->submit(MyTask::class, 'every:1', '匹配检查');
|
||||||
|
* // 任务每秒执行,匹配成功后在 handle() 中调用 cancel 停止
|
||||||
|
*/
|
||||||
|
public function submit(string $className, string $expression, string $name = ''): string
|
||||||
|
{
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
throw new \InvalidArgumentException("任务类不存在: {$className}");
|
||||||
|
}
|
||||||
|
if (!in_array(TaskInterface::class, class_implements($className), true)) {
|
||||||
|
throw new \InvalidArgumentException("{$className} 必须实现 TaskInterface 接口");
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskKey = $this->generateDynamicTaskKey($className);
|
||||||
|
|
||||||
|
$taskConfig = new TaskConfig(
|
||||||
|
taskKey: $taskKey,
|
||||||
|
className: $className,
|
||||||
|
name: $name !== '' ? $name : $className,
|
||||||
|
expression: $expression,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: time(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->persistNewTask($taskConfig);
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 动态投递任务: {$taskKey} '{$taskConfig->name}' '{$expression}'");
|
||||||
|
|
||||||
|
return $taskKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消/移除指定任务 — 从 Redis 队列和元数据中彻底删除
|
||||||
|
*
|
||||||
|
* @param string $taskKey 任务标识,由 submit() 返回
|
||||||
|
* @return bool 是否成功取消
|
||||||
|
*/
|
||||||
|
public function cancelTask(string $taskKey): bool
|
||||||
|
{
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
|
||||||
|
if (!$this->redis->exists($hashKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
$this->redis->del($hashKey);
|
||||||
|
$this->redis->del($this->getTaskLockKey($taskKey));
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 任务已取消: {$taskKey}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消当前正在执行的任务
|
||||||
|
* 在 TaskInterface::handle() 内部调用,执行完后任务将被移除不再调度
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* class MatchTask implements TaskInterface {
|
||||||
|
* public function handle(): void {
|
||||||
|
* if ($this->isMatched()) {
|
||||||
|
* CrontabScheduler::getInstance()?->cancelCurrentTask();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function cancelCurrentTask(): void
|
||||||
|
{
|
||||||
|
if ($this->currentTaskKey !== null) {
|
||||||
|
$this->markTaskForRemoval($this->currentTaskKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前正在执行的任务 key (供任务内部使用)
|
||||||
|
*/
|
||||||
|
public function getCurrentTaskKey(): ?string
|
||||||
|
{
|
||||||
|
return $this->currentTaskKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 任务管理 API
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步任务到 Redis
|
||||||
|
*/
|
||||||
|
public function syncTasks(): void
|
||||||
|
{
|
||||||
|
foreach (TaskRegistry::all() as $taskKey => $taskConfig) {
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
|
||||||
|
if (!$this->redis->exists($hashKey)) {
|
||||||
|
$this->persistNewTask($taskConfig);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mergeExistingTask($taskConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新所有任务的表达式和下次运行时间
|
||||||
|
*/
|
||||||
|
public function refreshTasks(): void
|
||||||
|
{
|
||||||
|
foreach (TaskRegistry::all() as $taskKey => $taskConfig) {
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
|
||||||
|
$nextRun = $this->cronExpression->getNextRunTime($taskConfig->expression, time() - 1);
|
||||||
|
$interval = $this->cronExpression->getIntervalDescription($taskConfig->expression);
|
||||||
|
|
||||||
|
$taskConfig->nextRun = $nextRun;
|
||||||
|
$taskConfig->interval = $interval;
|
||||||
|
|
||||||
|
$hashData = $taskConfig->toHash();
|
||||||
|
$this->redis->hMSet($hashKey, $hashData);
|
||||||
|
|
||||||
|
if ($taskConfig->status === 'active' && $nextRun > 0) {
|
||||||
|
$this->redis->zAdd(self::QUEUE_KEY, $nextRun, $taskKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停指定任务
|
||||||
|
*/
|
||||||
|
public function pauseTask(string $taskKey): bool
|
||||||
|
{
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
if (!$this->redis->exists($hashKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redis->hSet($hashKey, 'status', 'paused');
|
||||||
|
$this->redis->zRem(self::QUEUE_KEY, $taskKey);
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 任务已暂停: {$taskKey}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复指定任务
|
||||||
|
*/
|
||||||
|
public function resumeTask(string $taskKey): bool
|
||||||
|
{
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
$hash = $this->redis->hGetAll($hashKey);
|
||||||
|
if (empty($hash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = TaskConfig::fromHash($taskKey, $hash);
|
||||||
|
$nextRun = $this->cronExpression->getNextRunTime($config->expression, time() - 1);
|
||||||
|
|
||||||
|
$this->redis->hMSet($hashKey, [
|
||||||
|
'status' => 'active',
|
||||||
|
'next_run' => $nextRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($nextRun > 0) {
|
||||||
|
$this->redis->zAdd(self::QUEUE_KEY, $nextRun, $taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 任务已恢复: {$taskKey}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 内部调度
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次 tick — 检查并执行到期的任务
|
||||||
|
*/
|
||||||
|
private function tick(): void
|
||||||
|
{
|
||||||
|
if (!$this->acquireMasterLock()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->processDueTasks();
|
||||||
|
} finally {
|
||||||
|
$this->releaseMasterLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主锁,防止多实例同时调度
|
||||||
|
*/
|
||||||
|
private function acquireMasterLock(): bool
|
||||||
|
{
|
||||||
|
return $this->redis->set(
|
||||||
|
self::MASTER_LOCK_KEY,
|
||||||
|
(string)getmypid(),
|
||||||
|
['nx', 'ex' => $this->lockTtl]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放主锁
|
||||||
|
*/
|
||||||
|
private function releaseMasterLock(): void
|
||||||
|
{
|
||||||
|
$script = <<<'LUA'
|
||||||
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('del', KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
LUA;
|
||||||
|
$this->redis->eval($script, [self::MASTER_LOCK_KEY, (string)getmypid()], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 续期主锁
|
||||||
|
*/
|
||||||
|
private function renewMasterLock(): void
|
||||||
|
{
|
||||||
|
$this->redis->expire(self::MASTER_LOCK_KEY, $this->lockTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理所有到期任务
|
||||||
|
*/
|
||||||
|
private function processDueTasks(): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$dueTaskKeys = $this->redis->zRangeByScore(self::QUEUE_KEY, '-inf', $now, ['limit' => [0, 100]]);
|
||||||
|
|
||||||
|
if (empty($dueTaskKeys)) {
|
||||||
|
$this->renewMasterLock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renewMasterLock();
|
||||||
|
|
||||||
|
if ($this->concurrentTasks) {
|
||||||
|
$this->executeTasksConcurrently($dueTaskKeys, $now);
|
||||||
|
} else {
|
||||||
|
foreach ($dueTaskKeys as $taskKey) {
|
||||||
|
$this->executeSingleTask($taskKey, $now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 并发执行到期任务 (通过协程)
|
||||||
|
*/
|
||||||
|
private function executeTasksConcurrently(array $taskKeys, int $now): void
|
||||||
|
{
|
||||||
|
$batchSize = min(count($taskKeys), $this->maxConcurrent);
|
||||||
|
|
||||||
|
$channel = new Coroutine\Channel($batchSize);
|
||||||
|
|
||||||
|
foreach ($taskKeys as $taskKey) {
|
||||||
|
$channel->push(true);
|
||||||
|
|
||||||
|
Coroutine::create(function () use ($taskKey, $now, $channel) {
|
||||||
|
try {
|
||||||
|
$this->executeSingleTask($taskKey, $now);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->logger->error("[CrontabScheduler] 并发执行异常: {$taskKey} - {$throwable->getMessage()}");
|
||||||
|
} finally {
|
||||||
|
$channel->pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < $batchSize; $i++) {
|
||||||
|
$channel->push(true);
|
||||||
|
}
|
||||||
|
$channel->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单个任务
|
||||||
|
*/
|
||||||
|
private function executeSingleTask(string $taskKey, int $now): void
|
||||||
|
{
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
$hash = $this->redis->hGetAll($hashKey);
|
||||||
|
|
||||||
|
if (empty($hash)) {
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = TaskConfig::fromHash($taskKey, $hash);
|
||||||
|
|
||||||
|
if ($config->status !== 'active') {
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->acquireTaskLock($taskKey)) {
|
||||||
|
$this->logger->warning("[CrontabScheduler] 任务锁获取失败 (可能仍在执行中): {$taskKey}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// 记录当前任务 key,供任务内部通过 cancelCurrentTask() 取消自身
|
||||||
|
$this->currentTaskKey = $taskKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->redis->sAdd(self::RUNNING_SET_KEY, $taskKey);
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 开始执行: {$taskKey} ({$config->name})");
|
||||||
|
|
||||||
|
$className = $config->className;
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
throw new \RuntimeException("任务类不存在: {$className}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var TaskInterface $taskInstance */
|
||||||
|
$taskInstance = new $className();
|
||||||
|
$taskInstance->handle();
|
||||||
|
|
||||||
|
$duration = round(microtime(true) - $startTime, 4);
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 执行成功: {$taskKey} 耗时 {$duration}s");
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$duration = round(microtime(true) - $startTime, 4);
|
||||||
|
|
||||||
|
$this->logger->error("[CrontabScheduler] 执行失败: {$taskKey} 耗时 {$duration}s 错误: {$throwable->getMessage()}");
|
||||||
|
} finally {
|
||||||
|
$this->redis->sRem(self::RUNNING_SET_KEY, $taskKey);
|
||||||
|
|
||||||
|
// 检查是否被标记为"执行后移除"
|
||||||
|
$shouldRemove = $this->isTaskMarkedForRemoval($taskKey);
|
||||||
|
|
||||||
|
if ($shouldRemove) {
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
$this->redis->del($hashKey);
|
||||||
|
$this->redis->del($this->getTaskLockKey($taskKey));
|
||||||
|
$this->clearRemovalFlag($taskKey);
|
||||||
|
$this->logger->info("[CrontabScheduler] 任务已自毁: {$taskKey}");
|
||||||
|
} else {
|
||||||
|
$this->finalizeTaskExecution($config, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->releaseTaskLock($taskKey);
|
||||||
|
$this->currentTaskKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成执行后的任务状态更新
|
||||||
|
*/
|
||||||
|
private function finalizeTaskExecution(TaskConfig $config, int $now): void
|
||||||
|
{
|
||||||
|
$taskKey = $config->taskKey;
|
||||||
|
$hashKey = $this->getTaskHashKey($taskKey);
|
||||||
|
|
||||||
|
$isOneShot = $this->cronExpression->isOneShot($config->expression);
|
||||||
|
|
||||||
|
if ($isOneShot) {
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
$this->redis->del($hashKey);
|
||||||
|
$this->logger->info("[CrontabScheduler] 一次性任务已移除: {$taskKey}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextRun = $this->cronExpression->getNextRunTime($config->expression, $now);
|
||||||
|
$interval = $this->cronExpression->getIntervalDescription($config->expression);
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'last_run' => $now,
|
||||||
|
'next_run' => $nextRun,
|
||||||
|
'interval' => $interval,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->redis->hMSet($hashKey, $updateData);
|
||||||
|
|
||||||
|
if ($nextRun > 0) {
|
||||||
|
$this->redis->zAdd(self::QUEUE_KEY, $nextRun, $taskKey);
|
||||||
|
} else {
|
||||||
|
$this->removeFromQueue($taskKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记任务为"下次执行后移除"
|
||||||
|
* 当任务内部调用 cancelCurrentTask() 时,不立即删除(执行中操作安全),仅标记
|
||||||
|
* 等当前执行完成后,executeSingleTask 的 finally 块会检查此标记并清理
|
||||||
|
*/
|
||||||
|
private function markTaskForRemoval(string $taskKey): void
|
||||||
|
{
|
||||||
|
$this->redis->set('crontab:removal:' . $taskKey, '1', 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查任务是否被标记为待移除
|
||||||
|
*/
|
||||||
|
private function isTaskMarkedForRemoval(string $taskKey): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->redis->exists('crontab:removal:' . $taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除移除标记
|
||||||
|
*/
|
||||||
|
private function clearRemovalFlag(string $taskKey): void
|
||||||
|
{
|
||||||
|
$this->redis->del('crontab:removal:' . $taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Redis 操作辅助
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务执行锁
|
||||||
|
*/
|
||||||
|
private function acquireTaskLock(string $taskKey): bool
|
||||||
|
{
|
||||||
|
$lockKey = $this->getTaskLockKey($taskKey);
|
||||||
|
return $this->redis->set($lockKey, (string)time(), ['nx', 'ex' => $this->taskTimeout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放任务执行锁
|
||||||
|
*/
|
||||||
|
private function releaseTaskLock(string $taskKey): void
|
||||||
|
{
|
||||||
|
$this->redis->del($this->getTaskLockKey($taskKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从调度队列移除任务
|
||||||
|
*/
|
||||||
|
private function removeFromQueue(string $taskKey): void
|
||||||
|
{
|
||||||
|
$this->redis->zRem(self::QUEUE_KEY, $taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化新任务到 Redis
|
||||||
|
*/
|
||||||
|
private function persistNewTask(TaskConfig $taskConfig): void
|
||||||
|
{
|
||||||
|
$nextRun = $this->cronExpression->getNextRunTime($taskConfig->expression, time() - 1);
|
||||||
|
$interval = $this->cronExpression->getIntervalDescription($taskConfig->expression);
|
||||||
|
|
||||||
|
$taskConfig->nextRun = $nextRun;
|
||||||
|
$taskConfig->interval = $interval;
|
||||||
|
$taskConfig->createdAt = time();
|
||||||
|
|
||||||
|
$hashKey = $this->getTaskHashKey($taskConfig->taskKey);
|
||||||
|
$this->redis->hMSet($hashKey, $taskConfig->toHash());
|
||||||
|
|
||||||
|
if ($taskConfig->status === 'active' && $nextRun > 0) {
|
||||||
|
$this->redis->zAdd(self::QUEUE_KEY, $nextRun, $taskConfig->taskKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info("[CrontabScheduler] 新任务已注册: {$taskConfig->taskKey} 下次执行: " . date('Y-m-d H:i:s', $nextRun));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并更新已有任务配置 (保留运行时状态)
|
||||||
|
*/
|
||||||
|
private function mergeExistingTask(TaskConfig $taskConfig): void
|
||||||
|
{
|
||||||
|
$hashKey = $this->getTaskHashKey($taskConfig->taskKey);
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'class' => $taskConfig->className,
|
||||||
|
'name' => $taskConfig->name,
|
||||||
|
'expression' => $taskConfig->expression,
|
||||||
|
];
|
||||||
|
$this->redis->hMSet($hashKey, $updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成动态任务的唯一标识
|
||||||
|
*/
|
||||||
|
private function generateDynamicTaskKey(string $className): string
|
||||||
|
{
|
||||||
|
$baseKey = strtolower(str_replace('\\', '.', ltrim($className, '\\')));
|
||||||
|
$suffix = substr(md5(uniqid((string)mt_rand(), true)), 0, 8);
|
||||||
|
|
||||||
|
return 'dynamic.' . $baseKey . '.' . $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 辅助方法
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算下一个 tick 的精确时间点 (对齐到秒边界)
|
||||||
|
*/
|
||||||
|
private function calculateNextTickTime(): float
|
||||||
|
{
|
||||||
|
$now = microtime(true);
|
||||||
|
$current = floor($now);
|
||||||
|
return $current + $this->tickInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务 Hash key
|
||||||
|
*/
|
||||||
|
private function getTaskHashKey(string $taskKey): string
|
||||||
|
{
|
||||||
|
return self::KEY_PREFIX . ':task:' . $taskKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务执行锁 key
|
||||||
|
*/
|
||||||
|
private function getTaskLockKey(string $taskKey): string
|
||||||
|
{
|
||||||
|
return self::KEY_PREFIX . ':lock:task:' . $taskKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务执行前事件
|
||||||
|
*/
|
||||||
|
class OnTaskBeforeExecute
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $taskKey 任务标识
|
||||||
|
* @param string $className 任务处理类
|
||||||
|
* @param string $taskName 任务显示名称
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $taskKey,
|
||||||
|
public string $className,
|
||||||
|
public string $taskName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务执行成功事件
|
||||||
|
*/
|
||||||
|
class OnTaskExecuted
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $taskKey 任务标识
|
||||||
|
* @param string $className 任务处理类
|
||||||
|
* @param string $taskName 任务显示名称
|
||||||
|
* @param float $duration 执行耗时 (秒)
|
||||||
|
* @param int $nextRun 下次执行时间戳
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $taskKey,
|
||||||
|
public string $className,
|
||||||
|
public string $taskName,
|
||||||
|
public float $duration,
|
||||||
|
public int $nextRun,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab\Events;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务执行失败事件
|
||||||
|
*/
|
||||||
|
class OnTaskFailed
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $taskKey 任务标识
|
||||||
|
* @param string $className 任务处理类
|
||||||
|
* @param string $taskName 任务显示名称
|
||||||
|
* @param \Throwable $error 异常信息
|
||||||
|
* @param float $duration 执行耗时 (秒)
|
||||||
|
* @param int $nextRun 下次执行时间戳 (失败仍会调度下次)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $taskKey,
|
||||||
|
public string $className,
|
||||||
|
public string $taskName,
|
||||||
|
public \Throwable $error,
|
||||||
|
public float $duration,
|
||||||
|
public int $nextRun,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务配置值对象 — 描述一个定时任务的完整元数据
|
||||||
|
*
|
||||||
|
* 调度表达式支持以下格式:
|
||||||
|
* every:{秒} 每 N 秒执行
|
||||||
|
* every:{秒}s 每 N 秒执行
|
||||||
|
* every:{分}m 每 N 分钟执行
|
||||||
|
* every:{时}h 每 N 小时执行
|
||||||
|
* daily:{HH:MM} 每天指定时间
|
||||||
|
* hourly:{MM} 每小时指定分钟
|
||||||
|
* cron:{表达式} 标准 5 字段 cron
|
||||||
|
* at:{时间戳} 一次性执行
|
||||||
|
*/
|
||||||
|
class TaskConfig
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $taskKey 任务唯一标识,用于 Redis key
|
||||||
|
* @param string $className 任务处理类完整路径
|
||||||
|
* @param string $name 任务显示名称
|
||||||
|
* @param string $expression 调度表达式
|
||||||
|
* @param string $status 状态: active / paused / disabled
|
||||||
|
* @param int $nextRun 下次执行时间戳 (0 表示立即)
|
||||||
|
* @param int $lastRun 上次执行时间戳
|
||||||
|
* @param string $interval 可读的执行间隔描述
|
||||||
|
* @param int $createdAt 创建时间戳
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $taskKey,
|
||||||
|
public string $className,
|
||||||
|
public string $name = '',
|
||||||
|
public string $expression = '',
|
||||||
|
public string $status = 'active',
|
||||||
|
public int $nextRun = 0,
|
||||||
|
public int $lastRun = 0,
|
||||||
|
public string $interval = '',
|
||||||
|
public int $createdAt = 0,
|
||||||
|
) {
|
||||||
|
if ($this->createdAt === 0) {
|
||||||
|
$this->createdAt = time();
|
||||||
|
}
|
||||||
|
if ($this->name === '') {
|
||||||
|
$this->name = $this->taskKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数组创建配置对象
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): static
|
||||||
|
{
|
||||||
|
return new static(
|
||||||
|
taskKey: $data['taskKey'] ?? $data['class'],
|
||||||
|
className: $data['class'],
|
||||||
|
name: $data['name'] ?? '',
|
||||||
|
expression: $data['expression'] ?? '',
|
||||||
|
status: $data['status'] ?? 'active',
|
||||||
|
nextRun: (int)($data['next_run'] ?? 0),
|
||||||
|
lastRun: (int)($data['last_run'] ?? 0),
|
||||||
|
interval: $data['interval'] ?? '',
|
||||||
|
createdAt: (int)($data['created_at'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Hash 数据生成配置对象 (从 Redis 读取)
|
||||||
|
*/
|
||||||
|
public static function fromHash(string $taskKey, array $hash): static
|
||||||
|
{
|
||||||
|
return new static(
|
||||||
|
taskKey: $taskKey,
|
||||||
|
className: $hash['class'] ?? '',
|
||||||
|
name: $hash['name'] ?? '',
|
||||||
|
expression: $hash['expression'] ?? '',
|
||||||
|
status: $hash['status'] ?? 'active',
|
||||||
|
nextRun: (int)($hash['next_run'] ?? 0),
|
||||||
|
lastRun: (int)($hash['last_run'] ?? 0),
|
||||||
|
interval: $hash['interval'] ?? '',
|
||||||
|
createdAt: (int)($hash['created_at'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转为 Hash 存储数组
|
||||||
|
*/
|
||||||
|
public function toHash(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'class' => $this->className,
|
||||||
|
'name' => $this->name,
|
||||||
|
'expression' => $this->expression,
|
||||||
|
'status' => $this->status,
|
||||||
|
'next_run' => $this->nextRun,
|
||||||
|
'last_run' => $this->lastRun,
|
||||||
|
'interval' => $this->interval,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务接口 — 所有定时任务必须实现此接口
|
||||||
|
*
|
||||||
|
* 任务类通过 TaskRegistry 注册后,由 CrontabScheduler 按调度表达式定时执行
|
||||||
|
* 任务类构造函数可接受 DI 注入的参数(独立模式下需要自行管理依赖)
|
||||||
|
*/
|
||||||
|
interface TaskInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任务的核心逻辑
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws \Throwable 异常会被调度器捕获记录,不影响后续任务调度
|
||||||
|
*/
|
||||||
|
public function handle(): void;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务注册中心 — 静态注册表,管理所有定时任务配置
|
||||||
|
*
|
||||||
|
* 支持两种注册方式:
|
||||||
|
* 1. Scanner 驱动: #[Crontab] 注解在方法上,Scanner 自动调用 register()
|
||||||
|
* 2. 配置驱动: config/crontab.php 的 tasks 列表中声明
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* TaskRegistry::register(['class' => ..., 'expression' => 'daily:03:00']);
|
||||||
|
* $all = TaskRegistry::all();
|
||||||
|
* $count = TaskRegistry::count();
|
||||||
|
*/
|
||||||
|
class TaskRegistry
|
||||||
|
{
|
||||||
|
|
||||||
|
/** @var array<string, TaskConfig> 已注册任务,key 为任务标识 */
|
||||||
|
private static array $tasks = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册单个任务
|
||||||
|
*
|
||||||
|
* @param array $config 任务配置数组,必须包含 class 和 expression 字段
|
||||||
|
* @return TaskConfig
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public static function register(array $config): TaskConfig
|
||||||
|
{
|
||||||
|
if (empty($config['class'])) {
|
||||||
|
throw new \InvalidArgumentException('任务配置必须包含 class 字段');
|
||||||
|
}
|
||||||
|
if (empty($config['expression'])) {
|
||||||
|
throw new \InvalidArgumentException('任务配置必须包含 expression 字段');
|
||||||
|
}
|
||||||
|
|
||||||
|
$className = $config['class'];
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
throw new \InvalidArgumentException("任务类不存在: {$className}");
|
||||||
|
}
|
||||||
|
if (!in_array(TaskInterface::class, class_implements($className), true)) {
|
||||||
|
throw new \InvalidArgumentException("{$className} 必须实现 TaskInterface 接口");
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskKey = self::generateTaskKey($config['class']);
|
||||||
|
$taskConfig = TaskConfig::fromArray(array_merge($config, ['taskKey' => $taskKey]));
|
||||||
|
|
||||||
|
self::$tasks[$taskKey] = $taskConfig;
|
||||||
|
|
||||||
|
return $taskConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册任务
|
||||||
|
*
|
||||||
|
* @param array<array> $configs 任务配置数组列表
|
||||||
|
* @return array<string, TaskConfig>
|
||||||
|
*/
|
||||||
|
public static function registerMany(array $configs): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$taskConfig = self::register($config);
|
||||||
|
$results[$taskConfig->taskKey] = $taskConfig;
|
||||||
|
}
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已注册任务
|
||||||
|
*
|
||||||
|
* @return array<string, TaskConfig>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return self::$tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定任务配置
|
||||||
|
*/
|
||||||
|
public static function get(string $taskKey): ?TaskConfig
|
||||||
|
{
|
||||||
|
return self::$tasks[$taskKey] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有活跃任务 (status=active)
|
||||||
|
*/
|
||||||
|
public static function getActiveTasks(): array
|
||||||
|
{
|
||||||
|
return array_filter(self::$tasks, fn(TaskConfig $config) => $config->status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务总数
|
||||||
|
*/
|
||||||
|
public static function count(): int
|
||||||
|
{
|
||||||
|
return count(self::$tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空注册表 (用于测试)
|
||||||
|
*/
|
||||||
|
public static function clear(): void
|
||||||
|
{
|
||||||
|
self::$tasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类名生成任务唯一标识
|
||||||
|
* 将反斜杠替换为点号,转为小写可读 key
|
||||||
|
*/
|
||||||
|
private static function generateTaskKey(string $className): string
|
||||||
|
{
|
||||||
|
return strtolower(
|
||||||
|
str_replace('\\', '.', ltrim($className, '\\'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Kiri\Crontab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向调度系统动态投递一个任务
|
||||||
|
*
|
||||||
|
* 任何业务代码都可以直接调用此函数,无需持有 CrontabScheduler 引用
|
||||||
|
* 适用于运行时突发投递场景(如用户触发、事件回调等)
|
||||||
|
*
|
||||||
|
* @param string $className 实现了 TaskInterface 的任务类
|
||||||
|
* @param string $expression 调度表达式 (every:1, every:5m, at:时间戳, daily:03:00 等)
|
||||||
|
* @param string $name 任务显示名称 (可选)
|
||||||
|
* @return string 返回 taskKey,可用于后续取消
|
||||||
|
* @throws \RuntimeException 调度器未运行时抛出
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* // 每秒检查匹配结果
|
||||||
|
* $key = submitToCrontab(MatchCheckTask::class, 'every:1', '匹配检查 #123');
|
||||||
|
*
|
||||||
|
* // 1 小时后执行一次
|
||||||
|
* $key = submitToCrontab(DelayNotifyTask::class, 'at:' . (time() + 3600), '延迟通知');
|
||||||
|
*/
|
||||||
|
function submitToCrontab(string $className, string $expression, string $name = ''): string
|
||||||
|
{
|
||||||
|
$scheduler = CrontabScheduler::getInstance();
|
||||||
|
|
||||||
|
if ($scheduler === null) {
|
||||||
|
throw new \RuntimeException('调度器未运行,无法投递任务。请先启动 CrontabProcess');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scheduler->submit($className, $expression, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消指定任务
|
||||||
|
*
|
||||||
|
* @param string $taskKey 由 submit() 或 submitToCrontab() 返回的任务标识
|
||||||
|
* @return bool 是否成功取消
|
||||||
|
*/
|
||||||
|
function cancelCrontabTask(string $taskKey): bool
|
||||||
|
{
|
||||||
|
$scheduler = CrontabScheduler::getInstance();
|
||||||
|
|
||||||
|
if ($scheduler === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scheduler->cancelTask($taskKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CronExpression 解析器单元测试
|
||||||
|
*
|
||||||
|
* 运行: php tests/CronExpressionTest.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Kiri\Crontab\CronExpression;
|
||||||
|
|
||||||
|
// 手动加载源文件 (未安装 composer 时)
|
||||||
|
require_once __DIR__ . '/../src/CronExpression.php';
|
||||||
|
|
||||||
|
class CronExpressionTest
|
||||||
|
{
|
||||||
|
|
||||||
|
private CronExpression $parser;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->parser = new CronExpression();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行所有测试
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$methods = get_class_methods($this);
|
||||||
|
$passed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
if (!str_starts_with($method, 'test')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo " 运行: {$method}..." . PHP_EOL;
|
||||||
|
try {
|
||||||
|
$this->{$method}();
|
||||||
|
$passed++;
|
||||||
|
echo " ✓ 通过" . PHP_EOL;
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$failed++;
|
||||||
|
echo " ✗ 失败: {$throwable->getMessage()}" . PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo PHP_EOL;
|
||||||
|
echo "总计: " . ($passed + $failed) . " 个测试, {$passed} 通过, {$failed} 失败" . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础断言
|
||||||
|
*/
|
||||||
|
private function assertTrue(bool $condition, string $message = ''): void
|
||||||
|
{
|
||||||
|
if (!$condition) {
|
||||||
|
throw new \RuntimeException($message ?: '断言失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 every:60 表达式
|
||||||
|
*/
|
||||||
|
public function testEverySeconds(): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$next = $this->parser->getNextRunTime('every:60', $now);
|
||||||
|
$this->assertTrue($next === $now + 60, "期望 " . ($now + 60) . " 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 every:5m 表达式
|
||||||
|
*/
|
||||||
|
public function testEveryMinutes(): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$next = $this->parser->getNextRunTime('every:5m', $now);
|
||||||
|
$this->assertTrue($next === $now + 300, "期望 " . ($now + 300) . " 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 every:1h 表达式
|
||||||
|
*/
|
||||||
|
public function testEveryHours(): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$next = $this->parser->getNextRunTime('every:1h', $now);
|
||||||
|
$this->assertTrue($next === $now + 3600, "期望 " . ($now + 3600) . " 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 daily 表达式
|
||||||
|
*/
|
||||||
|
public function testDaily(): void
|
||||||
|
{
|
||||||
|
$now = mktime(10, 0, 0, 1, 15, 2026); // 2026-01-15 10:00:00
|
||||||
|
$next = $this->parser->getNextRunTime('daily:03:00', $now);
|
||||||
|
$expectedToday = mktime(3, 0, 0, 1, 15, 2026);
|
||||||
|
|
||||||
|
if ($now >= $expectedToday) {
|
||||||
|
$expected = mktime(3, 0, 0, 1, 16, 2026);
|
||||||
|
} else {
|
||||||
|
$expected = $expectedToday;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue($next === $expected, "期望 $expected 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 hourly 表达式
|
||||||
|
*/
|
||||||
|
public function testHourly(): void
|
||||||
|
{
|
||||||
|
$now = mktime(10, 15, 0, 1, 15, 2026); // 10:15
|
||||||
|
$next = $this->parser->getNextRunTime('hourly:30', $now);
|
||||||
|
// 10:15 之后的下一个 10:30
|
||||||
|
$expected = mktime(10, 30, 0, 1, 15, 2026);
|
||||||
|
$this->assertTrue($next === $expected, "期望 $expected 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 at 一次性任务
|
||||||
|
*/
|
||||||
|
public function testAt(): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$future = $now + 3600;
|
||||||
|
$next = $this->parser->getNextRunTime("at:{$future}", $now);
|
||||||
|
$this->assertTrue($next === $future, "期望 $future 实际 $next");
|
||||||
|
|
||||||
|
// 已过期的 at 任务返回 0
|
||||||
|
$past = $now - 3600;
|
||||||
|
$expired = $this->parser->getNextRunTime("at:{$past}", $now);
|
||||||
|
$this->assertTrue($expired === 0, "期望 0 实际 $expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 isOneShot
|
||||||
|
*/
|
||||||
|
public function testIsOneShot(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->parser->isOneShot('at:1234567890'));
|
||||||
|
$this->assertTrue(!$this->parser->isOneShot('every:60'));
|
||||||
|
$this->assertTrue(!$this->parser->isOneShot('daily:03:00'));
|
||||||
|
$this->assertTrue(!$this->parser->isOneShot('cron:* * * * *'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 cron 表达式 每5分钟
|
||||||
|
*/
|
||||||
|
public function testCronEvery5Minutes(): void
|
||||||
|
{
|
||||||
|
$now = mktime(10, 2, 0, 1, 15, 2026); // 10:02
|
||||||
|
$next = $this->parser->getNextRunTime('cron:*/5 * * * *', $now);
|
||||||
|
// 下一个 */5 是 10:05
|
||||||
|
$expected = mktime(10, 5, 0, 1, 15, 2026);
|
||||||
|
$this->assertTrue($next === $expected, "期望 $expected 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 cron 表达式精确时间
|
||||||
|
*/
|
||||||
|
public function testCronExactTime(): void
|
||||||
|
{
|
||||||
|
$now = mktime(10, 0, 0, 1, 15, 2026);
|
||||||
|
$next = $this->parser->getNextRunTime('cron:30 8 * * *', $now);
|
||||||
|
// 下一个 8:30 是明天
|
||||||
|
$expected = mktime(8, 30, 0, 1, 16, 2026);
|
||||||
|
$this->assertTrue($next === $expected, "期望 $expected 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 cron 无效表达式
|
||||||
|
*/
|
||||||
|
public function testInvalidCronExpression(): void
|
||||||
|
{
|
||||||
|
$next = $this->parser->getNextRunTime('cron:invalid', time());
|
||||||
|
$this->assertTrue($next === 0, "期望 0 实际 $next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试间隔描述
|
||||||
|
*/
|
||||||
|
public function testIntervalDescription(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->parser->getIntervalDescription('every:60') === '每 60 秒');
|
||||||
|
$this->assertTrue($this->parser->getIntervalDescription('every:5m') === '每 5 分钟');
|
||||||
|
$this->assertTrue($this->parser->getIntervalDescription('every:1h') === '每 1 小时');
|
||||||
|
$this->assertTrue($this->parser->getIntervalDescription('daily:03:00') === '每天 03:00');
|
||||||
|
$this->assertTrue($this->parser->getIntervalDescription('at:1234567890') === '一次性任务');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行测试
|
||||||
|
$test = new CronExpressionTest();
|
||||||
|
$test->run();
|
||||||
Reference in New Issue
Block a user