后端与架构
Symfony 中的夜间任务:健身房 SaaS 里的控制台命令、cron 与 EasyAdmin
阅读需 7 分钟
Symfony 中的夜间任务:健身房 SaaS 里的控制台命令、cron 与 EasyAdmin
SaaS 产品中的功能并不都始于一次点击。有些最有用的工作发生在夜里、没人注意的时候:结束已经到期的东西、清理数据、让数据保持一致,这样第二天早上的页面才合乎逻辑。
本文介绍一个来自 MasMusculo 的真实例子——这是我在 Osunasport 参与开发的健身房管理平台:训练计划如何在基于 EasyAdmin 的后台中管理,以及一个每晚运行的 Symfony 控制台命令如何结束那些已经到期的计划。实体和类名均做了简化。
业务领域:两种“训练计划”
我首先要弄清楚的是,有两个不同的概念都被叫作“训练计划”:
- 计划模板(
Routine):由教练创建,包含名称、目标、强度、每周训练天数,以及每一天的动作列表(组数、次数、休息时间)。动作本身来自一个动作库,并关联到器械和肌群。 - 已分配计划(
MemberRoutine):由员工把某个模板分配给具体会员,带有开始日期,以及最终的结束日期。
会员的当前计划,就是结束日期仍为 null 的那个已分配计划。分配新计划时,之前处于有效状态的计划会把当前日期设为结束日期。每次分配还会记录是谁创建的:一个指向员工的引用,以及该员工邮箱的副本;如果之后该员工被删除,引用会被置为 null,这样历史记录不会丢失。
后台:EasyAdmin 实践
管理端使用 EasyAdmin(本项目中为第 3 版)构建。它的模型非常以 CRUD 为中心,很适合这类目录型数据。它主要有两类类:
- Dashboard:主要定义每个面板的菜单。
- CRUD 控制器:每个实体一个,定义字段、操作、过滤器和搜索。
一个典型的动作库 CRUD 控制器会把字段分组到不同面板中,并根据页面显示不同字段:
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->hideOnForm();
yield FormField::addPanel('General')->addCssClass('col-md-8');
yield TextField::new('name')->setColumns(12);
yield BooleanField::new('active');
yield TextareaField::new('description')->setColumns(12)->hideOnIndex();
yield FormField::addPanel('Relations')->addCssClass('col-md-12');
yield AssociationField::new('machines')->setColumns(4);
yield AssociationField::new('muscleGroups')->setColumns(4);
}
public function configureFilters(Filters $filters): Filters
{
return $filters->add('active')->add('muscleGroups');
}
几个帮我节省了不少时间的点:
hideOnIndex()、onlyOnDetail()、hideOnForm()等方法,可以让列表页保持简洁,同时表单保持完整。FormField::addTab()可以拆分较长的表单。训练计划表单用一个标签页放基本信息,另一个标签页按天编排计划内容。- 字段不必对应一个已存储的属性。如果实体暴露了
getFullName()这样的 getter,TextField::new('fullName')就能按照 Symfony PropertyAccess 的约定把它显示出来。对于分配表单中只用于输入的字段,同样的思路也适用于未映射的表单字段。
训练计划表单中有一个细节后面会用到:每个训练日都是一个独立的集合字段,提交时 EasyAdmin 会针对每一天各调用一次实体的 setter。一个隐蔽的 Doctrine bug 就藏在这里(集合被过滤成了副本,而不是在原集合上修改),我在另一篇文章中专门讲过。
问题:永远不会结束的训练计划
分配新计划会结束旧计划,但很多会员并没有及时被分配新的计划。他们的计划有开始日期和预期时长,结束日期却永远是 null,因此在本应结束很久之后,仍然显示为“当前计划”。
平台原本就有一个每晚运行的控制台命令,里面列着一系列维护任务。我的任务是再加一步:找出所有没有结束日期、但根据开始日期和时长本应已经结束的已分配计划,并把命令运行的时刻设为它们的结束日期。
命令实现
Symfony 的控制台命令本身就是服务,因此依赖通过构造函数注入。有一点值得记住:实体不是服务,不能被注入。应该注入的是仓库(repository)和实体管理器,再通过它们获取实体。
这项工作被拆成三个小部分:
- 实体上用于结束自身的方法。
- 仓库中返回候选数据的方法。
- 命令中把它们串起来并持久化结果的步骤。
// 实体
public function isExpired(\DateTimeImmutable $now): bool
{
return $this->endDate === null
&& $this->startDate->modify("+{$this->durationDays} days") <= $now;
}
public function close(\DateTimeImmutable $at): void
{
$this->endDate = $at;
}
// 仓库:已经开始且尚未结束的计划
public function findOpenStartedBefore(\DateTimeImmutable $now): array
{
return $this->createQueryBuilder('r')
->where('r.endDate IS NULL')
->andWhere('r.startDate <= :now')
->setParameter('now', $now)
->getQuery()
->getResult();
}
#[AsCommand(name: 'app:nightly-maintenance')]
final class NightlyMaintenanceCommand extends Command
{
public function __construct(
private readonly MemberRoutineRepository $routines,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = new \DateTimeImmutable();
$closed = 0;
foreach ($this->routines->findOpenStartedBefore($now) as $routine) {
if ($routine->isExpired($now)) {
$routine->close($now);
$closed++;
}
}
$this->em->flush();
$output->writeln("Closed routines: {$closed}");
return Command::SUCCESS;
}
}
为了让示例简单,这里的时长以天为单位。关键的设计决定在于每条规则放在哪里:查询负责缩小候选范围,实体负责判断自己是否真正到期,这样日期逻辑留在 PHP 中,不依赖于特定数据库的日期函数。
每晚自动运行
开发时,这个命令和其他命令一样手动运行:
php bin/console app:nightly-maintenance
php bin/console list # 查看所有可用命令
在生产环境中,它由服务器的任务调度器每晚触发一次。下面是一个示例 crontab 配置:
0 3 * * * cd /path/to/app && php bin/console app:nightly-maintenance --env=prod
由于它只处理仍处于开放状态的计划,重复运行也不会有副作用:第二次运行时已经没有需要结束的计划了。
我的收获
- 显式地为状态建模。“当前计划 = 结束日期为
null”是一条简单的规则,但前提是必须有机制保证结束日期最终会被设置。 - **让命令保持精简。**命令负责编排,仓库负责筛选,实体负责自身的业务规则。这样每一部分都易于阅读,也便于单独测试。
- **让夜间任务具备幂等性。**一个可以安全运行两次的任务,意味着你可以在早上九点手动重跑,而不必担心任何问题。
- 顺着 EasyAdmin 的约定来用,它会非常高效(面板、标签页、按页面区分字段、过滤器),只有在业务确实需要时才去写自定义代码。