Компонент EventDispatcher

Дата оновлення перекладу 2022-12-14

Компонент 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(new ResponseEvent($response, $request), 'response');

        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', [new Simplex\ContentLengthListener(), 'onResponse'], -255);
$dispatcher->addListener('response', [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

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

Щоб зробити ваш фреймворк дійсно гнучким, не вагаючись додавайте більше подій; і щоб зробити його крутішим при початковій установці, додайте більше слухачів. Знову ж, ця книга не о створенні непримітного фреймворку, а про створення такого, який буде підігнано під ваші потреби. Зупиніться тоді, коли ви цього захочете, і розвивайте ваш код далі звідти.