Как переиспользовать код с бандлами Symfony 5? Часть 4. Расширение бандла в хосте

от автора

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

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

Попробуем предусмотреть такие точки в нашем демо-приложении. В этой статье:

  • Подключение пользовательской логики к бандлу
  • Работа с тегами
  • Compiler Pass
  • Автоконфигурация сервисов

Содержание серии

Часть 1. Минимальный бандл
Часть 2. Выносим код и шаблоны в бандл
Часть 3. Интеграция бандла с хостом: шаблоны, стили, JS
Часть 4. Интерфейс для расширения бандла
Часть 5. Параметры и конфигурация
Часть 6. Тестирование, микроприложение
Часть 7. Релизный цикл, установка и обновление

Если вы не последовательно выполняете туториал, то скачайте приложение из репозитория:
https://github.com/bravik/symfony-bundles-tutorial/
и переключитесь на ветку 3-integration.

Инструкции по установке и запуску проекта в файле README.md.
Финальную версию кода для этой статьи вы найдете в ветке 4-extend.

Задача

В календаре есть функция экспорта события в GoogleCalendar или iCalendar.
Наша задача — сделать наш бандл гибче и добавить возможность пользователям бандла расширять его собственными форматами экспорта в своих приложениях.

Например, добавим экспорт в JSON-файл. Приступим.

Как устроен экспорт мероприятий?

Чтобы понять, как добавить новый формат посмотрим, как работает EventExporter.

Логика экспорта реализована в компоненте EventExporter, который располагается в services/EventExporter. Мы уже перенесли его в бандл и поправили названия пространств имен. Главные файлы компонента это:

  • ExporterInterface моделирующий формат экспорта события и
  • ExporterManager, который хранит информацию о доступных экспортерах и выдает их по необходимости

Откроем ExporterInterface.

Экспортер — это объект, который может из события либо формировать специальную ссылку экспорта (например для Google Calendar), либо генерировать текстовый файл (например для iCalendar). Первые экспортеры будем называть inline. А для вторых нам потребуется дополнительный экшн в контроллере EventController::export(), который будет отдавать браузеру сгенерированный файл.

Экспортер моделируется простым классом, в котором определяется его

  • название
  • тип
  • является ли экспортер inline
  • собственно функция экспорта

Далее мы определяем 2 абстрактных экспортёра AbtractInlineExporter и AbstractFileExporter. Первый возвращает в результате экспорта строку (отформатированную ссылку), второй возвращает объект, моделирующий файл (ExportedFile).

Из коробки наш бандл поставляется с 2мя конкретными реализациями — GoogleCalendarExporter и ICalendarExporter.

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

Создаем свой формат экспорта

В приложении-хосте создадим класс JSON-экспортера.

Вы можете вытащить готовый код класса из репозитория:

git checkout 4-extend -- src/Service/EventExporter/JsonExporter.php

или скопировать src/Service/EventExporter/JsonExporter.php:

<?php declare(strict_types=1);  namespace App\Service\EventExporter;  use bravik\CalendarBundle\Entity\Event; use bravik\CalendarBundle\Service\EventExporter\AbstractFileExporter; use bravik\CalendarBundle\Service\EventExporter\ExportedFile;  /**  * Generates a JSON file  */ class JsonExporter extends AbstractFileExporter {     private const DATE_FORMAT = 'Y-m-d H:i:s';      public function getName(): string     {         return 'Файл JSON';     }      public function getType(): string     {         return 'json-file';     }      public function export(Event $event): ExportedFile     {         $data = [             'id'            => $event->getId(),             'title'         => $event->getTitle(),             'description'   => $event->getDescription(),             'venueName'     => $event->getVenueName(),             'venueAddress'  => $event->getVenueAddress(),             'startsAt'      => $event->getStartsAt()->format(self::DATE_FORMAT),             'endsAt'        => $event->getEndsAt() ? $event->getEndsAt()->format(self::DATE_FORMAT) : null,         ];          return new ExportedFile('event.json', 'application/json', json_encode($data));     } }

Как подключить новый формат экспорта?

Сейчас экспортеры регистрируются как сервис в DI-контейнере через конфигурационный файл config/services.yaml бандла. После этого они передаются в ExporterManager в качестве аргумента конструктора.

В Symfony конфиг приложения-хоста всегда имеет приоритет над приложением бандла. Поэтому, как вариант, мы можем переопределить сервис ExporterManager в services.yaml приложения. Например так:

bravik\CalendarBundle\Service\EventExporter\ExporterManager:     arguments:         $exporters:             - '@bravik\CalendarBundle\Service\EventExporter\Exporters\GoogleCalendarExporter'             - '@bravik\CalendarBundle\Service\EventExporter\Exporters\ICalendarExporter'             - 'App\Service\EventExporter\Exporters\JsonExporter'

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

Что если наш бандл будет развиваться, и в какой-то момент поменяются аргументы конструктора, или добавятся новые встроенные в бандл экспортеры, или вообще внутри бандла ExporterManager исчезнет и заменится чем-то новым? Следить за этими обновлениями и вручную обновлять в десятках наших проектов было бы затруднительно.

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

Посмотрим на класс ExporterManager.

Мы видим, что все доступные экспортеры хранятся в простом массиве, а попадают туда через публичный метод ExporterManager::registerExporter(). Передаваемые в конструктор экспортеры тоже регистрируются этим методом. Чтобы зарегистрировать свой экспортер, нужно либо передавать его в конструктор либо где-то вызывать метод registerExporter. И желательно сделать это при сборке DI-контейнера.

Помечаем сервисы тегами

В Symfony сервисы можно помечать тегами. Тег — это произвольная строка, по которой при компиляции DI-контейнера можно достать помеченные сервисы и что-то с ними сделать. Например, можно передать помеченные экспортеры в ExporterManager.

Назначим тег JsonExporter в services.yaml приложения-хоста:

App\Service\EventExporter\JsonExporter:     tags: ['bravik.calendar.exporter']

А так же назначим тег встроенным экспортерам бандла при их регистрации в services.yaml бандла:

bravik\CalendarBundle\Service\EventExporter\Exporters\GoogleCalendarExporter:     tags: ['bravik.calendar.exporter'] bravik\CalendarBundle\Service\EventExporter\Exporters\ICalendarExporter:     tags: ['bravik.calendar.exporter']

Теги существуют в общем пространстве имен, поэтому, чтобы избежать конфликтов, я используйте привычный формат vendor.package.name.

Простой путь передать помеченные тегом сервисы

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

Передадим все помеченные тегом bravik.calendar.exporter экспортеры в качестве аргумента конструктора в ExporterManager. В services.yaml:

bravik\CalendarBundle\Service\EventExporter\ExporterManager:     arguments:         $exporters: !tagged bravik.calendar.exporter

!tagged <tag-name> передаст в аргумент объект iterable со всеми сервисами, помеченными указанном тегом. Поэтому нам нужно поправить typehint в конструкторе ExporterManager:

    public function __construct(iterable $exporters) {     //...     }

Проверим, работает ли наше приложение.

Открыв страницу любого события вы увидите, что в дропдауне «В календарь» появился новый пункт «Файл JSON», а по клику на него скачивается JSON-файл.

(Если что-то не получилось, переключитесь на ветку 4-extend с финальным кодом этой статьи.)

Конструкция !tagged <tag-name>, передаваемая в аргумент, — это удобный синтаксический сахар над более глубоким и интересным процессом, который нельзя обойти стороной. Попробуем передать сервисы по другому.

Создаем Compiler Pass

Все классы в services.yaml регистрируются в DI-контейнере как зависимости. На раннем этапе запуска приложения происходит процесс компиляции контейнера. Symfony анализирует все конфиги, проверяет и оптимизирует зависимости и их связи друг с другом, кэширует их в компактных PHP-файлах. За счет этого приложения Symfony работают быстро.

Каждая операция в процессе компиляции называется Compiler Pass. Symfony позволяет вмешиваться в процесс и создавать свои собственные Compiler Pass.

Мы можем использовать это, чтобы еще на этапе сборки контейнера, добавить регистрацию экспортеров в ExporterManager.

Создайте новую папку и класс bundles/CalendarBundle/src/DependencyInjection/Compiler/ExporterRegistrationPass.php, имплементирующий интерфейс CompilerPassInterface:

namespace bravik\CalendarBundle\DependencyInjection\Compiler;  use bravik\CalendarBundle\Service\EventExporter\ExporterManager; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder;  class ExporterRegistrationPass implements CompilerPassInterface {     public function process(ContainerBuilder $container)     {         if (!$container->has(ExporterManager::class)) {             return;         }          $exporterManagerDefinition = $container->findDefinition(ExporterManager::class);          $taggedServices = $container->findTaggedServiceIds('bravik.calendar.exporter');          $exporterReferences = [];         foreach ($taggedServices as $id => $tags) {             $exporterReferences[] = new Reference($id);         }          $exporterManagerDefinition->setArguments(['$exporters' => $exporterReferences]);     } }

Подключить новый Compiler Pass к компиляции контейнера можно через основной класс бандла src/CalendarBundle. Переопределите в нем унаследованный от Bundle метод Bundle::build() следующим образом:

    public function build(ContainerBuilder $container)     {         parent::build($container);         $container->addCompilerPass(new ExporterRegistrationPass());     }

Чтобы убедиться, что pass подключен, добавьте die("pass") в начало его метода process и обновите страницу. Если вы увидели pass — все нормально.

Что происходит внутри?

Чтобы подключить к ExporterManager экпортеры, нам нужно извлечь их из собираемого DI-контейнера.
ExporterManager мы получим с помощью этой строки:

$exporterManagerDefinition = $container->findDefinition(ExporterManager::class);

Но что за Definition?

Контейнер хранит не сами эти объекты, а так называемые дефиниции Definition объектов.

Суть DI-контейнера в том, что он не создает все возможные зависимости сразу при регистрации, а делает это «лениво». С помощью объектов Definition он хранит «инструкции»: как нужно создавать и инициализировать эти сервисы, какие им требуются в свою очередь зависимости.

Сама инициализация происходит только тогда, когда сервис потребуются в первый раз использовать где-то в коде. Далее использованный сервис кэшируется, и, при повторном использовании, DI-контейнер выдает уже инициализированный сервис.

Таким образом при сборке контейнера мы работаем не с самими сервисами, а с их дефинициями.
Дефиниции экспортеров мы извлекаем по их метке тегом bravik.calendar.exporter.

$taggedServices = $container->findTaggedServiceIds('bravik.calendar.exporter');

Для каждого найденного сервиса, мы создаем ссылку Reference на него, и сообщаем дефиниции ExporterManager, что при инициализации нужно в конструктор передать переменную $exporters с выбранными экспортерами.

$exporterReferences = []; foreach ($taggedServices as $id => $tags) {     $exporterReferences[] = new Reference($id); }  $exporterManagerDefinition->setArguments(['$exporters' => $exporterReferences]);

Уберем в services.yaml бандла явно передаваемые аргументы из регистрации ExporterManager:

bravik\CalendarBundle\Service\EventExporter\ExporterManager: ~

И проверим, работает ли наше приложение.

Автоматическое назначение тегов (autoconfiguration)

Вернемся в конфиги services.yaml.

У нас простое приложение, и в бандле регистрируется всего 2 экспортера:

bravik\CalendarBundle\Service\EventExporter\Exporters\GoogleCalendarExporter:     tags: ['bravik.calendar.exporter'] bravik\CalendarBundle\Service\EventExporter\Exporters\ICalendarExporter:     tags: ['bravik.calendar.exporter']

И еще один в конфиге хоста:

App\Service\EventExporter\JsonExporter:     tags: ['bravik.calendar.exporter']

Но представьте, что у нас не 3, а 30 экспортеров. Регистрировать и назначать тег каждому было бы утомительно.

Мы можем автоматически назначить тег всем сервисам, реализующим определенный интерфейс.

Например ExporterInterface.

Способ №1. _instanceof в конфиге

Вместо предыдущего отрывка кода вставьте в services.yaml бандла:

_instanceof:     # Apply tag to all ExporterInterface implementations     bravik\CalendarBundle\Service\EventExporter\ExporterInterface:         tags: ['bravik.calendar.exporter']

Обновим страницу — всё работает!

Однако если убрать тег у App\Service\EventExporter\JsonExporter, то приложение его уже не найдет.
Дело в том, что конфиг бандла имеет область видимости, ограниченную только бандлом. Но есть и другой способ, избавленный от этого недостатка.

Способ №2. Автоконфигурация Symfony

Уберем предыдущий отрывок из конфига, и в файле DependencyInjection/CalendarExtension добавим в начало метода load():

 public function load(array $configs, ContainerBuilder $container) {     $container->registerForAutoconfiguration(ExporterInterface::class)         ->addTag('bravik.calendar.exporter');     //... }

С помощью механизма автоконфигурации Symfony все дефиниции сервисов, имплементирующих заданный интерфейс, автоматически будут помечены тегом.

Теперь мы полностью можем убрать из конфигов регистрацию как экспортеров, так и ExporterManager. Оставляем лишь:

# Register all EventExporter classes as injectable services bravik\CalendarBundle\Service\EventExporter\:     resource: '../src/Service/EventExporter/*'

в конфиге бандла.

Резюме

Мы рассмотрели использование механизма тегов в Symfony, для передачи сервисов в бандл, определили точку в которой пользователи бандла могут расширить его функциональность. Мы так же заглянули под капот Symfony: узнали, как работает компиляция контейнера, как использовать Compiler Pass и как использовать механизм автоконфигурации.

Финальную версию кода для этой статьи вы найдете в ветке 4-extend.

В следующей статье определим параметры файла, создадим файл конфигурации, научим Symfony его понимать и проверять.

О некоторых еще более продвинутых примерах переопределения сервисов, использования псевдонимов сервисов для создания точек расширения можно почитать в курсе:
(https://symfonycasts.com/screencast/symfony-bundle/override-service#play)

Другие статьи серии:

Часть 1. Минимальный бандл
Часть 2. Выносим код и шаблоны в бандл
Часть 3. Интеграция бандла с хостом: шаблоны, стили, JS
Часть 4. Интерфейс для расширения бандла
Часть 5. Параметры и конфигурация
Часть 6. Тестирование, микроприложение
Часть 7. Релизный цикл, установка и обновление

ссылка на оригинал статьи https://habr.com/ru/post/499074/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *