Події та слухачі подій

Дата оновлення перекладу 2023-05-22

Події та слухачі подій

Під час виконання додатку 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->getException();
        $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(ExceptionEvent $event)
    {
        // ...
    }

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

    public function notifyException(ExceptionEvent $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"

Система безпеки використовує по диспетчеру подій для кожного брандмауеру. Використовуйте опцію --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) - сповіщується при кожному запиті, але після того, як контролер повертає обʼєкт Відповіді. Щоб створити слухача "після", створіть клас слухача та зареєструйте його як сервіс у цій події.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 event та змінити зворотне значення методу:

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',
        ];
    }
}

Ось і все! Ваш підписник має викликатися автоматично (або прочитайте більше про конфігурацію підписників подій ).