Компонент Messenger (Symfony)

Еще в марте 2018 года разработчиков порадовали новостью о том, что теперь семейство symfony-компонентов пополнилось полезным инструментом Messenger. Этот компонент по заверению авторов должен взять на себя задачу отправки/получения сообщений в/из других приложений, а также управление очередями.

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

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

Я не сторонник копировать примеры из документации, поэтому решил сделать упор на более концептуальных вещах, а точнее рассмотреть устройство Messenger-а, его базовые абстракции и задачи, которые на них возложены.

Сложность восприятия

Начну с небольшого лирического отступления и немного затрону тему сложности восприятия информации.

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

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

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

Источник: https://symfony.com/doc/current/components/messenger.html

Чтобы Вы не тратили много времени, хотел бы тезисно дать рекомендации по чтению этой схемы:

    • Ваше сообщение, а это может быть любой объект, оборачивается в дополнительный wrapper, который называется Envelope. Вы можете в этом убедится заглянув в реализацию метода dispatch класса MessageBus.
      1. $envelope = Envelope::wrap($message, $stamps);
    • Внутри Bus, Envelope проходит через цепочку Middlewares, с помощью которых можно задействовать дополнительную логику (на схеме не отражено);
    • Stamps — это метаданные, оперируя которыми Вы фактически можете управлять вашим сообщением внутри Middlewares (на схеме не отражено). Принцип банально прост, если Envelope отправлен с каким-то Stamp, зная это мы можем выполнять дополнительную логику;
    • Пропустив Envelope через все Middlewares, Bus задействует указанный вами Transport и наконец отправит сообщение;
    • На схеме видно, что Receiver, Bus и Sender окрашены одним цветом. По моему мнению, это не совсем корректно. Receiver и Sender — это Transport.

Вы можете в этом убедиться, изучив немного структуру Message компонента.

Таким образом, под фразой «Bus задействует Transport», предполагается, что будет активирован Sender указанного Transport-а для вашего сообщения. Аналогичный вывод применим и к Receiver-у;

Кружками на схеме отмечены как раз 3rd part системы типа RabbitMQ, Doctrine, Redis и т.д.

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

Message Bus

Базовая абстракция в задачи которой входит контроль над процессом отправки сообщения и передача его обработчику. Несмотря на то, что на Bus возложены фактически ключевые задачи — это довольно простой класс с одним методом dispatch. Все дело в том, что Bus это делает очень элегантно.

Как видим, по коду Bus выполняет только две ключевые функции: оборачивает месседж в Envelope (детально об этой абстракции чуть позже) и пропускает сообщение итеративно через Middlewares.

Middlewares — это как раз те строительные блоки, которые имплементируют основную логику Messenger-а. Стоит знать, что Middlewares выполняются согласно порядку регистрации их в контейнере.

Разумеется, у Вас также есть возможность имплементировать любую бизнес-логику в своих Middlewares, но стоит учитывать, что последними всегда выполняются SendMessageMiddleware и HandleMessageMiddleware...что в принципе логично :)

Доступный список можно получить выполнив следующую команду

    php bin/console debug:container messenger.middleware

На момент написания статьи:

     [0 ] messenger.middleware.send_message
      [1 ] messenger.middleware.handle_message
      [2 ] messenger.middleware.add_bus_name_stamp_middleware
      [3 ] messenger.middleware.dispatch_after_current_bus
      [4 ] messenger.middleware.validation
      [5 ] messenger.middleware.reject_redelivered_message_middleware
      [6 ] messenger.middleware.failed_message_processing_middleware
      [7 ] messenger.middleware.traceable
      [8 ] messenger.middleware.doctrine_transaction
      [9 ] messenger.middleware.doctrine_ping_connection
      [10] messenger.middleware.doctrine_close_connection

Middleware, Envelope и Stamps

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

Что значит контекст в этом случае? Это набор данных, которые необходимы для обработки сообщения. К примеру, нам нужно после регистрации нового пользователя отправить письмо-приветствие. Как вариант, мы можем создать класс-сообщение UserRegisteredMessage и в качестве контекста сохранять в нем UUID. По этому UUID-у Handler найдет в базе пользователя и отправит ему письмо на почтовый адрес.

    class UserRegisteredMessage
    {
       /** @var string */
       private $userId;
     
       /**
        * @param string $userId
        */
       public function __construct(string $userId)
       {
           $this->userId = $userId;
       }
     
       /**
        * @return string
        */
       public function getUserId(): string
       {
           return $this->userId;
       }
    }

Это просто для понимания и знакомо по EventDispatcher. Какие тогда причины появление абстракций Envelope и Stamp?

Ранее уже упоминал, что Bus пропускает сообщение через промежуточную логику, так называемые Middlewares. В рамках каждого Middleware мы можем управлять нашим сообщением.

Теперь вопрос, скажем я хочу, чтобы Middleware 1 ничего не делал с сообщением, а в Middleware 2 выполнилась некая логика, каким образом мне уведомить об этом Middleware 2? (см. схему далее по тексту).

Как вариант, мы могли бы в Middleware 2 проверять инстанс переданного сообщения.

    $envelope instanceof UserRegisteredMessage

Это неплохое решение, но тогда Middleware 2 будет жестко ассоциирован с конкретным сообщением. Не исключаю, что такие кейсы тоже могут быть. Но что делать, если мы имеем дело с универсальным Middleware, который должен обрабатывать разные сообщения?

Здесь мы приходим к идее метаданных, то есть, некоего вспомогательного набора данных, которые должны отправляться вместе с сообщением. Stamps, как раз и выполняет эту задачу. Это некий маркер, который несет свой контекст.

И это первопричина появления Stamps. Что же касается Envelope, нужно ответить на следующий вопрос, насколько корректно вместе с контекстом сообщения передавать и метаданные? Я имею в виду сохранять Stamps прямо в объекте сообщения.

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

Это приводит ко второй идее, что контексты нужно передавать как отдельные объекты, а чтобы управлять ими, нужна вспомогательная абстракция, некий управляющий объект. Эту функцию возложили как раз на Envelope. Он выступает wrapper-ом для нашего сообщения и добавляет возможность привязывать к сообщению Stamps.

Давайте закрепим понимание концепции на простом примере. Допустим вы хотите проверить правильность сообщения перед отправкой обработчику. Эту задачу можно решить с помощью доступного из коробки ValidationMiddleware. Обратите внимание на 39-ю строчку, валидатор сработает в том случае, если к Envelope был прикреплен ValidationStamp

    if ($validationStamp = $envelope->last(ValidationStamp::class)) {
        $groups = $validationStamp->getGroups();
    }

Чтобы это сделать, во время dispatch, передадим объект ValidationStamp с названием группы валидации. И тогда в случае ошибки будет выброшено исключение ValidationFailedException, которое можно будет обработать вызывающей стороной.

    $bus->dispatch($message), [
       new ValidationStamp([
           'validation_group',
       ])
    ]);

Transport, Sender и Receiver

Для лучшего осознания, что такое Transport, его лучше представлять в виде некоего хранилища, для работы с которым необходимы еще две абстракции: Sender, который реализует отправку сообщения в хранилище, а также Receiver, который это сообщение забирает из хранилища и отправляет на обработчик.

Из коробки нам доступно три асинхронных Transports: AmqpExt, RedisExt и Doctrine, а также два синхронных: In Memory и Sync. Причем для Sync не требуется реализация Receiver-а, т.к. сообщения сразу отправляется на Handler. В общем делает то же, что и EventDispatcher (ну вы поняли намек ;) ).

Разумеется у вас есть возможность создать свой кастомизированный Transport. Для этого необходимо реализовать два интерфейса TransportFactory и TransportInterface.

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

Подведем итоги

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

По сути, мы получаем улучшенный EventDispatcher с роутингом, Middlewares, возможностью управлять сообщением посредством Stamps, а также использовать различный Transport, в том числе асинхронный.

Делая такие выводы, я фактически поставил Messenger и EventDispatcher в один ряд, но хочу подчеркнуть, что четко осознаю разность подходов в этих двух компонентах.

EventDispatcher отправляет, а Listeners/Subscribers обрабатывают. В то же время Messenger как отправляет, так и контролирует обработку. Можно сделать вывод, что Messenger умеет делать все то, что и EventDispatcher и даже больше. Однако обратное утверждения некорректно: второй ограничен на фоне первого.

Вы вправе сами решать, что использовать, но я бы предпочел Messenger, как более гибкую альтернативу.

Немного статистики. На момент написания статьи:

На packagist.org 1.8 млн инсталляций и почти 500 звезд.

По issues имеем 41/190 (opened/closed) из которых:

    • bugs: 13/93;
    • performance: 2/5;
    • feature: 31/160.

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

И напоследок: в процессе изучения материалов доступных в интернете я натолкнулся на довольно интересную статью по теме. Хоть я и не знаком с автором, мне очень импонирует формат подачи информации, поэтому рекомендую для прочтения:

https://vria.eu/delve_into_the_heart_of_the_symfony_messenger/

Ну и красочный Cheat Sheet :)

https://medium.com/@andreiabohner/symfony-4-4-messenger-cheat-sheet-5ca99cbba4a8

Спасибо за то, что дочитали до конца. Удачи!