通过Openswoole定时器运行cronjob的详细指南

169 阅读10分钟

我建立的网站经常利用cronjob来定期从其他来源获取数据。 例如,我可能想每天轮询一次API,或者每月从其他网站刮取一次内容。 cronjob是一个完美的选择。

然而,cron有一些问题:

  • 如果工作是将信息写入你的网络应用程序的文件树中,你需要确保权限是正确的,无论是在文件系统层面,还是在编写cronjob的时候(例如,以同一个用户运行,或者在完成时改变权限)。
  • 如果你正在运行与你的PHP应用程序相关的控制台工具,你可能需要担心当你运行作业时,特定的环境变量是否在范围内。
  • 在容器化环境中,强烈反对使用 cron,因为这意味着运行另一个守护进程。 你可以用诸如s6-overlay 的工具来解决这个问题,但这是另一个问题的载体。

由于我现在建立的大多数网站都使用mezzio-swoole,我开始思考是否可以用另一种方式处理这些工作。

任务工作者

在mezzio-swoole的第二版中,我们引入了与Swoole的任务工作者的整合。 任务工作者作为一个独立于web工作者的池子运行,当当前的请求不需要处理结果时,允许web工作者卸载繁重的处理。 他们作为每台服务器的消息队列的一种形式,对于发送电子邮件、处理webhook有效载荷等工作非常有用。

mezzio-swoole的集成允许你在mezzio-swooleMezzio\Swoole\Task\DeferredListenerDeferredServiceListener 实例中装饰PSR-14 EventDispatcher监听器;当这种情况发生时,装饰器会在Swoole服务器上创建一个任务,给它实际的监听器和事件。 当日程表处理任务时,它会调用带有事件的监听器。

结果是,要创建一个任务,你只需从你的代码中派发一个事件。 因此,你的代码对它被异步处理的事实是不可知的。

然而,由于任务在一个单独的池中工作,这意味着他们收到的事件实例在技术上是副本,而不是引用;因此,你的应用代码不能期望监听器将事件状态传回给你。 如果你选择使用这个功能,请只将其用于 "发射和遗忘 "事件。

我现在提出这个问题,是因为我将在稍后回过头来讨论它。

安排工作

Swoole对工作调度的回答是它的定时器。 有了定时器,你就可以勾选:每过一段时间就调用一次功能。 定时器在事件循环中运行,这意味着Swoole暴露的每一个服务器类型都有一个tick() 方法,包括HTTP服务器。

那么,明显的答案就是注册一个tick:

// Intervals are measured in milliseconds.
// The following means "every 3 hours".
$server->tick(1000 * 60 * 60 * 3, $callback);

现在我遇到了问题:

  • 我怎样才能获得对服务器实例的访问?
  • 我可以指定什么作为回调,以及我如何获得它?

使用mezzio-swoole,注册的时间是HTTP服务器启动的时候。 由于Swoole每个事件只允许一个监听器,mezzio-swoole组成了一个PSR-14事件调度器,并与每个Swoole HTTP服务器事件一起注册。 然后监听器通过PSR-14事件调度器触发事件,在内部使用自定义事件类型,提供对最初传递给Swoole服务器事件的数据的访问。 这种方法允许应用程序开发人员将监听器附加到事件,修改应用程序如何工作。

为了让这些 "工作流 "事件在需要时与应用程序分开,我们注册了一个Mezzio\Swoole\Event\EventDispatcherInterface 服务,返回一个离散的PSR-14事件调度器实现。我一般将其别名为PSR-14接口,这样我就可以在应用程序事件中使用同一个实例。

我使用我自己的phly/phly-event-dispatcher实现,它提供了许多不同的监听器提供者。 最简单的一个是Phly\EventDispatcher\AttachableListenerProvider ,它定义了一个单一的listen() 方法,用于将监听器附加到一个给定的事件类。

除此之外,Mezzio和Laminas还有一个委托工厂的概念。 这些允许你 "装饰 "一个服务的创建。 一个用例是装饰AttachableListenerProvider 服务,并调用其listen() 方法来附加监听器。

这就是接下来的啰嗦解释:AttachableListenerProvider 上的委托工厂在Mezzio\Swoole\Event\ServerStartEvent 上注册了一个监听器,而这个监听器又注册了一个 tick 来运行一个从容器中提取的作业:

namespace Mwop;

use Mezzio\Swoole\Event\ServerStartEvent;
use Phly\EventDispatcher\AttachableListenerProvider;
use Psr\Container\ContainerInterface;

class RunPeriodicJobDelegatorFactory
{
    public function __invoke(
        ContainerInterface $container,
        string $serviceName,
        callable $factory,
    ): AttachableListenerProvider {
        /** @var AttachableListenerProvider $provider */
        $provider = $factory();

        $provider->listen(
            ServerStartEvent::class,
            function (ServerStartEvent $e) use ($container): void {
                $e->getServer()->tick(
                    1000 * 60 * 60 * 3,
                    $container->get(SomeJobRunner::class),
                );
            },
        );

        return $provider;
    }
}

然后我将通过配置将其附加到AttachableListenerProvider

use Mwop\RunPeriodicJobDelegatorFactory;
use Phly\EventDispatcher\AttachableListenerProvider;

return [
    'dependencies' => [
        'delegators' => [
            AttachableListenerProvider::class => [
                RunPeriodicJobDelegatorFactory::class,
            ],
        ],
    ],
];

这......很好。 然而,我几乎马上就遇到了这样的情况:这种方法在应用程序中引起了分离故障,导致服务器瘫痪。

而这正是任务重新发挥作用的地方。

我修改了上面的例子,现在改成调度一个事件:

namespace Mwop;

use Mezzio\Swoole\Event\ServerStartEvent;
use Phly\EventDispatcher\AttachableListenerProvider;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;

class RunPeriodicJobDelegatorFactory
{
    public function __invoke(
        ContainerInterface $container,
        string $serviceName,
        callable $factory,
    ): AttachableListenerProvider {
        /** @var AttachableListenerProvider $provider */
        $provider = $factory();

        $provider->listen(
            ServerStartEvent::class,
            function (ServerStartEvent $e) use ($container): void {
                // This is done in the listener to prevent a race condition!
                $dispatcher = $container->get(EventDispatcherInterface::class),

                $e->getServer()->tick(
                    1000 * 60 * 60 * 3,
                    function () use ($dispatcher): void {
                        $dispatcher->dispatch(new SomeJob());
                    }
                );
            },
        );

        return $provider;
    }
}

这种方法需要更多的工作。我现在需要为SomeJob 事件注册一个监听器,而且我需要将监听器配置为可延迟的

首先,让我们创建一个委托者来附加这个监听器;它看起来会和前面的例子很像:

namespace Mwop;

use Phly\EventDispatcher\AttachableListenerProvider;
use Psr\Container\ContainerInterface;

class SomeJobRunnerDelegatorFactory
{
    public function __invoke(
        ContainerInterface $container,
        string $serviceName,
        callable $factory,
    ): AttachableListenerProvider {
        /** @var AttachableListenerProvider $provider */
        $provider = $factory();

        $provider->listen(
            SomeJob::class,
            // Since listeners are invokables, we can likely use the same class as previously
            $container->get(SomeJobRunner::class)
        );

        return $provider;
    }
}

现在是布线问题。我们将在AttachableListenerProvider ,但我们将为我们的SomeJobRunner 类注册一个委托工厂:

return [
use Mezzio\Swoole\Task\DeferredServiceListenerDelegator;
use Mwop\RunPeriodicJobDelegatorFactory;
use Phly\EventDispatcher\AttachableListenerProvider;

return [
    'dependencies' => [
        'delegators' => [
            AttachableListenerProvider::class => [
                RunPeriodicJobDelegatorFactory::class,
                SomeJobRunnerDelegatorFactory::class,
            ],
            SomeJobRunner::class => [
                DeferredServiceListenerDelegator::class,
            ],
        ],
    ],
];

这就说明了为什么委托工厂的配置要映射到数组而不是类名:这样你就可以在每个服务中运行多个委托工厂。当我们请求我们的AttachableListenerProvider 服务时,它的工厂将被传递给第一个委托工厂,而该委托工厂的返回值将被传递给下一个,以此类推。结果是,我们最终将我们的两个监听器都注册到它那里。

第二个注册是一个有趣的注册。DeferredServiceListenerDelegator 注册了一个包含服务名称和容器的Mezzio\Swoole\Task\ServiceBasedTask 。当被调用时,它把提供给它的事件实例传递给任务实例。当任务被调用时,它从容器中提取监听器,然后用事件调用它。

最终的结果是,通过在我们的tick处理程序中调度一个事件,我们有效地将执行推到我们的任务工作者,确保我们不会在处理周期性事件上浪费宝贵的网络工作者。

调度工作

我发现这种方法的问题是,每次我想创建一个新的定期工作时,都需要添加一个tick。 此外,我无法控制它何时执行,只能控制它的频率。 你可以说说cron,但它确实了解如何为特定的时间进行调度。

所以,我抓住了Chris Tankersley的cron-expression包。 这个优秀的包允许你把一个cron时间表字符串传递给它,然后它会让你知道:

  • 如果它是一个有效的时间表。
  • 如果它要在给定的时间运行(默认为 "现在")。

有了这个,我可以创建一个通用的Tick。

我决定,我的配置将采用以下格式:

[
    'jobs' => [
        'job name' => [
            'schedule' => 'crontab expression',
            'event'    => 'event class name',
        ],
    ],
]

由此,我创建了一个Cronjob 类,该类有时间表和事件类的属性:

namespace Mwop;

class Cronjob
{
    public function __construct(
        public readonly string $schedule,
        public readonly string $eventClass,
    ) {
    }
}

和一个代表整个crontab的属性:

namespace Mwop;

use ArrayIterator;
use Countable;
use IteratorAggregate;
use Traversable;

use function count;

class Crontab implements Countable, IteratorAggregate
{
    /** @var Cronjob[] */
    private array $jobs = [];

    public function count(): int
    {
        return count($this->jobs);
    }

    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->jobs);
    }

    public function append(Cronjob $job): void
    {
        $this->jobs[] = $job;
    }
}

一个cron事件接口将允许我实例化要监听的事件,并在需要时让我访问时间戳:

namespace Mwop;

use DateTimeInterface;

interface CronEventInterface
{
    public static function forTimestamp(DateTimeInterface $timestamp): self;

    public function timestamp(): DateTimeInterface;
}

一个配置分析器将验证各种条目,记录并省略任何无效的条目。 我不会展示这些代码,因为它相当冗长,而且很容易自己创建。

有了这些变化,我现在可以更新我的委托人,使之更加通用:

namespace Mwop;

use Cron\CronExpression;
use DateTimeImmutable;
use Mezzio\Swoole\Event\ServerStartEvent;
use Phly\EventDispatcher\AttachableListenerProvider;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;

class RunPeriodicJobDelegatorFactory
{
    public function __invoke(
        ContainerInterface $container,
        string $serviceName,
        callable $factory,
    ): AttachableListenerProvider {
        /** @var AttachableListenerProvider $provider */
        $provider = $factory();

        $config = $container->get('config')['cron']['jobs'] ?? [];

        /** @var Crontab $crontab */
        $crontab = (new ConfigParser())($config, $container->get(LoggerInterface::class));

        // Do not register if there are no jobs!
        if (0 === $crontab->count()) {
            return $provider;
        }

        $provider->listen(
            ServerStartEvent::class,
            function (ServerStartEvent $e) use ($container, $crontab): void {
                // This is done in the listener to prevent a race condition!
                $dispatcher = $container->get(EventDispatcherInterface::class),

                $e->getServer()->tick(
                    1000 * 60, // every minute
                    function () use ($dispatcher, $crontab): void {
                        $now = new DateTimeImmutable('now')
                        foreach ($crontab as $job) {
                            $cron = new CronExpression($job->schedule);
                            if (! $cron->isDue($now)) {
                                continue;
                            }
                            $dispatcher->dispatch(($job->eventClass)::forTimestamp($now));
                        }
                    }
                );
            },
        );

        return $provider;
    }
}

从那里,我可以配置工作:

namespace Mwop;

return [
    'cron' => [
        'jobs' => [
            'some-job' => [
                'schedule' => '*/15 * * * *',
                'event'    => SomeJob::class,
            ],
        ],
    ],
];

在最终的版本中,我提取了一个可调用的类来注册tick,但仍然只在作为ServerStartEvent 监听器的匿名函数中从容器中提取该服务,以防止出现试图提取事件调度器服务的竞赛条件,而这又需要监听器提供者......这又需要调度器。 你可以看到这是在干什么。

这种方法非常好用

通过每分钟运行tick,我可以评估是否有任何应该运行的cronjob,如果有,就把它们分派出去。 因为我把监听器配置为任务运行,它们被卸载到任务工作者队列中,所以我的web工作者不会在它们身上阻塞。 因为这是在同一个进程组中运行,我不必担心权限问题,而且环境与web工作者完全一样。 在许多方面,它最终成为一个比使用cron更强大的解决方案。

经验之谈

多年来,我见过许多从PHP应用程序中运行cronjob的解决方案。 框架和PHP应用程序包括在向webserver刷新缓冲区后定期运行cronjob的功能,这并不稀奇。 它们的主要好处是,它们与web服务器共享相同的环境和权限--这通常对与应用程序相关的工作很有用--而且它们不需要在web服务器上有一个单独的守护进程。 然而,我倾向于远离这些,因为它们依赖于你的网站有定期流量的想法,而且它们占用了web工作进程(无论这些是mod_php还是php-fpm)。

如果有能力将这些任务卸载到一个单独的工作池中,就可以完全消除我的这种反对意见。 如果所有的任务工作者都很忙,一旦他们通过队列工作,任务就会被处理。 而且一旦处理,没有任何传入的请求会被这个队列或cronjob本身阻断。

增加了应用程序的复杂性。 然而,通过抽象化的cron runner,添加新的cronjob变得:

  • 创建一个自定义的事件类型。
  • 为该事件创建一个监听器,进行工作。
  • 将监听器与监听器提供者注册。
  • 配置监听器,使其能够被延迟。
  • 添加配置,详细说明时间表和事件。

我不必担心我是否以正确的用户身份运行作业,用户是否有一个登录的外壳(Web工作者用户通常没有,这增加了设置你的cronjob的复杂性),cronjob是否与应用程序的环境相同,等等。 而最后三个项目是微不足道的依赖和配置线路,只要它们被记录下来。

我还在测试这个功能,但计划向mezzio-swoole提出,或者为它创建一个包。 因为mezzio-swoole是一个理想的容器化应用程序的目标,对于那些想为他们的应用程序提供计划工作的人来说,拥有这个功能将是一个不错的功能。