The Symfony Scheduler Component: Cron in Your App, Not Your Crontab
The Symfony Scheduler Component: Cron in Your App, Not Your Crontab
Symfony Scheduler 组件:将 Cron 任务纳入应用,而非 Crontab
You SSH into the box to check why the nightly report didn’t send. You run crontab -l. There’s a wall of lines, half of them commented out, one pointing at a bin/console command that got renamed six months ago. Nobody knows who added the 3am entry. It’s not in git. It’s not in the deploy pipeline. It exists only on this one server, and when the box gets recycled by the autoscaler, it’s gone.
你通过 SSH 登录服务器,检查为什么昨晚的报告没有发送。你运行了 crontab -l,看到了一堆密密麻麻的行,其中一半被注释掉了,还有一行指向了一个六个月前就已经改名的 bin/console 命令。没人知道是谁添加了凌晨 3 点的那个任务。它不在 git 里,也不在部署流水线里。它只存在于这台服务器上,一旦服务器被自动伸缩(autoscaler)回收,它就彻底消失了。
That’s the problem the Symfony Scheduler component solves. Your recurring tasks stop being infrastructure trivia on a single host and become versioned PHP that ships with your code, gets code-reviewed, and can be unit tested. The Scheduler landed as stable in Symfony 6.4 and has grown a lot since. Here’s how it works and the one gotcha that will bite you in production. 这就是 Symfony Scheduler 组件要解决的问题。你的周期性任务不再是单台主机上的基础设施琐事,而是随代码发布的版本化 PHP 代码,可以进行代码审查和单元测试。Scheduler 在 Symfony 6.4 中正式稳定,此后发展迅速。以下是它的工作原理,以及一个在生产环境中可能会坑到你的注意事项。
The shape: a schedule provider
结构:调度提供者 (Schedule Provider)
The core idea: a class that describes what runs and when. You mark it with #[AsSchedule] and implement ScheduleProviderInterface.
核心理念是:创建一个类来描述运行什么以及何时运行。你需要用 #[AsSchedule] 标记它,并实现 ScheduleProviderInterface 接口。
// src/Scheduler/MainSchedule.php
namespace App\Scheduler;
use App\Message\SendDailyReport;
use App\Message\PurgeExpiredTokens;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('main')]
final class MainSchedule implements ScheduleProviderInterface
{
private ?Schedule $schedule = null;
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())->add(
RecurringMessage::every(
'1 hour',
new PurgeExpiredTokens(),
),
RecurringMessage::cron(
'0 6 * * *',
new SendDailyReport(),
),
);
}
}
Two triggers, two styles. every() takes a human interval (‘10 minutes’, ‘1 hour’, ‘1 day’). cron() takes a standard cron expression, so you keep the vocabulary you already know. The ??= cache matters: getSchedule() can be called more than once, and you want the same Schedule instance each time. The messages themselves are plain DTOs. No base class, no interface.
这里有两种触发器和两种风格。every() 接受人类可读的时间间隔(如 ‘10 minutes’, ‘1 hour’, ‘1 day’)。cron() 接受标准的 cron 表达式,因此你可以沿用你熟悉的语法。??= 缓存非常重要:getSchedule() 可能会被多次调用,而你希望每次都返回同一个 Schedule 实例。消息本身就是普通的 DTO,不需要基类,也不需要接口。
Cron needs a package, and can hash for you
Cron 需要依赖包,且支持哈希表达式
RecurringMessage::cron() depends on dragonmantank/cron-expression. Install it or the trigger throws: composer require dragonmantank/cron-expression. Once it’s there, you get a feature worth knowing about: hashed cron expressions.
RecurringMessage::cron() 依赖于 dragonmantank/cron-expression。请安装它,否则触发器会报错:composer require dragonmantank/cron-expression。安装后,你将获得一个值得了解的功能:哈希 cron 表达式。
If every service in your fleet fires its cleanup job at 0 0 * * *, midnight becomes a stampede. A hashed expression spreads the load by deriving the exact minute from a hash of the message.
如果你的集群中每个服务都在 0 0 * * * 执行清理任务,午夜时分就会出现流量洪峰。哈希表达式通过从消息的哈希值中推导出具体的分钟数,从而分散负载。
// runs once a day, but at a stable pseudo-random
// minute derived from the message, not on the
// midnight boundary everyone else picked
RecurringMessage::cron('#daily', new PurgeExpiredTokens());
// or a whole hashed expression
RecurringMessage::cron('# # * * *', new SendDailyReport());
The minute stays stable for a given message, so you get spread-out load without random drift between runs. 对于给定的消息,分钟数保持稳定,因此你可以在不产生随机漂移的情况下实现负载均衡。
Messenger is the engine underneath
Messenger 是底层的引擎
The Scheduler doesn’t run anything on its own. It’s a Messenger transport that generates messages when a trigger is due. That’s the design that makes it worth adopting: your scheduled message goes through the same bus, the same middleware, the same retry strategy, and the same failure transport as every other message in your app. Scheduler 本身不运行任何东西。它是一个 Messenger 传输器,在触发时间到达时生成消息。这种设计使其非常值得采用:你的定时消息会像应用中的其他消息一样,经过相同的总线、相同的中间件、相同的重试策略和相同的失败处理传输器。
So the handler is an ordinary Messenger handler: 因此,处理器就是一个普通的 Messenger 处理器:
// src/MessageHandler/SendDailyReportHandler.php
namespace App\MessageHandler;
use App\Message\SendDailyReport;
use App\Report\ReportMailer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendDailyReportHandler
{
public function __construct(
private readonly ReportMailer $mailer,
) {}
public function __invoke(SendDailyReport $message): void
{
$this->mailer->sendFor($message->segment);
}
}
You run the schedule by consuming its transport. The transport name is scheduler_ plus the name you gave #[AsSchedule]: php bin/console messenger:consume scheduler_main -vv. That’s the process that ticks. It stays up, checks triggers, and dispatches due messages into your handlers. In production it goes under Supervisor or systemd like any other Messenger worker. No line in a crontab anywhere.
你通过消费其传输器来运行调度任务。传输器的名称是 scheduler_ 加上你在 #[AsSchedule] 中定义的名称:php bin/console messenger:consume scheduler_main -vv。这就是负责计时的进程。它保持运行,检查触发器,并将到期的消息分发给你的处理器。在生产环境中,它像其他 Messenger 工作进程一样由 Supervisor 或 systemd 管理。无需在 crontab 中添加任何一行代码。
Skip the message for one-off methods
对于一次性方法,可以跳过消息机制
When you have a service method that runs on a schedule and doesn’t need a message and handler, two attributes cut the ceremony. They target the method directly. 当你有一个需要定时运行的服务方法,且不需要消息和处理器时,可以使用两个属性来简化流程。它们直接作用于方法。
// src/Maintenance/TokenCleaner.php
namespace App\Maintenance;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
final class TokenCleaner
{
#[AsCronTask('0 3 * * *')]
public function purgeExpired(): void
{
// runs every day at 03:00
}
#[AsPeriodicTask(frequency: '5 minutes')]
public function refreshCache(): void
{
// runs every five minutes
}
}
These register onto the default schedule, so you consume them with messenger:consume scheduler_default. Good for maintenance chores. When the task carries a payload or belongs in your domain, prefer the message-and-handler form.
这些任务会注册到默认调度器中,因此你需要使用 messenger:consume scheduler_default 来消费它们。这非常适合维护任务。如果任务带有负载(payload)或属于你的业务领域,建议使用“消息+处理器”的形式。
Schedules you can unit test
可进行单元测试的调度
This is the payoff a crontab can never give you. The schedule is an object, so you can assert on it without booting a worker or waiting for the clock. 这是 crontab 永远无法给你的回报。调度是一个对象,因此你可以在不启动工作进程或等待时钟的情况下对其进行断言。
// tests/Scheduler/MainScheduleTest.php
namespace App\Tests\Scheduler;
use App\Message\SendDailyReport;
use App\Scheduler\MainSchedule;
use PHPUnit\Framework\TestCase;
final class MainScheduleTest extends TestCase
{
public function testDailyReportFiresAtSix(): void
{
$schedule = (new MainSchedule())->getSchedule();
$messages = $schedule->getRecurringMessages();
// ...
}
}