我建立的网站经常利用cronjob来定期从其他来源获取数据。 例如,我可能想每天轮询一次API,或者每月从其他网站刮取一次内容。 cronjob是一个完美的选择。
然而,cron有一些问题:
- 如果工作是将信息写入你的网络应用程序的文件树中,你需要确保权限是正确的,无论是在文件系统层面,还是在编写cronjob的时候(例如,以同一个用户运行,或者在完成时改变权限)。
- 如果你正在运行与你的PHP应用程序相关的控制台工具,你可能需要担心当你运行作业时,特定的环境变量是否在范围内。
- 在容器化环境中,强烈反对使用 cron,因为这意味着运行另一个守护进程。 你可以用诸如s6-overlay 的工具来解决这个问题,但这是另一个问题的载体。
由于我现在建立的大多数网站都使用mezzio-swoole,我开始思考是否可以用另一种方式处理这些工作。
任务工作者
在mezzio-swoole的第二版中,我们引入了与Swoole的任务工作者的整合。 任务工作者作为一个独立于web工作者的池子运行,当当前的请求不需要处理结果时,允许web工作者卸载繁重的处理。 他们作为每台服务器的消息队列的一种形式,对于发送电子邮件、处理webhook有效载荷等工作非常有用。
mezzio-swoole的集成允许你在mezzio-swooleMezzio\Swoole\Task\DeferredListener 或DeferredServiceListener 实例中装饰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是一个理想的容器化应用程序的目标,对于那些想为他们的应用程序提供计划工作的人来说,拥有这个功能将是一个不错的功能。