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