Перейти к содержанию

Почему так

У каждой библиотеки есть решения, которые сначала кажутся странными. Здесь собраны такие решения events-router: что выбрано, почему, и чем за это заплачено. Ни одно из них не случайно.

Один файл маршрутов

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

Здесь ответ лежит в одном файле. Открыли — увидели всех слушателей, их порядок и middleware. Цена — файл нужно поддерживать руками. Это осознанный обмен: немного больше набора текста ради того, что систему можно прочитать.

Порядок — это порядок строк

Приоритетов нет. Слушатели одного события вызываются в том порядке, в каком объявлены маршруты.

С приоритетами порядок перестаёт быть видимым. priority: 10 в одном файле и priority: 20 в другом — и уже нужно сравнивать числа из разных мест, а при равенстве гадать. Рано или поздно кто-то ставит priority: 999, «чтобы точно сработало первым».

Порядок строк не требует объяснений. Нужно раньше — объявите выше.

Один слушатель на listen()

listen('order.created', [A::class, B::class]) выглядел бы компактнее. Но тогда у маршрута появилось бы два слушателя с общими middleware, и непонятно, что происходит, если первый упал. Одна строка — один слушатель — один отчёт. Общие middleware выносятся на группу.

Статичные слушатели

Слушатель — класс со статичным handle(), а не объект с зависимостями в конструкторе.

Слушателей много, а на конкретное событие срабатывают единицы. Если бы каждый был объектом, роутеру пришлось бы создавать их через контейнер, держать кэш экземпляров и разбираться с тем, что делать, если создать не удалось. Статичный метод снимает всё это: вызвать — и всё. Так же устроены обработчики событий в Bitrix, к которым привыкла команда.

Цена честная: зависимости не видны в сигнатуре, слушатель достаёт их из контейнера сам (Container::get(...)). Подменить зависимость в тесте можно только через контейнер.

Middleware при этом остаются объектами: их меньше, и зависимости через конструктор для них естественнее.

Ошибки видны при объявлении

Шаблон топика, класс слушателя и класс middleware проверяются в listen() и add(), а не когда придёт событие.

Событие может прийти через неделю, ночью, в воркере, который никто не смотрит. Ошибка в маршруте должна всплыть при первом запуске приложения — со стеком, который указывает на строку файла маршрутов. Цена — классы слушателей загружаются автозагрузчиком при чтении маршрутов. Для файла с сотней маршрутов это незаметно.

Кэш — всё или ничего

С кэшем маршрутов файл маршрутов в проде не выполняется. Если разрешить объявлять маршруты и снаружи loadRoutes(), на разработке они работали бы, а в проде молча исчезали. Поэтому с включённым кэшем роутер запрещает такие объявления сразу, и на разработке тоже. Строже, чем хотелось бы, зато прод не может отличаться от того, что вы проверили.

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

Ошибка одного не ломает всех

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

Параметр занимает весь сегмент

В Slim можно написать /order-{id}, здесь order-{order_id} — ошибка. Когда параметр, * и # занимают сегмент целиком, шаблон однозначно разбирается по точкам, а таблицу маршрутов можно скомпилировать в одно регулярное выражение и, в будущем, в дерево поиска. Если сегмент всё же нужно разобрать, это делает regex в параметре: {order_ref:order-\d+}.

Никакой магии с именами событий

Библиотека не выводит имя события из имени класса и не читает атрибуты вроде #[Topic('order.{order_id}.paid')]. Имя события пишется явно — в new Event(...) или в именованном конструкторе DTO.

Магия экономит строчку, но прячет ответ на вопрос «какое событие сейчас уйдёт». Нужны рефлексия, неявное сопоставление {order_id} со свойством $orderId и второй способ сделать то же самое. Явная строка проще и честнее.

Bitrix — в payload, а не в своём событии

Событие Bitrix приходит к слушателю как обычный Event, а всё специфичное для Bitrix — &$arFields, объект D7, отмена — лежит в payload, в BitrixEvent. Второй реализации EventInterface нет. Слушатели, middleware, отчёт и лог для событий Bitrix работают так же, как для ваших собственных.

Bitrix вызывает обработчики двумя несовместимыми способами: старый API передаёт поля по ссылке, D7 — объект по значению. Замыкание с параметром по ссылке на D7 даёт предупреждение PHP, без ссылки — теряет изменения полей. Мост регистрирует обработчик как [$bridge, 'bitrix.main.OnAfterUserAdd'] и принимает вызов в __call(): PHP сохраняет ссылки из call_user_func_array() и не требует их там, где их нет. Один обработчик на оба стиля, без настройки на каждое событие. Это поведение проверяется тестами на всех поддерживаемых версиях PHP.

PSR-14 — снаружи, а не внутри

Роутер реализует стандартный EventDispatcherInterface через адаптер, но внутри работает со своим EventInterface. Стандарт нужен, чтобы встать в чужой код. Свой API нужен, чтобы были топики, параметры, атрибуты и отчёт — то, ради чего роутер вообще написан.

Сообщения на двух языках, один язык на процесс

Сообщения библиотеки по умолчанию на русском, на английском — с 'locale' => 'en'. Язык один на процесс, а не на роутер: имя события проверяется в new Event(), где роутера, у которого можно спросить язык, нет. Глобальная настройка — компромисс, но альтернатива — передавать язык в каждое событие.

В коде сообщения пишутся по-русски, русский текст и есть ключ перевода: Messages::translate('шаблон пустой'). Так код читается так же, как раньше, а тест проверяет, что у каждого сообщения есть английская пара с теми же плейсхолдерами.