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


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


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


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

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

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


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