Skip to content

Планирование задач

Некоторые задачи обслуживания должны выполняться по расписанию. В отличие от воркеров, которые работают непрерывно, запланированные задачи запускаются периодически и на короткое время.

Очистка ненужных комментариев

Комментарии, помеченные как спам или отклонённые администратором, хранятся в базе, чтобы администратор мог просмотреть их позже. Но они, вероятно, в любом случае должны быть удалены через определённое время. Думаю, что хранить такие комментарии в течение недели после создания будет достаточно.

Добавьте несколько вспомогательных методов в репозиторий комментариев для поиска, подсчёта и удаления отклонённых комментариев:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
--- i/src/Repository/CommentRepository.php
+++ w/src/Repository/CommentRepository.php
@@ -5,7 +5,9 @@ namespace App\Repository;
 use App\Entity\Comment;
 use App\Entity\Conference;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\QueryBuilder;
 use Doctrine\ORM\Tools\Pagination\Paginator;

 /**
@@ -13,6 +15,8 @@ use Doctrine\ORM\Tools\Pagination\Paginator;
  */
 class CommentRepository extends ServiceEntityRepository
 {
+    private const DAYS_BEFORE_REJECTED_REMOVAL = 7;
+
     public const COMMENTS_PER_PAGE = 2;

     public function __construct(ManagerRegistry $registry)
@@ -20,6 +24,27 @@ class CommentRepository extends ServiceEntityRepository
         parent::__construct($registry, Comment::class);
     }

+    public function countOldRejected(): int
+    {
+        return $this->getOldRejectedQueryBuilder()->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
+    }
+
+    public function deleteOldRejected(): int
+    {
+        return $this->getOldRejectedQueryBuilder()->delete()->getQuery()->execute();
+    }
+
+    private function getOldRejectedQueryBuilder(): QueryBuilder
+    {
+        return $this->createQueryBuilder('c')
+            ->andWhere('c.state = :state_rejected or c.state = :state_spam')
+            ->andWhere('c.createdAt < :date')
+            ->setParameter('state_rejected', 'rejected')
+            ->setParameter('state_spam', 'spam')
+            ->setParameter('date', new \DateTimeImmutable(-self::DAYS_BEFORE_REJECTED_REMOVAL.' days'))
+        ;
+    }
+
     public function getCommentPaginator(Conference $conference, int $offset): Paginator
     {
         $query = $this->createQueryBuilder('c')

Tip

Для более сложных запросов иногда полезно посмотреть сгенерированные SQL-запросы (которые можно найти в логах и в профилировщике веб-запросов).

Использование констант класса, параметров контейнера и переменных среды окружения

Почему именно 7 дней? Мы могли бы выбрать другое число, может быть 10 или 20. Это число потом может поменяться. Поэтому лучше всего хранить такие данные в константе класса, что мы и сделали, хотя это значение можно было поместить в параметр контейнера или даже определить соответствующую переменную окружения.

Несколько основных правил, по которым можно определить, какую абстракцию использовать:

  • Если значение является конфиденциальной информацией (пароли, токены API и т.д.), используйте секретное хранилище Symfony или Vault;
  • Если значение динамическое, которое должно изменяться без повторного развёртывания, используйте переменные окружения;
  • Если значение может различаться в разных окружениях, используйте параметры контейнера;
  • Во всех остальных случаях храните значение в коде, например, в константах класса.

Создание CLI-команды

Удаление старых комментариев — идеальное задание для cron. Оно должно выполняться регулярно и небольшие задержки в выполнении не играют существенной роли.

Создайте CLI-команду с названием app:comment:cleanup в файле src/Command/CommentCleanupCommand.php:

src/Command/CommentCleanupCommand.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
namespace App\Command;

use App\Repository\CommentRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand('app:comment:cleanup', 'Deletes rejected and spam comments from the database')]
class CommentCleanupCommand
{
    public function __invoke(
        SymfonyStyle $io,
        CommentRepository $commentRepository,
        #[Option(description: 'Dry run')]
        bool $dryRun = false,
    ): int {
        if ($dryRun) {
            $io->note('Dry mode enabled');

            $count = $commentRepository->countOldRejected();
        } else {
            $count = $commentRepository->deleteOldRejected();
        }

        $io->success(sprintf('Deleted "%d" old rejected/spam comments.', $count));

        return Command::SUCCESS;
    }
}

Все команды приложения регистрируются вместе со встроенными командами Symfony, и все они доступны через symfony console. Поскольку количество доступных команд может быть большим, необходимо группировать их по пространствам имён. По соглашению команды приложения должны храниться в пространстве app. Можно добавлять любое количество подпространств, разделяя их двоеточием (:).

Команда объявляет свои аргументы и опции с помощью атрибутов #[Argument] и #[Option] на параметрах метода __invoke() (параметр $dryRun становится опцией --dry-run). Остальные параметры Symfony внедряет по их типу: SymfonyStyle для красиво отформатированного вывода в консоль и любой сервис, например репозиторий комментариев, так же, как и для аргументов контроллеров.

Очистите базу данных, выполнив команду:

1
$ symfony console app:comment:cleanup

Планирование команды

Запускать команду вручную можно, но она должна выполняться каждую ночь. Компонент Symfony Scheduler генерирует сообщения по расписанию; затем они обрабатываются воркером, как и любые другие сообщения Messenger.

Добавьте компонент Scheduler вместе с библиотекой, которая разбирает cron-выражения:

1
$ symfony composer req scheduler dragonmantank/cron-expression

Запланируйте команду с помощью атрибута #[AsCronTask]:

1
2
3
4
5
6
7
8
9
10
11
12
13
--- i/src/Command/CommentCleanupCommand.php
+++ w/src/Command/CommentCleanupCommand.php
@@ -7,8 +7,10 @@ use Symfony\Component\Console\Attribute\AsCommand;
 use Symfony\Component\Console\Attribute\Option;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Scheduler\Attribute\AsCronTask;

 #[AsCommand('app:comment:cleanup', 'Deletes rejected and spam comments from the database')]
+#[AsCronTask('50 23 * * *')]
 class CommentCleanupCommand
 {
     public function __invoke(

Атрибут регистрирует команду в расписании по умолчанию с cron-выражением: каждую ночь в 23:50 (UTC). Проверьте:

1
$ symfony console debug:scheduler

Расписание доступно как обычный транспорт Messenger с тем же именем; обрабатывайте его, как любой другой транспорт:

1
$ symfony run -d symfony console messenger:consume scheduler_default -vv

Развёртывание расписания

В Upsun воркер обрабатывает только транспорт async. Заставьте его обрабатывать и расписание:

1
2
3
4
5
6
7
8
--- i/.upsun/config.yaml
+++ w/.upsun/config.yaml
@@ -87,4 +87,4 @@ applications:
         messenger:
             commands:
                 # Consume "async" messages (as configured in the routing section of config/packages/messenger.yaml)
-                    start: symfony console --time-limit=3600 --memory-limit=64M messenger:consume async
+                    start: symfony console --time-limit=3600 --memory-limit=64M messenger:consume async scheduler_default

Это всё, что нужно: ни crontab, ни дополнительного процесса; расписание живёт в PHP-коде, рядом с задачей, которую оно запускает, и развёртывается и версионируется вместе с остальной частью приложения.

А как же системные cron?

Upsun также поддерживает cron-задания на уровне операционной системы, описываемые в .upsun/config.yaml рядом с веб-контейнером и воркерами; конфигурация по умолчанию уже определяет одно, которое очищает устаревшие PHP-сессии. Системные cron хорошо подходят для задач, не реализованных на PHP.

Утилита croncape, используемая cron-заданием по умолчанию, следит за выполнением команды и отправляет письмо на адреса, указанные в переменной окружения MAILTO, если команда возвращает код выхода, отличный от 0:

1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:MAILTO --value=ops@example.com

Обратите внимание, что cron-задания устанавливаются на всех ветках Upsun. Если вы не хотите запускать некоторые из них вне продакшена, проверяйте переменную окружения $PLATFORM_ENVIRONMENT_TYPE:

1
2
3
if [ "$PLATFORM_ENVIRONMENT_TYPE" = "production" ]; then
    croncape symfony app:invoices:send
fi
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version