使用 Yii2 构建一个定时任务管理后台 [ 2.0 版本 ]
首先介绍一下我遇到过的,个人觉得奇葩的极其不方便的定时任务方式
每当有一个定时任务需求就在linux
下crontab
中注册一个任务
*/5 * * * * wget --spider "http://xxxxx.com/index.php?m=Kf&c=Task&a=recommendTasks"
*/2 * * * * wget --spider "http://xxxxx.com/index.php?m=Kf&c=Task&a=batchOneBuyCodesa"
*/5 * * * * wget --spider "http://xxxxx.com/index.php?m=Kf&c=Task&a=bathCardtradesd"
*/1 * * * * wget --spider "http://xxxxx.com/index.php?m=Kf&c=Task&a=pushg"
不知道有不有大兄弟躺枪了,希望你看了我的实现方式后,以后不要这么搞定时任务了,当然我的也不会是最好了,别钻牛角尖
这种方式的定时任务有什么问题?
- 显而易见的就是不知道这种鬼链接是什么个东西,想停不敢停怕背锅,久而久之就扔上面
- http请求的方式触发任务,任务多的时候占用webserver的资源(
如果是以cli模式触发就算了,当我没说
) - 无法记录任务运行的状态,例如: 是否运行成功,运行一次耗时多少(
你千万别跟我说在每个任务记录个里日志啥的好吧
)
我将围绕如何解决以上三个问题来展开我的实现过程
- 创建一个专门管理定时任务的表
CREATE TABLE `tb_crontab` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '定时任务名称',
`route` varchar(50) NOT NULL COMMENT '任务路由',
`crontab_str` varchar(50) NOT NULL COMMENT 'crontab格式',
`switch` tinyint(1) NOT NULL DEFAULT '0' COMMENT '任务开关 0关闭 1开启',
`status` tinyint(1) DEFAULT '0' COMMENT '任务运行状态 0正常 1任务报错',
`last_rundate` datetime DEFAULT NULL COMMENT '任务上次运行时间',
`next_rundate` datetime DEFAULT NULL COMMENT '任务下次运行时间',
`execmemory` decimal(9,2) NOT NULL DEFAULT '0.00' COMMENT '任务执行消耗内存(单位/byte)',
`exectime` decimal(9,2) NOT NULL DEFAULT '0.00' COMMENT '任务执行消耗时间',
PRIMARY KEY (`id`)
)
- 所有任务通过一个入口方法来调度
* * * * * cd /server/webroot/yii-project/ && php yii crontab/index
- 实现任务调度控制器
commands/CrontabController.php
<?php
namespace app\commands;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
use app\common\models\Crontab;
/**
* 定时任务调度控制器
* @author jlb
*/
class CrontabController extends Controller
{
/**
* 定时任务入口
* @return int Exit code
*/
public function actionIndex()
{
$crontab = Crontab::findAll(['switch' => 1]);
$tasks = [];
foreach ($crontab as $task) {
// 第一次运行,先计算下次运行时间
if (!$task->next_rundate) {
$task->next_rundate = $task->getNextRunDate();
$task->save(false);
continue;
}
// 判断运行时间到了没
if ($task->next_rundate <= date('Y-m-d H:i:s')) {
$tasks[] = $task;
}
}
$this->executeTask($tasks);
return ExitCode::OK;
}
/**
* @param array $tasks 任务列表
* @author jlb
*/
public function executeTask(array $tasks)
{
$pool = [];
$startExectime = $this->getCurrentTime();
foreach ($tasks as $task) {
$pool[] = proc_open("php yii $task->route", [], $pipe);
}
// 回收子进程
while (count($pool)) {
foreach ($pool as $i => $result) {
$etat = proc_get_status($result);
if($etat['running'] == FALSE) {
proc_close($result);
unset($pool[$i]);
# 记录任务状态
$tasks[$i]->exectime = round($this->getCurrentTime() - $startExectime, 2);
$tasks[$i]->last_rundate = date('Y-m-d H:i');
$tasks[$i]->next_rundate = $tasks[$i]->getNextRunDate();
$tasks[$i]->status = 0;
// 任务出错
if ($etat['exitcode'] !== ExitCode::OK) {
$tasks[$i]->status = 1;
}
$tasks[$i]->save(false);
}
}
}
}
private function getCurrentTime () {
list ($msec, $sec) = explode(" ", microtime());
return (float)$msec + (float)$sec;
}
}
- 实现crontab模型
common/models/Crontab.php
没有则自己创建
<?php
namespace app\common\models;
use Yii;
use app\common\helpers\CronParser;
/**
* 定时任务模型
* @author jlb
*/
class Crontab extends \yii\db\ActiveRecord
{
/**
* switch字段的文字映射
* @var array
*/
private $switchTextMap = [
0 => '关闭',
1 => '开启',
];
/**
* status字段的文字映射
* @var array
*/
private $statusTextMap = [
0 => '正常',
1 => '任务保存',
];
public static function getDb()
{
#注意!!!替换成自己的数据库配置组件名称
return Yii::$app->tfbmall;
}
/**
* 获取switch字段对应的文字
* @author jlb
* @return ''|string
*/
public function getSwitchText()
{
if(!isset($this->switchTextMap[$this->switch])) {
return '';
}
return $this->switchTextMap[$this->switch];
}
/**
* 获取status字段对应的文字
* @author jlb
* @return ''|string
*/
public function getStatusText()
{
if(!isset($this->statusTextMap[$this->status])) {
return '';
}
return $this->statusTextMap[$this->status];
}
/**
* 计算下次运行时间
* @author jlb
*/
public function getNextRunDate()
{
if (!CronParser::check($this->crontab_str)) {
throw new \Exception("格式校验失败: {$this->crontab_str}", 1);
}
return CronParser::formatToDate($this->crontab_str, 1)[0];
}
}
- 一个crontab格式工具解析类
common/helpers/CronParser.php
<?php
namespace app\common\helpers;
/**
* crontab格式解析工具类
* @author jlb <497012571@qq.com>
*/
class CronParser
{
protected static $weekMap = [
0 => 'Sunday',
1 => 'Monday',
2 => 'Tuesday',
3 => 'Wednesday',
4 => 'Thursday',
5 => 'Friday',
6 => 'Saturday',
];
/**
* 检查crontab格式是否支持
* @param string $cronstr
* @return boolean true|false
*/
public static function check($cronstr)
{
$cronstr = trim($cronstr);
if (count(preg_split('#\s+#', $cronstr)) !== 5) {
return false;
}
$reg = '#^(\*(/\d+)?|\d+([,\d\-]+)?)\s+(\*(/\d+)?|\d+([,\d\-]+)?)\s+(\*(/\d+)?|\d+([,\d\-]+)?)\s+(\*(/\d+)?|\d+([,\d\-]+)?)\s+(\*(/\d+)?|\d+([,\d\-]+)?)$#';
if (!preg_match($reg, $cronstr)) {
return false;
}
return true;
}
/**
* 格式化crontab格式字符串
* @param string $cronstr
* @param interge $maxSize 设置返回符合条件的时间数量, 默认为1
* @return array 返回符合格式的时间
*/
public static function formatToDate($cronstr, $maxSize = 1)
{
if (!static::check($cronstr)) {
throw new \Exception("格式错误: $cronstr", 1);
}
$tags = preg_split('#\s+#', $cronstr);
$crons = [
'minutes' => static::parseTag($tags[0], 0, 59), //分钟
'hours' => static::parseTag($tags[1], 0, 23), //小时
'day' => static::parseTag($tags[2], 1, 31), //一个月中的第几天
'month' => static::parseTag($tags[3], 1, 12), //月份
'week' => static::parseTag($tags[4], 0, 6), // 星期
];
$crons['week'] = array_map(function($item){
return static::$weekMap[$item];
}, $crons['week']);
$nowtime = strtotime(date('Y-m-d H:i'));
$today = getdate();
$dates = [];
foreach ($crons['month'] as $month) {
// 获取单月最大天数
$maxDay = cal_days_in_month(CAL_GREGORIAN, $month, date('Y'));
foreach ($crons['day'] as $day) {
if ($day > $maxDay) {
break;
}
foreach ($crons['hours'] as $hours) {
foreach ($crons['minutes'] as $minutes) {
$i = mktime($hours, $minutes, 0, $month, $day);
if ($nowtime > $i) {
continue;
}
$date = getdate($i);
// 解析是第几天
if ($tags[2] != '*' && in_array($date['mday'], $crons['day'])) {
$dates[] = date('Y-m-d H:i', $i);
}
// 解析星期几
if ($tags[4] != '*' && in_array($date['weekday'], $crons['week'])) {
$dates[] = date('Y-m-d H:i', $i);
}
// 天与星期几
if ($tags[2] == '*' && $tags[4] == '*') {
$dates[] = date('Y-m-d H:i', $i);
}
if (isset($dates) && count($dates) == $maxSize) {
break 4;
}
}
}
}
}
return array_unique($dates);
}
/**
* 解析元素
* @param string $tag 元素标签
* @param integer $tmin 最小值
* @param integer $tmax 最大值
* @throws \Exception
*/
protected static function parseTag($tag, $tmin, $tmax)
{
if ($tag == '*') {
return range($tmin, $tmax);
}
$step = 1;
$dateList = [];
if (false !== strpos($tag, '/')) {
$tmp = explode('/', $tag);
$step = isset($tmp[1]) ? $tmp[1] : 1;
$dateList = range($tmin, $tmax, $step);
}
else if (false !== strpos($tag, '-')) {
list($min, $max) = explode('-', $tag);
if ($min > $max) {
list($min, $max) = [$max, $min];
}
$dateList = range($min, $max, $step);
}
else if (false !== strpos($tag, ',')) {
$dateList = explode(',', $tag);
}
else {
$dateList = array($tag);
}
// 越界判断
foreach ($dateList as $num) {
if ($num < $tmin || $num > $tmax) {
throw new \Exception('数值越界');
}
}
sort($dateList);
return $dateList;
}
}
大功告成
创建一个用于测试的方法吧 commands/tasks/TestController.php
<?php
namespace app\commands\tasks;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
class TestController extends Controller
{
/**
* @return int Exit code
*/
public function actionIndex()
{
sleep(1);
echo "我是index方法\n";
return ExitCode::OK;
}
/**
* @return int Exit code
*/
public function actionTest()
{
sleep(2);
echo "我是test方法\n";
return ExitCode::OK;
}
}
还记得一开始就创建好的crontab表吗,手动在表添加任务如下
进入yii根目录运行
php yii crontab/index
即可看到效果
最后祭出我做好的的增删改查定时任务管理界面
这一块就劳烦你自己动动手仿照做出来吧
qq497012571
注册时间:2018-03-22
最后登录:2022-08-04
在线时长:20小时48分
最后登录:2022-08-04
在线时长:20小时48分
- 粉丝14
- 金钱475
- 威望30
- 积分975
共 21 条评论
可以自动执行这个命令吗php yii crontab/index
用crontab 一分钟运行一次
* * * * * cd /yii-project/ && php yii crontab/index
@qq497012571 这个命令是在linux下运行的吧!
旧的CronParser类不完善有BUG,所以附上最新的 crontab解析类
大家也许发现了,我这种方案只支持单服务器部署,如果定时任务太多,单机不够的情况下要做下集群,我也是有个方案,但是还没实际运用,是否有必要提上来,需要看大家的反馈与需求
有这个需求,我公司就需要集群,任务太多
在win10上能自动运行吗?设置好时间,不开机的那种
win上可以使用计划任务. https://jingyan.baidu.com/article/e6c8503c55529be54f1a18d1.html
有一个问题 ,频率为一分钟执行一次时,next_rundate的值和last_rundate的值相同
如 last_rundate 为 2018-04-10 10:23:00 next_rundate也是2018-04-10 10:23:00
next_rundate应该是2018-04-10 10:24:00
crontab解析类 是解析类有问题,用这个最新的.复制粘贴进去,谢谢你的反馈
@qq497012571 好的,感谢分享!
我问的问题比较小白,不要打我 数据库组件的名称在哪个文件看,main.php这个文件么
1.1.13版本的可以用么
@hh570 1.1没用过,但是这个东西跟框架无关.你可以在yii2上研究下,套用到1.1上就行.. 不要太死搬,要变通.核心实现代码没有用到yii框架里的东西
我发现有朋友报错 yii\console\ExitCode 没有这个类, 因为我用的是yii2最新的版本2.0.15所以没这个问题,可能这个类是升级版本之后才有,解决办法
/** * @return int Exit code */ public function actionIndex() { sleep(1); echo "我是index方法\n"; return 0; }
@qq497012571 我想知道你的这种情况是否存在一些问题呢?比如:
php yii crontab/index
设置频率为一分钟执行一次时,假如上次执行时间是21点10分5秒,那么下次执行时间自然就是21点11分5秒。
但是如果数据表中有计划任务是需要21点10分10秒执行呢?
是否直到21点11分5秒才会执行呢?
诸如类似这种问题如何解决呢?
设置10秒执行一次? 或者有其他更好的解决方案?
不会出现你说的这个问题哦. 不会出现执行时间是秒级别的情况, 因为最小的维度就是分钟, 不支持秒级别的哦.. 如果要用到秒级要借助类似swoole,workerman的定时器的那种东西
如果要实现秒级,首先我的CronParser类要支持解析秒级, 再者借助一个和crontab类似的东西, 如你写一个php脚本,一秒钟去执行一次shell命令,当然这种是比较low的.
<?php while(true) { shell_exec('php yii crontab/index'); sleep(1); }
@qq497012571 没有秒级别的维度,但是有秒级别的需求,这个情况宏观来讲就是:实际需要执行任务的触发时间正好卡在上一次执行到下一次执行中间时间段内,这种情况有没有比较适宜的办法处理呢? 你说的sleep(1)的确如此所说,比较low的。
好比多个定时任务,有的一天执行一次,有的一小时一次,有的某时段05分执行,仅有一个要求20秒一次。
那么设定一分钟执行一次,会出现bug。sleep()又太low。而且那么多定时任务,仅有一个频率这么高,是否因为它设置成一分钟执行一次有点浪费?
这么干会不会被嘲笑
/** * Class CronController * * 0/5 * * * * /path/to/yii task/minute >/dev/null 2>&1 * 30 * * * * /path/to/yii task/hourly >/dev/null 2>&1 * 00 18 * * * /path/to/yii task/daily >/dev/null 2>&1 * 00 00 15 * * /path/to/yii task/month >/dev/null 2>&1 * * @author Tongle Xu <xutongle@gmail.com> * @since 3.0 */ class TaskController extends Controller { /** * @event Event 每分钟触发事件 */ const EVENT_ON_MINUTE_RUN = "minute"; /** * @event Event 每小时触发事件 */ const EVENT_ON_HOURLY_RUN = "hourly"; /** * @event Event 每天触发事件 */ const EVENT_ON_DAILY_RUN = "daily"; /** * @event Event 每月触发事件 */ const EVENT_ON_MONTH_RUN = "month"; /** * @var string */ protected $dateTime; /** * @var string 任务配置文件 */ public $taskFile = '@vendor/yuncms/tasks.php'; /** * @throws \yii\base\InvalidConfigException */ public function init() { parent::init(); $this->dateTime = Yii::$app->formatter->asDatetime(time()); $this->taskFile = Yii::getAlias($this->taskFile); } /** * 初始化并注册计划任务 */ public function initTask() { if (is_file($this->taskFile)) { $tasks = require $this->taskFile; foreach ($tasks as $task) { if (isset($task['class'])) { Event::on($task['class'], $task['event'], $task['callback']); } else { Event::on($task[0], $task[1], $task[2]); } } } } }
/** * Executes minute cron tasks. * @return int */ public function actionMinute() { $this->stdout($this->dateTime . " Executing minute tasks." . PHP_EOL, Console::FG_YELLOW); $this->trigger(self::EVENT_ON_MINUTE_RUN); return ExitCode::OK; }
我可没资格嘲笑别人哦. 因为每个人的想法都不同,需求也不同.我可以简单说下我这种方式的优点
说一下我对你的这种实现方式的看法(疑问)
发现你貌似使用的是YII自带的Event类,做定时任务,我阅读的一下trigger这个源代码发现最终触发的是call_user_func这个函数.这样就意味着.你这种实现方式, 假设多个定时任务同一时间执行就会变成串行阻塞, 就是后面的任务要等前面的任务执行完毕才执行~ 这样是否会有问题,是否满足你的需求呢? 看你自己了
@xutongle
@qq497012571 我这个想法比较简单,预定义几个执行时间,然后配置文件一写,执行确实是串行的。
thanks
我的也和你类似
第二章接口次数用完了,就一直处于失败,但是任务管理器是在运行
图片插入表数据的图片打不开,想问一下插入的crontab_str字段格式是什么?
楼主,最新的crontab解析类,打开是404,麻烦再给一个地址吧。谢谢。
以前的链接失效的,补发一遍 crontab解析类
<?php /** * crontab格式解析工具类 * @author jlb <497012571@qq.com> */ class CronParser { protected static $tags = []; protected static $weekMap = [ 0 => 'Sunday', 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', ]; /** * 检查crontab格式是否支持 * @param string $cronstr * @return boolean true|false */ public static function check($cronstr, $checkCount = true) { $cronstr = trim($cronstr); $splitTags = preg_split('#\s+#', $cronstr); if ($checkCount && count($splitTags) !== 5) { return false; } foreach ($splitTags as $tag) { $r = '#^\*(\/\d+)?|\d+([\-\/]\d+(\/\d+)?)?(,\d+([\-\/]\d+(\/\d+)?)?)*$#'; if (preg_match($r, $tag) == false) { return false; } } return true; } /** * 格式化crontab格式字符串 * @param string $cronstr * @param interge $maxSize 设置返回符合条件的时间数量, 默认为1 * @return array 返回符合格式的时间 */ public static function formatToDate($cronstr, $maxSize = 1) { if (!static::check($cronstr)) { throw new \Exception("格式错误: $cronstr", 1); } $dates = []; self::$tags = preg_split('#\s+#', $cronstr); $crons = [ 'minutes' => static::parseTag(self::$tags[0], 0, 59), //分钟 'hours' => static::parseTag(self::$tags[1], 0, 23), //小时 'day' => static::parseTag(self::$tags[2], 1, 31), //一个月中的第几天 'month' => static::parseTag(self::$tags[3], 1, 12), //月份 'week' => static::parseTag(self::$tags[4], 0, 6), // 星期 ]; $crons['week'] = array_map(function($item){ return static::$weekMap[$item]; }, $crons['week']); return self::getDateList($crons, $maxSize); } /** * 递归获取符合格式的日期,直到取到满足$maxSize的数为止 * @param array $crons 解析crontab字符串后的数组 * @param interge $maxSize 最多返回多少数据的时间 * @param interge $year 指定年 * @return array|null 符合条件的日期 */ private static function getDateList(array $crons, $maxSize, $year = null) { $dates = []; // 年份基点 $nowyear = ($year) ? $year : date('Y'); // 时间基点已当前为准,用于过滤小于当前时间的日期 $nowtime = strtotime(date("Y-m-d H:i")); foreach ($crons['month'] as $month) { // 获取此月最大天数 $maxDay = cal_days_in_month(CAL_GREGORIAN, $month, $nowyear); foreach (range(1, $maxDay) as $day) { foreach ($crons['hours'] as $hours) { foreach ($crons['minutes'] as $minutes) { $i = mktime($hours, $minutes, 0, $month, $day, $nowyear); if ($nowtime >= $i) { continue; } $date = getdate($i); // 解析是第几天 if (self::$tags[2] != '*' && in_array($date['mday'], $crons['day'])) { $dates[] = date('Y-m-d H:i', $i); } // 解析星期几 if (self::$tags[4] != '*' && in_array($date['weekday'], $crons['week'])) { $dates[] = date('Y-m-d H:i', $i); } // 天与星期几 if (self::$tags[2] == '*' && self::$tags[4] == '*') { $dates[] = date('Y-m-d H:i', $i); } $dates = array_unique($dates); if (isset($dates) && count($dates) == $maxSize) { break 4; } } } } } // 已经递归获取了.但是还是没拿到符合的日期时间,说明指定的时期时间有问题 if ($year && !count($dates)) { return []; } if (count($dates) != $maxSize) { // 向下一年递归 $dates = array_merge(self::getDateList($crons, $maxSize, ($nowyear + 1)), $dates); } return $dates; } /** * 解析元素 * @param string $tag 元素标签 * @param integer $tmin 最小值 * @param integer $tmax 最大值 * @throws \Exception */ private static function parseTag($tag, $tmin, $tmax) { if ($tag == '*') { return range($tmin, $tmax); } $step = 1; $dateList = []; // x-x/2 情况 if (false !== strpos($tag, ',')) { $tmp = explode(',', $tag); // 处理 xxx-xxx/x,x,x-x foreach ($tmp as $t) { if (self::checkExp($t)) {// 递归处理 $dateList = array_merge(self::parseTag($t, $tmin, $tmax), $dateList); } else { $dateList[] = $t; } } } else if (false !== strpos($tag, '/') && false !== strpos($tag, '-')) { list($number, $mod) = explode('/', $tag); list($left, $right) = explode('-', $number); if ($left > $right) { throw new \Exception("$tag 不支持"); } foreach (range($left, $right) as $n) { if ($n % $mod === 0) { $dateList[] = $n; } } } else if (false !== strpos($tag, '/')) { $tmp = explode('/', $tag); $step = isset($tmp[1]) ? $tmp[1] : 1; $dateList = range($tmin, $tmax, $step); } else if (false !== strpos($tag, '-')) { list($left, $right) = explode('-', $tag); if ($left > $right) { throw new \Exception("$tag 不支持"); } $dateList = range($left, $right, $step); } else { $dateList = array($tag); } // 越界判断 foreach ($dateList as $num) { if ($num < $tmin || $num > $tmax) { throw new \Exception('数值越界'); } } sort($dateList); return array_unique($dateList); } /** * 判断tag是否可再次切割 * @return 需要切割的标识符|null */ private static function checkExp($tag) { return (false !== strpos($tag, ',')) || (false !== strpos($tag, '-')) || (false !== strpos($tag, '/')); } }
Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "yii\caching\DbTarget".'
大佬 ,按你写的复制,运行的时候报错,查了好久的资料得不到解决,帮帮忙看看
Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "yii\caching\DbTarget".'
大佬 ,按你写的复制,运行的时候报错,查了好久的资料得不到解决,帮帮忙看看
Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "yii\caching\DbTarget".'
大佬 ,按你写的复制,运行的时候报错,查了好久的资料得不到解决,帮帮忙看看
Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "yii\caching\DbTarget".'
大佬 ,按你写的复制,运行的时候报错,查了好久的资料得不到解决,帮帮忙看看
Exception 'yii\base\InvalidConfigException' with message 'Failed to instantiate component or class "yii\caching\DbTarget".'
大佬 ,按你写的复制,运行的时候报错,查了好久的资料得不到解决,帮帮忙看看