Files

316 lines
9.5 KiB
PHP
Raw Permalink Normal View History

2026-08-31 11:33:59 +08:00
<?php
namespace core\scheduler;
use app\service\admin\crontab\CrontabService;
use support\Container;
use Webman\RedisQueue\Client as RedisClient;
use WebmanTech\SymfonyLock\Locker;
use Workerman\Connection\TcpConnection;
use Workerman\Crontab\Crontab;
use Workerman\Timer;
use Workerman\Worker;
/**
* 该任务调度进程服务必须安装以下扩展组件
* 1.定时任务 composer require workerman/crontab
* 2.Redis composer require webman/redis-queue illuminate/redis
* 3.业务锁 composer require webman-tech/symfony-lock
* 4.env组件 composer require vlucas/phpdotenv
* @method getAllTask() 获取所有任务
* @method getTask($id) 获取单个任务
* @method writeRunLog($insert_data = []) 写入运行日志
* @method updateTaskRunState($id, $last_running_time) 更新任务状态
*/
class SchedulerServer
{
/**
* 进程
*
* @var \Workerman\Worker
*/
protected \Workerman\Worker $worker;
/**
* Redis
* @var \Workerman\RedisQueue\Client|null
*/
protected \Workerman\RedisQueue\Client|null $redis;
/**
* 调试模式
*
* @var bool
*/
protected bool $debug = false;
/**
* 是否运行定时任务呢
*
* @var bool
*/
protected bool $enabled = false;
/**
* 日志
*
* @var bool
*/
protected bool $writeLog = true;
/**
* 配置
*
* @var array
*/
protected array $config = [];
/**
* 任务进程池
*
* @var Crontab[] array
*/
private array $crontabPool = [];
/**
* @var \Workerman\RedisQueue\Client|null 订阅实例
*/
protected \Workerman\RedisQueue\Client|null $subscribeClient;
/**
* @var \Workerman\RedisQueue\Client|null 通知实例
*/
protected \Workerman\RedisQueue\Client|null $publishClient;
/**
* @param Worker $worker
*
* @return void
* 进程启动
*/
public function onWorkerStart(Worker $worker)
{
$config = config('core.scheduler.app');
$this->debug = $config['debug'] ?? true;
$this->writeLog = $config['write_log'] ?? true;
$this->enabled = $config['enable'] ?? false;
if (!$this->enabled) {
$this->writeln('定时任务未开启,如需开启,请修改配置 .env APP_TASK_ENABLED=true 或者core\\scheduler\\config\\app.php. enable = true ');
return false;
}
$this->writeln("定时任务消息通道:{$config['listen']},请注意端口是否冲突,");
$this->writeln("如果需要修改端口,请修改 core\\scheduler\\config\\app.php,process.php 两个文件");
$this->config = $config;
$this->worker = $worker;
//订阅事件
$this->subscribeEvent();
//初始化任务
$this->crontabInit();
}
/**
* 订阅事件
*/
private function subscribeEvent(): void
{
//创建发布实例
if (empty($this->publishClient)) {
$this->publishClient = $this->redisCreate();
$this->writeln('启用心跳');
Timer::add(30, function () {//保持链接活跃
$this->writeln('发送心跳');
$this->publishClient->send('ping', ['name' => 'ping', 'age' => 1]);
});
}
// 创建订阅实例
if (empty($this->subscribeClient)) {
$this->subscribeClient = $this->redisCreate();
}
//订阅一个心跳链接
$this->subscribeClient->subscribe('ping', function ($data) {
$this->writeln('收到心跳:' . json_encode($data, 256));
});
//订阅任务改变事件
$this->subscribeClient->subscribe('change_contrab', function ($data) {
$args = $data['args'] ?? '';
$this->crontabReload($args);
});
}
/**
* Redis 初始化
*
* @return \Webman\RedisQueue\Client|\Workerman\RedisQueue\Client|false
*/
function redisCreate(): RedisClient|\Workerman\RedisQueue\Client|false
{
$connection_name = 'default';
try {
$redis = RedisClient::connection($connection_name);
} catch (\Exception $e) {
var_dump($e->getMessage());
$this->writeln('Redis初始化失败');
return false;
}
return $redis;
}
/**
* @param TcpConnection $connection
* @param $data
*
* @return void
*/
public function onMessage(TcpConnection $connection, $data): void
{
$data = json_decode($data, true);
$method = $data['method'] ?? '';
$args = $data['args'] ?? '';
// 这里只有保留一个重启方法,对任务进行任何操作,直接调用重启解决
if (!in_array($method, ['crontabReload'])) {
$connection->send(json_encode(['code' => 400, 'msg' => "{$method} is not found"]));
return;
}
$this->writeln('发送重启通知');
//通知所有进程该任务进行重启
$this->publishClient->send('change_contrab', ['method' => 'crontabReload', 'args' => $args]);
$connection->send(json_encode(['code' => 200, 'msg' => "ok"]));
}
/**
* 重启定时任务
*
* @param array $param
*/
private function crontabReload(array $param): void
{
$ids = explode(',', (string)($param['id'] ?? ''));
foreach ($ids as $id) {
if (isset($this->crontabPool[$id])) {
$this->writeln('销毁定时器,ID:' . $id);
$this->crontabPool[$id]['crontab']->destroy();
unset($this->crontabPool[$id]);
}
$this->crontabRun($id);
}
}
/**
* 初始化定时任务
*
* @return void
*/
private function crontabInit(): void
{
/**
* 定时任务服务类
*/
$systemCrontabService = Container::make(CrontabService::class);
$data = $systemCrontabService->getTaskAll('id');
$this->writeln('定时器任务数:' . count($data));
if (!empty($data)) {
foreach ($data as $item) {
$this->crontabRun($item['id']);
}
}
}
/**
* 创建定时器
*
* @param string|int $id
*/
private function crontabRun(string|int $id): void
{
$systemCrontabService = Container::make(CrontabService::class);
$data = $systemCrontabService->getTask($id);
if (empty($data)) {
return;
}
if (intval($data['enabled']) === 0) {
return;
}
$crontab = new Crontab($data['rule'], callback: function () use (&$data, $systemCrontabService) { //这里传参必须传引用
//首次不执行(如果数据库中标记为未启动)
if (empty($data['first_start'])) {
$data['first_start'] = 1;
// 持久化到数据库(直接使用DAO层)
$systemCrontabService->dao->update($data['id'], ['first_start' => 1]);
$this->writeln("任务#{$data['id']} 首次执行跳过,已更新 first_start 状态");
return false;
}
//运行次数加1,很重要,多进程情况下用来检测当前次数是否已执行
$data['running_times'] += 1;
$uuid = $this->createTaskUuid($data);
//获取锁.
$lock = Locker::cash($uuid);
// 获取锁失败
if (!$lock) {
return;
}
try {
/** @var CrontabService $systemCrontabService */
$systemCrontabService = Container::make(CrontabService::class);
$reData = $systemCrontabService->runOneTask($data['id']);
$this->writeln( $data['title'].' 任务#' . $data['id'] . ' ' . $data['rule'] . ' ' . $data['target'], $reData['code']);
$this->isSingleton($data);
} finally {
// 释放锁
$lock->release();
}
});
$this->crontabPool[$data['id']] = [
'id' => $data['id'],
'create_time' => date('Y-m-d H:i:s'),
'crontab' => $crontab,
];
}
/**
* 是否单次
*
* @param $crontab
*
* @return void
*/
private function isSingleton($crontab): void
{
if ($crontab['singleton'] == 0 && isset($this->crontabPool[$crontab['id']])) {
$this->writeln("定时器销毁", true);
$this->crontabPool[$crontab['id']]['crontab']->destroy();
unset($this->crontabPool[$crontab['id']]);
}
}
/**
* @param $crontab
*
* @return string
* 创建任务uuid
*/
private function createTaskUuid($crontab): string
{
// 以任务id + 运行次数作为唯一id, 这样在过期范围内,只会有一个进程能执行到该次任务
return 'crontab_' . $crontab['id'] . '_' . $crontab['running_times'];
}
/**
* 输出日志
*
* @param $msg
* @param bool $isSuccess
*/
private function writeln($msg, bool $isSuccess = false): void
{
if ($this->debug) {
$workerId = isset($this->worker) && !empty($this->worker) ? $this->worker->id : 0;
echo 'webman-scheduler:' . $workerId . ' [' . date('Y-m-d H:i:s') . '] ' . $msg . ($isSuccess == 0 ? " [Ok] " : " [Fail] ") . PHP_EOL;
}
}
}