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

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


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


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


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


    Если вы не последовательно выполняете туториал, то скачайте приложение из репозитория и переключитесь на ветку 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. Релизный цикл, установка и обновление

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 5

      +1
      Серия статей в целом хороша и полезна. Но если честно, местами коробят избыточные англицизмы.
      «Контейнер хранит не сами эти объекты, а так называемые дефиниции Definition объектов.»
      Дефиниции? Вы серьёзно? До сегодняшнего дня в русских статьях я видел исключительно «объект описания сервиса» или «объект-описатель», и как-то все понимали о чём речь.

      Я не против английских слов, но подобная практика приводит к тому, что мы начинаем употреблять какой-то недоязык: и не английский, и не русский.
        0
        Действительно, так будет лучше, поправил. Спасибо
        0
        Такие компилер пасы не нужны
          +1
          Но понимать как «синтаксический сахар» работает полезно
            0
            Безусловно, но такие компилер пасы — просто мертвый код

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое