Loading

Laravel Schedule 中的 dailyAt 是如何工作的

Laravel Schedule 中的 dailyAt 是如何工作的

业务逻辑中通过 dailyAt​ 指定了一个每天都需要执行的定时任务:

$schedule->call(function () {
    // 业务逻辑
 })->dailyAt('14:29');

Illuminate\Console\Scheduling\ManagesFrequencies​ 中的 dailyAt​ 方法,最终是生成 cron 表达式:29 14 * * *

public function dailyAt($time)
{
    $segments = explode(':', $time);

    return $this->spliceIntoPosition(2, (int) $segments[0])
                ->spliceIntoPosition(1, count($segments) === 2 ? (int) $segments[1] : '0');
}

protected function spliceIntoPosition($position, $value)
{
    $segments = explode(' ', $this->expression);

    $segments[$position - 1] = $value;

    return $this->cron(implode(' ', $segments));
}

public function cron($expression)
{
    // 默认 expression  = '* * * * *';
    $this->expression = $expression;

    return $this;
}

Illuminate\Console\Scheduling\ScheduleRunCommand​ 中的 handle​ 方法:

public function handle(Schedule $schedule, Dispatcher $dispatcher, ExceptionHandler $handler)
{
    $this->schedule = $schedule;
    $this->dispatcher = $dispatcher;
    $this->handler = $handler;

    foreach ($this->schedule->dueEvents($this->laravel) as $event) {
        if (! $event->filtersPass($this->laravel)) {
            $this->dispatcher->dispatch(new ScheduledTaskSkipped($event));

            continue;
        }

        if ($event->onOneServer) {
            $this->runSingleServerEvent($event);
        } else {
            $this->runEvent($event);
        }

        $this->eventsRan = true;
    }

    if (! $this->eventsRan) {
        $this->info('No scheduled commands are ready to run.');
    }
}

通过 $this->schedule->dueEvents($this->laravel)​ 获取到期的定时任务,Illuminate\Console\Scheduling\Schedule​ 中的 dueEvents​:

public function dueEvents($app)
{
    return collect($this->events)->filter->isDue($app);
}

Illuminate\Console\Scheduling\Event​ 中的 isDue​ 和 expressionPasses​:

public function isDue($app)
{
    if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
        return false;
    }

    return $this->expressionPasses() &&
           $this->runsInEnvironment($app->environment());
}

protected function expressionPasses()
{
    $date = Date::now();

    if ($this->timezone) {
        $date = $date->setTimezone($this->timezone);
    }

    return (new CronExpression($this->expression))->isDue($date->toDateTimeString());
}

Cron\CronExpression​ 中的 isDue​ 方法的最后 $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp();​ 决定是否需要执行:

public function isDue($currentTime = 'now', $timeZone = null): bool
{
    $timeZone = $this->determineTimeZone($currentTime, $timeZone);

    if ('now' === $currentTime) {
        $currentTime = new DateTime();
    } elseif ($currentTime instanceof DateTime) {
        $currentTime = clone $currentTime;
    } elseif ($currentTime instanceof DateTimeImmutable) {
        $currentTime = DateTime::createFromFormat('U', $currentTime->format('U'));
    } elseif (\is_string($currentTime)) {
        $currentTime = new DateTime($currentTime);
    }

    Assert::isInstanceOf($currentTime, DateTime::class);
    $currentTime->setTimezone(new DateTimeZone($timeZone));

    // drop the seconds to 0
    $currentTime->setTime((int) $currentTime->format('H'), (int) $currentTime->format('i'), 0);

    try {
        return $this->getNextRunDate($currentTime, 0, true)->getTimestamp() === $currentTime->getTimestamp();
    } catch (Exception $e) {
        return false;
    }
}

currentTime​ 就是当前的时间,getNextRunDate​ 是根据当前时间和 expression​ (本例中是:29 14 * * *​)计算出下一次任务需要执行的时间点:

public function getNextRunDate($currentTime = 'now', int $nth = 0, bool $allowCurrentDate = false, $timeZone = null): DateTime
{
    return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone);
}


protected function getRunDate($currentTime = null, int $nth = 0, bool $invert = false, bool $allowCurrentDate = false, $timeZone = null): DateTime
{
    $timeZone = $this->determineTimeZone($currentTime, $timeZone);

    if ($currentTime instanceof DateTime) {
        $currentDate = clone $currentTime;
    } elseif ($currentTime instanceof DateTimeImmutable) {
        $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
    } elseif (\is_string($currentTime)) {
        $currentDate = new DateTime($currentTime);
    } else {
        $currentDate = new DateTime('now');
    }

    Assert::isInstanceOf($currentDate, DateTime::class);
    $currentDate->setTimezone(new DateTimeZone($timeZone));
    $currentDate->setTime((int) $currentDate->format('H'), (int) $currentDate->format('i'), 0);

    $nextRun = clone $currentDate;
    \Log::info('init nextRun = ' . $nextRun->format('Y-m-d H:i:s'));

    // We don't have to satisfy * or null fields
    $parts = [];
    $fields = [];
    // [5,3,2,4,1,0]
    foreach (self::$order as $position) {
        $part = $this->getExpression($position);
        if (null === $part || '*' === $part) {
            continue;
        }
        $parts[$position] = $part;
        $fields[$position] = $this->fieldFactory->getField($position);
    }
    // parts = [1 => 14, 0 => 29]
    // fields = [1 => HoursField, 0 => MinutesField]; // 对应的取值范围是 [0 ~ 59, 0 ~ 23]

    if (isset($parts[2]) && isset($parts[4])) {
        $domExpression = sprintf('%s %s %s %s *', $this->getExpression(0), $this->getExpression(1), $this->getExpression(2), $this->getExpression(3));
        $dowExpression = sprintf('%s %s * %s %s', $this->getExpression(0), $this->getExpression(1), $this->getExpression(3), $this->getExpression(4));

        $domExpression = new self($domExpression);
        $dowExpression = new self($dowExpression);

        $domRunDates = $domExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone);
        $dowRunDates = $dowExpression->getMultipleRunDates($nth + 1, $currentTime, $invert, $allowCurrentDate, $timeZone);

        $combined = array_merge($domRunDates, $dowRunDates);
        usort($combined, function ($a, $b) {
            return $a->format('Y-m-d H:i:s') <=> $b->format('Y-m-d H:i:s');
        });

        return $combined[$nth];
    }

    // Set a hard limit to bail on an impossible date
    for ($i = 0; $i < $this->maxIterationCount; ++$i) {
        foreach ($parts as $position => $part) {
            $satisfied = false;
            // Get the field object used to validate this part
            $field = $fields[$position];
            // Check if this is singular or a list
            if (false === strpos($part, ',')) {
                $satisfied = $field->isSatisfiedBy($nextRun, $part);
            } else {
                foreach (array_map('trim', explode(',', $part)) as $listPart) {
                    if ($field->isSatisfiedBy($nextRun, $listPart)) {
                        $satisfied = true;

                        break;
                    }
                }
            }

            // If the field is not satisfied, then start over
            if (!$satisfied) {
                $field->increment($nextRun, $invert, $part);

                continue 2;
            }
        }

        // Skip this match if needed
        if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
            $this->fieldFactory->getField(0)->increment($nextRun, $invert, $parts[0] ?? null);

            continue;
        }

        return $nextRun;
    }

    // @codeCoverageIgnoreStart
    throw new RuntimeException('Impossible CRON expression');
    // @codeCoverageIgnoreEnd
}

月、周、日、天、小时这几个字段都实现了 FieldInterface​ 接口

interface FieldInterface
{
    /**
     * Check if the respective value of a DateTime field satisfies a CRON exp. (检查 DateTime 字段的相应值是否满足 CRON exp。)
     *
     * @param DateTimeInterface $date  DateTime object to check
     * @param string            $value CRON expression to test against
     *
     * @return bool Returns TRUE if satisfied, FALSE otherwise
     */
    public function isSatisfiedBy(DateTimeInterface $date, $value): bool;

    /**
     * When a CRON expression is not satisfied, this method is used to increment
     * or decrement a DateTime object by the unit of the cron field. (当不满足 CRON 表达式时,此方法用于按 cron 字段的单位递增或递减 DateTime 对象。)
     *
     * @param DateTimeInterface $date DateTime object to change
     * @param bool $invert (optional) Set to TRUE to decrement
     * @param string|null $parts (optional) Set parts to use
     *
     * @return FieldInterface
     */
    public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface;

    /**
     * Validates a CRON expression for a given field.
     *
     * @param string $value CRON expression value to validate
     *
     * @return bool Returns TRUE if valid, FALSE otherwise
     */
    public function validate(string $value): bool;
}

// 小时字段(HoursField)中实现的 increment 方法
public function increment(DateTimeInterface &$date, $invert = false, $parts = null): FieldInterface
{
    // Change timezone to UTC temporarily. This will
    // allow us to go back or forwards and hour even
    // if DST will be changed between the hours.
    if (null === $parts || '*' === $parts) {
        $timezone = $date->getTimezone();
        $date = $date->setTimezone(new DateTimeZone('UTC'));
        $date = $date->modify(($invert ? '-' : '+') . '1 hour');
        $date = $date->setTimezone($timezone);

        $date = $date->setTime((int)$date->format('H'), $invert ? 59 : 0);
        return $this;
    }

    $parts = false !== strpos($parts, ',') ? explode(',', $parts) : [$parts];
    $hours = [];
    foreach ($parts as $part) {
        $hours = array_merge($hours, $this->getRangeForExpression($part, 23));
    }

    $current_hour = $date->format('H');
    $position = $invert ? \count($hours) - 1 : 0;
    $countHours = \count($hours);
    if ($countHours > 1) {
        for ($i = 0; $i < $countHours - 1; ++$i) {
            if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) ||
                ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) {
                $position = $invert ? $i : $i + 1;

                break;
            }
        }
    }

    $hour = (int) $hours[$position];
    if ((!$invert && (int) $date->format('H') >= $hour) || ($invert && (int) $date->format('H') <= $hour)) {
        $date = $date->modify(($invert ? '-' : '+') . '1 day');
        $date = $date->setTime($invert ? 23 : 0, $invert ? 59 : 0);
    } else {
        $date = $date->setTime($hour, $invert ? 59 : 0);
    }

    return $this;
}

getRunDate​ 方法中先用 $satisfied = $field->isSatisfiedBy($nextRun, $part);​ 检查 parts​ 中,对应位置上的值是否符合下一次任务执行的时间点,不满足时,使用 $field->increment($nextRun, $invert, $part);​ 对时间点进行调整

self::$order​ 中大的周期在上面,组装的 parts​ 中也是大的周期在最前面,所以后面处理的时候,会先处理大周期:

private static $order = [
    self::YEAR,
    self::MONTH,
    self::DAY,
    self::WEEKDAY,
    self::HOUR,
    self::MINUTE,
];

当前时间 2024-05-07 16:01:00​,定时任务的表达式是 29 14 * * *​,先处理大周期,所以第一个处理的周期是小时 14

当前时间中的小时是 16​,已经超过了 14​,HoursField​的 increment​ 方法中会加一天,同时将小时和分钟设置为 0,调整后的时间是:2024-05-08 00:00:00

getRunDate​ 方法中调用了 increment​ 方法之后,会 continue 2​,即:重新再验证一遍 parts​ 是否符合;

第二次验证时,时间中的小时是 00​,小于 14​,即时间还未到,此时会将小时 14​ 设置到时间里,调整后的时间是:2024-05-08 14:00:00

小时验证通过后,第二个处理的周期是分钟29​,此时时间中的分钟是 00​,小于 29​,再将分钟 29​ 设置到时间里,调整后的时间是:2024-05-08 14:29:00

getRunDate​ 最终返回的下一次任务执行时间是 2024-05-08 14:29:00​,如果当前时间也走到了 2024-05-08 14:29:00​ 的时候,任务就会被触发。

posted @ 2024-05-07 17:41  zhpj  阅读(59)  评论(0)    收藏  举报