Обработка сигналов с помощью Symfony Command
15233

Обработка сигналов с помощью Symfony Command


Несколько лет назад мы написали статью о том, как работают сигналы POSIX в PHP.

Сегодня мы хотим поделиться с вами тем, как работать с сигналами с помощью Symfony Command.

⚠ Это работает только начиная с Symfony 6.3. Эта версия будет выпущена в мае 2023 года.

По умолчанию Symfony Command не обрабатывает сигналы. Поэтому, когда вы запускаете команду и нажимаете CTRL+C, она немедленно остановит процесс.

В большинстве случаев это безопасно, но иногда вы хотите обрабатывать сигналы, чтобы выполнить некоторую очистку перед остановкой команды. Например, если ваша команда ведет диалог с API удаленных платежей, вы хотите написать атомарную транзакцию: либо платеж (удаленно + локально) выполнен, либо нет.

Как работать с сигналами в Symfony


Для обработки сигналов вам необходимо реализовать интерфейс SignalableCommandInterface:



<?php

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SignalableCommand\SignalableCommandInterface;

class PaymentCommand extends Command implements SignalableCommandInterface
{
    private bool $shouldStop = false;

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        foreach ($this->getPayments() as $payment) {
            if ($this->shouldStop) {
                break;
            }

            $this->processPayment($payment);
        }

        return Command::SUCCESS;
    }

    public function getSubscribedSignals(): array
    {
        return [
            SIGINT,
            SIGTERM,
        ];
    }

    public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
    {
        $this->logger->info('Signal received, stopping the command...', [
            'signal' => $signal,
        ]);
        $this->shouldStop = true;

        return false;
    }
}

Как только процесс прерывается сигналом SIGINT или SIGTERM, вызывается метод handleSignal(). Этот метод переключает свойство shouldStop в true. Затем метод execute() возобновляется и останавливает цикл, но только после того, как текущий платеж будет полностью обработан.

Строка "return false;" позволяет вернуть определенный код выхода при подаче сигнала о выполнении команды или не выходить вообще. В нашем случае мы не хотим автоматически выходить по сигналам SIGINT или SIGTERM, поэтому возвращаем false.

Как использовать Event (событие) для обработки сигналов


Symfony генерирует событие при получении сигнала. По умолчанию обрабатываются следующие сигналы:

  • SIGINT (Ctrl+C)
  • SIGTERM (kill)
  • SIGUSR1 (kill -USR1)
  • SIGUSR2 (kill -USR2).

Для обработки сигналов вы можете прослушать событие ConsoleEvents::SIGNAL. Следующий пример показывает, что можно сделать в подписчике:

<?php

namespace App\Somewhere\In\Your\Application;

use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleSignalEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class SignalSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            ConsoleEvents::SIGNAL => 'handleSignal',
        ];
    }

    public function handleSignal(ConsoleSignalEvent $event): void
    {
        $signal = $event->getSignal();

        // #1 Some log
        $this->logger->info('Signal received'., [
            'signal' => $signal,
        ]);

        // #2 Stop the command if it implements StoppableCommandInterface
        // StoppableCommandInterface is not part of the Symfony Console component
        // It's up to you to implement it in your application
        if (
            $event->getCommand() instanceof StoppableCommandInterface)
            && in_array($signal, [SIGINT, SIGTERM], true)
        ) {
            $event->getCommand()->stop();
            $event->abortExit();
        }

        // #3 Do not stop on SIGUSR1 or SIGUSR2
        // By default, PHP will stop on SIGUSR1, or SIGUSR2, let's change that
        if (in_array($signal, [SIGINT, SIGTERM], true)) {
            $event->setExitCode(null);
        }

        // #4 Set a custom status code on other signals
        $event->setExitCode(128 + $signal);
    }
}

Если вы хотите диспетчеризировать больше событий при получении сигнала, вы можете использовать метод Application::setSignalsToDispatchEvent():

// bin/console

$application = new Application($kernel);
$application->setSignalsToDispatchEvent([SIGINT, SIGTERM, SIGUSR1, SIGUSR2, SIGALRM]);

Или вы можете настроить ее в самой команде:

class MyCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->getApplication()->setSignalsToDispatchEvent([SIGINT, SIGTERM, SIGUSR1, SIGUSR2, SIGALRM]);
    }

Заключение


Обработка сигналов очень важна, когда вы хотите написать долго выполняющуюся команду, или когда команда обрабатывает критические данные, например, платежи.

Как вы можете видеть, начиная с Symfony 6.3 очень легко обрабатывать сигналы с помощью Symfony Command. Мы предлагаем вам всегда писать такой код, чтобы обеспечить безопасность вашего приложения.