跳至正文
Ende Sun
返回博客

后端与架构

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)和实体管理器,再通过它们获取实体。

这项工作被拆成三个小部分:

  1. 实体上用于结束自身的方法。
  2. 仓库中返回候选数据的方法。
  3. 命令中把它们串起来并持久化结果的步骤。
// 实体
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 的约定来用,它会非常高效(面板、标签页、按页面区分字段、过滤器),只有在业务确实需要时才去写自定义代码。