События и слушатели событий

Дата обновления перевода 2023-07-25

События и слушатели событий

Во время выполнения приложения Symfony, запускается множество уведомлений событий. Ваше приложение может принимать эти уведомления и отвечать на них, путем выполнения какой-либо части кода.

Symfony вызывает несколько событий, связанных с ядром, при обработке HTTP-запроса. Сторонние пакеты могут также запускать события, и вы даже можете запустить пользовательские события из вашего собственного кода.

Все примеры, показанные в этой статье, используют одно и то же событие KernelEvents::EXCEPTION в целях последовательности. В вашем приложении вы можете использовать любое событие и даже смешивать некоторые из них в одном абоненте.

Создание слушателя событий

Самым распространённым способом принять событие является его регистрация в слушателе событий:

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
// src/EventListener/ExceptionListener.php
namespace App\EventListener;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class ExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        // Вы получаете объект исключения из полученного события
        $exception = $event->getThrowable();
        $message = sprintf(
            'My Error says: %s with code: %s',
            $exception->getMessage(),
            $exception->getCode()
        );

        // Настройте ваш объект ответа, чтобы он отображал детали исключений
        $response = new Response();
        $response->setContent($message);

        // HttpExceptionInterface - это специальный тип исключения, который
        // содержит статус кода и детали заголовка
        if ($exception instanceof HttpExceptionInterface) {
            $response->setStatusCode($exception->getStatusCode());
            $response->headers->replace($exception->getHeaders());
        } else {
            $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
        }

        // Отправляет изменённый объект ответа событию
        $event->setResponse($response);
    }
}

Теперь, когда класс создан, вам просто нужно зарегистрировать его в качестве сервиса и уведомить Symfony, что он "слушатель" события, путём использования
специального "тега":

1
2
3
4
# config/services.yaml
services:
    App\EventListener\ExceptionListener:
        tags: [kernel.event_listener]

Symfony следует этой логике, чтобы решить, какой метод выполнить внутри класса слушателя событий:

  1. Если тег kernel.event_listener определяет атрибут method, то это имя метода, который нужно выполнить;
  2. Если не определён атрибут method, попробуйте вызвать волшебный метод __invoke() (который делает слушателей событий вызываемыми);
  3. Если метод _invoke() тоже не определён, вызовите исключение.

Note

Существует необязательный атрибут для тега kernel.event_listener под названием priority, который по умолчанию равняется 0 и контролирует порядок выполнения слушателей (чем выше приоритет, тем раньше выполняется слушатель). Это полезно,когда вам нужно гарантировать, что один слушатель будет выполнен перед другим. Приоритеы внутренних слушателей Symfony обычно колеблются в диапазоне от -255до 255, но ваши собственные слушатели могут использовать любое положительное или отрицательное целое число.

Note

Для тега kernel.event_listener существует необязательный атрибут, называемый event, который полезен, когда аргумент слушателя $event не является типизированным. Если его сконфигурировать, то он будет изменять тип объекта $event. Для события kernel.exception это ExceptionEvent. Ознакомьтесь со справочником событий Symfony, чтобы узнать, какой тип объекта предоставляет каждое событие.

С этим атрибутом Symfony руководствуется следующей логикой, чтобы решить, какой метод вызывать внутри класса слушателя событий:

  1. Если тег kernel.event_listener определяет атрибут method, то это имя метода, который будет вызван;
  2. Если атрибут method не определен, попробуйте вызвать метод, имя которого - on + "PascalCased имя события" (например, метод onKernelException() для события kernel.exception);
  3. Если и этот метод не определен, попробуйте вызвать магический метод __invoke() (который делает слушателей событий вызываемыми);
  4. Если метод __invoke() также не определен, то будет вызвано исключение.

Определение слушателей событий с PHP-атрибутами

Альтернативным способом определения слушателя событий является использование PHP-атрибута AsEventListener. Это позволяет сконфигурировать слушателя внутри его класса, без необходимости добавления какой-либо конфигурации во внешних файлах:

1
2
3
4
5
6
7
8
9
10
11
12
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class MyListener
{
    public function __invoke(CustomEvent $event): void
    {
        // ...
    }
}

Вы можете добавлять множество атрибутов #[AsEventListener()], чтобы сконфигурировать разные методы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: CustomEvent::class, method: 'onCustomEvent')]
#[AsEventListener(event: 'foo', priority: 42)]
#[AsEventListener(event: 'bar', method: 'onBarEvent')]
final class MyMultiListener
{
    public function onCustomEvent(CustomEvent $event): void
    {
        // ...
    }

    public function onFoo(): void
    {
        // ...
    }

    public function onBarEvent(): void
    {
        // ...
    }
}

Создание подписчика событий

Еще одним способом принимать события является подписчик событий - класс, который определяет один или более методов, которые слушают одно или более событий. Главное отличие от слушателя событий заключется в том, что подписчики всегда знают, какие события они слушают.

Если разные методы подписчиков событий слушают одно и то же событие, их порядок определяется параметром priority. Это значение является положительным или отрицательным целым числом, которое по умолчанию равно 0. Чем больше число, тем раньше вызывается метод. Приоритетность агрегируется для всех слушателей и подписчиков, поэтому ваши методы могут быть вызвано до или после методов, определенных в других слушателях и событиях. Чтобы узнать больше о подписчиках событий, прочтите Компонент EventDispatcher.

Следующий пример иллюстрирует подписчика событий, который определяет несколько методов, которые принимают одно и то же событие kernel.exception:

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
// src/EventSubscriber/ExceptionSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class ExceptionSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents()
    {
        // вернуть подписанные события, их методы и приоритеты
        return array(
           KernelEvents::EXCEPTION => array(
               array('processException', 10),
               array('logException', 0),
               array('notifyException', -10),
           )
        );
    }

    public function processException(GetResponseForExceptionEvent $event)
    {
        // ...
    }

    public function logException(GetResponseForExceptionEvent $event)
    {
        // ...
    }

    public function notifyException(GetResponseForExceptionEvent $event)
    {
        // ...
    }
}

Вот и все! Ваш файл services.yaml должен уже быть настроен так, чтобы загружать сервисы из каталога EventSubscriber. Об остальном позаботится Symfony.

Tip

Если ваши методы не вызываются, когда есть исключение, перепроверьте, что вы загружаете сервисы из каталога EventSubscriber и активировали автоконфигурацию . Вы также можете вручную добавить тег kernel.event_subscriber.

События запросов, проверка типов

Одна страница может делать несколько запросов (один главный и множество под-запросов - обычно с помощью ). Для главных событий Symfony, вам может понадобиться проверить, относится ли событие к "главному" запросу или "под-запросу":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/EventListener/RequestListener.php
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\RequestEvent;

class RequestListener
{
    public function onKernelRequest(RequestEvent $event)
    {
        if (!$event->isMainRequest()) {
            // ничего не делайте, если это не основной запрос
            return;
        }

        // ...
    }
}

Некоторые вещи, как то проверка информации в настоящем запросе, могут не понадобиться в приёмниках под-запросов.

Слушатели или подписчики

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

  • Подписчиков проще использовать повторно так как знание событий хранится в классе, а не в определении сервиса. Это то, почему Symfony использует подписчиков внутренне;
  • Слушатели более гибкие так как пакеты могут активировать или деактивировать каждый из них, в зависимости от значений конфигурации.

Псевдонимы событий

При конфигурации слушателей и подписчиков событий через внедрение зависимости, на базовые события Symfony также можно ссылаться по полному имени класса (FQCN) соответствующего класса события:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/EventSubscriber/RequestSubscriber.php
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;

class RequestSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            RequestEvent::class => 'onKernelRequest',
        ];
    }

    public function onKernelRequest(RequestEvent $event)
    {
        // ...
    }
}

Внутренне, FQCN события рассматривается как псевдоним первоначальных имен событий. Так как отображение уже происходит при компиляции сервис-контейнера, слушатели и подписчики событий, использующие FQCN вместо имен событий, будут появляться под первоначальным именем события при исследовании диспетчера событий.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Kernel.php
namespace App;

use App\Event\MyCustomEvent;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\DependencyInjection\AddEventAliasesPass;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    protected function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new AddEventAliasesPass([
            MyCustomEvent::class => 'my_custom_event',
        ]));
    }
}

Передача компилятора всегда будет раширять существующий список псевдонимов. Из-за этого, безопаснее регистрировать несколько экземпляров передач с разными конфигурациями.

Отладка слушателей событий

Вы можете узнать, какие слушатели зарегистрированы в диспетчере событий, используя консоль. Чтобы показать все события и их слушателей, выполните:

1
$ php bin/console debug:event-dispatcher

Вы можете получить зарегистрированных слушателя конкретного события, указав его имя:

1
$ php bin/console debug:event-dispatcher kernel.exception

или получить все, частично соответствующих имени события:

1
2
$ php bin/console debug:event-dispatcher kernel // matches "kernel.exception", "kernel.response" etc.
$ php bin/console debug:event-dispatcher Security // matches "Symfony\Component\Security\Http\Event\CheckPassportEvent"

Система security использует по диспетчеру событий для каждого файерволла. Используйте опцию --dispatcher, чтобы получить зарегистрированных слушателей для конкретного диспетчера событий:

1
$ php bin/console debug:event-dispatcher --dispatcher=security.event_dispatcher.main

Как настроить фильтры "до" и "после"

При разработке веб-приложений довольно часто возникает необходимость в выполнении некоторой логики непосредственно перед или после действий контроллера, выступая в качестве фильтров или крючков.

Некоторые веб-фреймворки определяют такие методы, как preExecute() и postExecute(), но в Symfony таких методов нет. Хорошей новостью является то, что существует гораздо лучший способ вмешательства в процесс Запрос -> Ответ, используя компонент EventDispatcher.

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

Представьте, что вам необходимо разработать API, в котором некоторые контроллеры являются публичными, но некоторые другие ограничены одним или несколькими клиентами. Для этих приватных функций вы можете предоставить клиентам токен для идентификации.

Таким образом, перед выполнением действия контроллера вам необходимо проверить, является ли это действие ограниченным или нет. Если оно ограничено, то необходимо проверить предоставленный токен.

Note

Обратите внимание, что для простоты в этом рецепте токены будут определены в конфигурации, и ни настройка базы данных, ни аутентификация через компонент Security, не будут использоваться.

Фильтры "до" с событием kernel.controller

Сначала определите некоторую конфигурацию токена как параметры:

1
2
3
4
5
# config/services.yaml
parameters:
    tokens:
        client1: pass1
        client2: pass2

Контроллеры тегов для проверки

Слушатель kernel.controller (он же KernelEvents::CONTROLLER) получает уведомление при каждом запросе, непосредственно перед выполнением контроллера. Поэтому, во-первых, вам необходимо каким-то образом определить, нуждается ли контроллер, соответствующий запросу, в валидации токена.

Чистый и простой способ - создать пустой интерфейс и заставить контроллеры реализовать его:

1
2
3
4
5
6
namespace App\Controller;

interface TokenAuthenticatedController
{
    // ...
}

Контроллер, реализующий этот интерфейс, выглядит таким образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Controller;

use App\Controller\TokenAuthenticatedController;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class FooController extends AbstractController implements TokenAuthenticatedController
{
    // Действите, требующее аутентификации
    public function bar()
    {
        // ...
    }
}

Создание подписчика событий

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

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
// src/EventSubscriber/TokenSubscriber.php
namespace App\EventSubscriber;

use App\Controller\TokenAuthenticatedController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

class TokenSubscriber implements EventSubscriberInterface
{
    private $tokens;

    public function __construct($tokens)
    {
        $this->tokens = $tokens;
    }

    public function onKernelController(ControllerEvent $event)
    {
        $controller = $event->getController();

        // когда класс контроллера определяет несколько методов действия, контроллер
        // возвращается как [$controllerInstance, 'methodName']
        if (is_array($controller)) {
            $controller = $controller[0];
        }

        if ($controller instanceof TokenAuthenticatedController) {
            $token = $event->getRequest()->query->get('token');
            if (!in_array($token, $this->tokens)) {
                throw new AccessDeniedHttpException('This action needs a valid token!');
            }
        }
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::CONTROLLER => 'onKernelController',
        ];
    }
}

Вот и всё! Ваш файл services.yaml уже должен быть настроен для загрузки сервисов из каталога EventSubscriber. Об остальном позаботится Symfony. Ваш метод TokenSubscriber onKernelController() будет выполняться при каждом запросе. Если контроллер, который будет выполняться, реализует TokenAuthenticatedController, будет применена аутентификация токена. Это позволяет использовать фильтр "до" для любого контроллера по вашему желанию.

Tip

Если ваш подписчик не вызывается при каждом запросе, проверьте, что вы загружаете сервисы из каталога EventSubscriber и включена автоконфигурация . Также вы можете вручную добавить тег kernel.event_subscriber.

Фильтры "после" с событием kernel.response

В дополнение к "крючку", выполняемому до вашего контроллера, вы можете добавить "крючок", выполняемый после вашего контроллера. Для примера, представьте, что вы
отите добавить хэш sha1 (с солью, использующей этот токен) ко всем ответам, которые прошли эту аутентификацию токена.

Другое событие ядра Symfony, называемое kernel.response (оно же KernelEvents::RESPONSE) уведомляется при каждом запросе, но после того, как контроллер возвращает объект Response. Чтобы создать слушателя "после", создайте класс слушателя и зарегистрируйте его в качестве сервиса для этого события.

Например, возьмём TokenSubscriber из предыдущего примера и сначала запишем токен аутентификации в атрибуты запроса. Это будет служить базовым индикатором того, что данный запрос прошёл аутентификацию токена:

public function onKernelController(ControllerEvent $event) { // ...

if ($controller instanceof TokenAuthenticatedController) {

$token = $event->getRequest()->query->get('token'); if (!in_array($token, $this->tokens)) { throw new AccessDeniedHttpException('This action needs a valid token!'); }

// отметить запрос, как прошедший аутентификацию токена $event->getRequest()->attributes->set('auth_token', $token);

}

}

Теперь сконфигурируйте подписчика для прослушивания другого события и добавьте onKernelResponse(). Оно будет искать флаг auth_token в объекте запроса и устанавливать пользовательский заголовок в ответе, если он найден:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// добавить новое заявление об использовании в верхней части вашего файла
use Symfony\Component\HttpKernel\Event\ResponseEvent;

public function onKernelResponse(ResponseEvent $event)
{
    // проверить, отмечен ли onKernelController как запрос токена "auth'ed"
    if (!$token = $event->getRequest()->attributes->get('auth_token')) {
        return;
    }

    $response = $event->getResponse();

    // создать хеш и установить его как заголовок ответа
    $hash = sha1($response->getContent().$token);
    $response->headers->set('X-CONTENT-HASH', $hash);
}

public static function getSubscribedEvents()
{
    return [
        KernelEvents::CONTROLLER => 'onKernelController',
        KernelEvents::RESPONSE => 'onKernelResponse',
    ];
}

Вот и всё! Теперь TokenSubscriber уведомляется перед выполнением каждого контроллера (onKernelController()) и после того, как каждый контроллер вернёт ответ (onKernelResponse()). Заставив конкретные контроллеры реализовывать интерфейс TokenAuthenticatedController, ваш слушатель знает, на какие контроллеры ему следует реагировать. А сохраняя значение в мешке "атрибутов" запроса, метод onKernelResponse() знает, что нужно добавить дополнительный заголовок. Повеселитесь!

Как настроить поведение метода без использованя наследования

Если вы хотите сделать что-то непосредственно перед или сразу после вызова метода вы можете развернуть событие соответственно в начале или в конце метода:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CustomMailer
{
    // ...

    public function send($subject, $message)
    {
        // развернуть событие перед методом
        $event = new BeforeSendMailEvent($subject, $message);
        $this->dispatcher->dispatch($event, 'mailer.pre_send');

        // получить $subject и $message из события, они могли быть изменены
        $subject = $event->getSubject();
        $message = $event->getMessage();

        // настоящая реализация метода здесь
        $returnValue = ...;

        // сделать что-то после метода
        $event = new AfterSendMailEvent($returnValue);
        $this->dispatcher->dispatch($event, 'mailer.post_send');

        return $event->getReturnValue();
    }
}

В этом примере развёртываются два события:

  1. mailer.pre_send, перед вызовом метода,
  2. и mailer.post_send после вызова метода.

Каждый использует пользовательский класс События, чтобы сообщать информацию слушателям двух событий. Например, BeforeSendMailEvent может выглядеть так:

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
// src/Event/BeforeSendMailEvent.php
namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

class BeforeSendMailEvent extends Event
{
    private $subject;
    private $message;

    public function __construct($subject, $message)
    {
        $this->subject = $subject;
        $this->message = $message;
    }

    public function getSubject()
    {
        return $this->subject;
    }

    public function setSubject($subject)
    {
        $this->subject = $subject;
    }

    public function getMessage()
    {
        return $this->message;
    }

    public function setMessage($message)
    {
        $this->message = $message;
    }
}

А AfterSendMailEvent даже так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Event/AfterSendMailEvent.php
namespace App\Event;

use Symfony\Contracts\EventDispatcher\Event;

class AfterSendMailEvent extends Event
{
    private $returnValue;

    public function __construct($returnValue)
    {
        $this->returnValue = $returnValue;
    }

    public function getReturnValue()
    {
        return $this->returnValue;
    }

    public function setReturnValue($returnValue)
    {
        $this->returnValue = $returnValue;
    }
}

Оба события позволяют вам получить некоторую информацию (например, getMessage()) и даже изменить эту информацию (например, setMessage()).

Теперь вы можете создать подписчика событий для подключения к этому событию. Напирмер, вы можете слушать событие mailer.post_send и изменять возвратное значение метода:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/EventSubscriber/MailPostSendSubscriber.php
namespace App\EventSubscriber;

use App\Event\AfterSendMailEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class MailPostSendSubscriber implements EventSubscriberInterface
{
    public function onMailerPostSend(AfterSendMailEvent $event)
    {
        $returnValue = $event->getReturnValue();
        // изменить изначальное значение ``$returnValue``

        $event->setReturnValue($returnValue);
    }

    public static function getSubscribedEvents()
    {
        return [
            'mailer.post_send' => 'onMailerPostSend',
        ];
    }
}

Это всё! Ваш подписчик должен вызываться автоматически (или прочтите больше про конфигурацию подписчика событий ).