Компонент EventDispatcher

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

Компонент EventDispatcher

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

О каких привязках мы говорим? Например, об аутентификации или кешировании. Чтобы иметь гибкость, привязки должны быть автоматически конфигурируемыми; те, которые вы "зарегистрируете" для приложения, отличюатся от тех, что зависят от ваших специфических нужд. Большинство софта имеет схожий концепт, вроде Drupal или Wordpress. В некоторых языках даже существует стандарт, как WSGI в Python или Rack в Ruby.

Как как в PHP не существует стандарта, мы будем использовать широко известный шаблон проекта, Mediator, чтобы позволить присоединение любого поведения к нашему фреймворку; Компонент Symfony EventDispatcher реализует облегчённую версию этого шаблона:

1
$ composer require symfony/event-dispatcher

Как это раотает? Диспетчер - центральный объект системы диспетчера событий, увдомляет слушателей о событии, развёрнутом в нём. Другими словами: ваш код развёртывает событие в дисптчере, диспетчер уведомляет всех зарегистрированных слушателей события, а каждый слушатель делает с событием то, что он хочет.

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

Чтобы это работало, фреймворк должен развернуть событие прямо перед тем, как вернуть экземпляр Ответа:

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
// example.com/src/Simplex/Framework.php
namespace Simplex;

use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;

class Framework
{
    private $dispatcher;
    private $matcher;
    private $controllerResolver;
    private $argumentResolver;

    public function __construct(EventDispatcher $dispatcher, UrlMatcherInterface $matcher, ControllerResolverInterface $controllerResolver, ArgumentResolverInterface $argumentResolver)
    {
        $this->dispatcher = $dispatcher;
        $this->matcher = $matcher;
        $this->controllerResolver = $controllerResolver;
        $this->argumentResolver = $argumentResolver;
    }

    public function handle(Request $request)
    {
        $this->matcher->getContext()->fromRequest($request);

        try {
            $request->attributes->add($this->matcher->match($request->getPathInfo()));

            $controller = $this->controllerResolver->getController($request);
            $arguments = $this->argumentResolver->getArguments($request, $controller);

            $response = call_user_func_array($controller, $arguments);
        } catch (ResourceNotFoundException $exception) {
            $response = new Response('Not Found', 404);
        } catch (\Exception $exception) {
            $response = new Response('An error occurred', 500);
        }

        // развернуть событие ответа
        $this->dispatcher->dispatch('response', new ResponseEvent($response, $request));

        return $response;
    }
}

Теперь, каждый раз, когда фреймворк обрабатывает Запрос, развёртывается событие ResponseEvent:

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
// example.com/src/Simplex/ResponseEvent.php
namespace Simplex;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\Event;

class ResponseEvent extends Event
{
    private $request;
    private $response;

    public function __construct(Response $response, Request $request)
    {
        $this->response = $response;
        $this->request = $request;
    }

    public function getResponse()
    {
        return $this->response;
    }

    public function getRequest()
    {
        return $this->request;
    }
}

Последий шаг - это создание диспетчера в фронт контроллере и регистрация слушателя для события response:

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
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';

// ...

use Symfony\Component\EventDispatcher\EventDispatcher;

$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
    $response = $event->getResponse();

    if ($response->isRedirection()
        || ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
        || 'html' !== $event->getRequest()->getRequestFormat()
    ) {
        return;
    }

    $response->setContent($response->getContent().'GA CODE');
});

$controllerResolver = new ControllerResolver();
$argumentResolver = new ArgumentResolver();

$framework = new Simplex\Framework($dispatcher, $matcher, $controllerResolver, $argumentResolver);
$response = $framework->handle($request);

$response->send();

Note

Слушатель - это просто подтверждение концепта и вы должны добавить код Google аналитики прямо перед тегом тела.

Как вы видите, addListener() связывает валидный обратный PHP-вызов с названным событием (response); имя события должно совпадать с тем, которое испольовалось в вызове dispatch().

В слушателе, мы добавляем код Google аналитики только если ответ не является перенапрвлением, если формат запроса - HTML, и если тим содержимого ответа - HTML (эти условия демострируют лёгкость управления данными Запроса и Ответа из вашего кода).

Пока всё неплохо, но давайте добавим ещё оди слушатель того же события. Давайте скажем, что мы хоти установить Content-Length Ответа, если он ещё не установлен:

1
2
3
4
5
6
7
8
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
    $response = $event->getResponse();
    $headers = $response->headers;

    if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
        $headers->set('Content-Length', strlen($response->getContent()));
    }
});

В зависимости от того, добавили ли вы эту часть кода до регистрации предыдущего слушателя, или после, у вас будет правильное или неправильное значение заголовка Content-Length. Иногда порядок слушателей важен, но по умолчанию, все слушатели регстрируются с одинаковой приоритетностью - 0. Чтобы сказать диспетчеру о раннем запуске слушателя, измените приоритет на положительное число; отрицательные числа могут быть использованы для слушателей с низким приоритетом. Здесь, мы хотим, чтобы слушатель Content-Length выполнялся последним, поэтому измените приоритет на -255:

1
2
3
4
5
6
7
8
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
    $response = $event->getResponse();
    $headers = $response->headers;

    if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
        $headers->set('Content-Length', strlen($response->getContent()));
    }
}, -255);

Tip

При создании вашего фреймворка, думайте о приоритетах (зарезервируйте некоторые числа для внутренних слушателей, например) и тщательно их документируйте.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;

class GoogleListener
{
    public function onResponse(ResponseEvent $event)
    {
        $response = $event->getResponse();

        if ($response->isRedirection()
            || ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
            || 'html' !== $event->getRequest()->getRequestFormat()
        ) {
            return;
        }

        $response->setContent($response->getContent().'GA CODE');
    }
}

И сделайте то же самое с другим слушателем:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;

class ContentLengthListener
{
    public function onResponse(ResponseEvent $event)
    {
        $response = $event->getResponse();
        $headers = $response->headers;

        if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
            $headers->set('Content-Length', strlen($response->getContent()));
        }
    }
}

Наш фронт контроллер теперь должен выглядеть так:

1
2
3
$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', array(new Simplex\ContentLengthListener(), 'onResponse'), -255);
$dispatcher->addListener('response', array(new Simplex\GoogleListener(), 'onResponse'));

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

1
2
3
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new Simplex\ContentLengthListener());
$dispatcher->addSubscriber(new Simplex\GoogleListener());

Подписчик знает обо всех событиях, в которых он заинтересован, и передаёт эту информацию диспетчеру через метод getSubscribedEvents(). Посмотрите на новую версию GoogleListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class GoogleListener implements EventSubscriberInterface
{
    // ...

    public static function getSubscribedEvents()
    {
        return ['response' => 'onResponse'];
    }
}

А вот новая версия ContentLengthListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class ContentLengthListener implements EventSubscriberInterface
{
    // ...

    public static function getSubscribedEvents()
    {
        return ['response' => ['onResponse', -255]];
    }
}

Tip

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

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