Привет, Хабр!
Иногда в Symfony встают задачи сделать систему плагинов, чтобы можно было подключать новые модули функциональности, не переписывая основную логику. В этой статье я расскажу, как можно создать плагинную архитектуру с помощью контейнера зависимостей Symfony.
Зачем нужна плагинная архитектура?
Если приложение сложное, хочется дать возможность его расширять, подключать сторонние модули или плагины. Правильная плагинная архитектура позволяет добавить новый функционал просто создав новый класс и сконфигурировав его, без изменений основного приложения. Это соответствует принципу Open/Closed.
Контейнер зависимостей Symfony отлично подходит для реализации такого подхода. Вместо того чтобы в коде вручную регистрировать все плагины, мы можем поручить эту работу контейнеру. Symfony умеет автоматически находить и подключать сервисы по определённому правилу, если мы его зададим. Например, можно настроить контейнер так, что он сам найдёт все наши «плагины» и предоставит к ним доступ. В итоге добавление нового плагина сведётся к написанию нового класса и размещению его в контейнере.
Теги сервисов в Symfony: отметим наши плагины
Прежде чем контейнер сможет автоматически собрать все плагины, нужно как‑то пометить сервисы, которые являются плагинами. Для этого в Symfony существуют теги сервисов. Тег по своей сути это просто метка, которую можно навесить на определённую сервисную дефиницию в контейнере. Symfony и сторонние бандлы активно используют теги, чтобы интегрировать ваши сервисы в различные механизмы фреймворка. Например, если пометить сервис тегом twig.extension, то TwigBundle автоматически подключит его как Twig‑расширение.
Можно заводить и свои собственные теги. Допустим, мы решили реализовать систему экспорта данных в разных форматах. У нас будет несколько классов‑экспортёров: один выдает данные в JSON, другой в CSV, третий в XML и так далее Объявим общий интерфейс для экспортёров и пометим все реализации специальным тегом, скажем app.exporter. Тогда контейнер будет знать, что эти сервисы относятся к нашей плагинной системе экспорта.
Для начала создадим интерфейс и пару реализаций. Например, так:
namespace App\Exporter;
interface ExporterInterface
{
public function export(array $data): string;
}
Предположим, каждый экспортёр реализует этот интерфейс. Реализации могут быть такими:
namespace App\Exporter;
class JsonExporter implements ExporterInterface
{
public function export(array $data): string
{
return json_encode($data);
}
}
class CsvExporter implements ExporterInterface
{
public function export(array $data): string
{
// Простейшая реализация CSV через соединение строк
$lines = [];
foreach ($data as $row) {
$lines[] = implode(';', $row);
}
return implode("\n", $lines);
}
}
Теперь надо зарегистрировать эти классы как сервисы в Symfony и пометить их тегом app.exporter. Если вы используете автоконфигурацию, Symfony может делать это автоматически. Например, можно использовать _instanceof в YAML‑конфигурации, чтобы все классы, реализующие ExporterInterface, получали нужный тегsymfony.com.
Покажу для ясности явно, как это выглядит в services.yaml:
# config/services.yaml
services:
# Включаем автоконфигурацию, чтобы Symfony сам добавлял теги для некоторых интерфейсов
_defaults:
autowire: true # автоматическое внедрение зависимостей
autoconfigure: true # автоматическое добавление тегов для известных интерфейсов
# Можно настроить автотегирование для нашего интерфейса ExporterInterface
_instanceof:
App\Exporter\ExporterInterface:
tags: [ 'app.exporter' ]
# Явно регистрируем наши классы экспортёров (хотя с автоконфигурацией это произойдёт и так)
App\Exporter\JsonExporter: ~
App\Exporter\CsvExporter: ~
Воспользовались механизмом _instanceof, чтобы сказать: все сервисы, класс которых реализует App\Exporter\ExporterInterface, пометь тегом app.exporter. Symfony при сборке контейнера проверит все сервисы и кому подходит, тем добавит этот тег автоматическиsymfony.com. В нашем случае JsonExporter и CsvExporter получат тег app.exporter, даже если мы явно его не прописали.
Итак, мы обозначили контейнеру наши плагины, пометив их тегом. Но сам по себе тег это просто текстовая метка. Контейнер её учтёт, но ничего автоматического именно для нашего тега не произойдёт, пока мы не запрограммируем, что делать с этим тегом. В Symfony множество встроенных тегов обрабатываются самим фреймворком или бандлами, но наш тег app.exporter ни о чём ему не говорит. Нам нужно самим реализовать логику: собрать все сервисы с тегом app.exporter и куда‑то их передать, чтобы потом можно было удобно ими пользоваться.
Компилер-пассы: автоматическое подключение плагинов
Symfony имеет мощный механизм для вмешательства в процесс сборки контейнера компилер‑пассы (compiler passes). Компилер‑пасс — это класс, который выполняется на этапе компиляции контейнера и позволяет модифицировать определения сервисов до того, как контейнер будет окончательно собран. С помощью компилер‑пасса можно найти в контейнере нужные сервисы, поменять их настройки, добавить новые сервисы или связать одни сервисы с другими. В нашем случае мы как раз хотим найти все сервисы с тегом app.exporter и зарегистрировать их в неком реестре плагинов.
Чтобы написать компилер‑пасс, достаточно создать класс, реализующий интерфейс CompilerPassInterface из компонента DI Symfony. У него есть один метод process(ContainerBuilder $container), который вызывается во время компиляции контейнера. Внутри него мы и опишем нашу логику.
Напишем компилер‑пасс, который будет собирать экспортёров.
namespace App\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class ExporterPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
// Проверяем, определён ли сервис ExportManager
if (!$container->has(ExportManager::class)) {
return;
}
// Получаем определение сервиса ExportManager
$definition = $container->findDefinition(ExportManager::class);
// Ищем все сервисы с тегом "app.exporter"
$taggedServices = $container->findTaggedServiceIds('app.exporter');
foreach ($taggedServices as $id => $tags) {
// $id — это ID сервиса (например, App\Exporter\JsonExporter)
// $tags — массив атрибутов для каждого вхождения тэга (может быть несколько)
foreach ($tags as $attributes) {
// Получаем имя формата из атрибутов тега, если оно есть
$format = $attributes['format'] ?? null;
// Добавляем вызов ExportManager->addExporter(сервис, формат)
$definition->addMethodCall('addExporter', [new Reference($id), $format]);
}
}
}
}
Сначала компилер‑пасс проверяет, зарегистрирован ли в контейнере сервис ExportManager. Это наш будущий реестр плагинов, о нём чуть позже. Если его нет, нет смысла собирать экспортёры. Затем мы получаем объект Definition для сервиса ExportManager, он понадобится, чтобы навесить на него дополнительные вызовы методов.
Далее используем $container->findTaggedServiceIds('app.exporter'), метод, который возвращает все сервисы с заданным тегомsymfony.com. Symfony вернёт ассоциативный массив, где ключи ID сервисов, а значение массив тегов. В нашем случае вряд ли кто‑то станет дублировать тег, но на всякий случай делаем вложенный foreach по $tags. Внутри этого второго цикла у нас есть $attributes, ассоциативный массив атрибутов тега. Мы ожидаем, что у тега может быть атрибут format, который указывает имя формата экспорта (например, 'json' или 'csv'). Если он есть, берем его.
Теперь про $definition->addMethodCall('addExporter', [new Reference($id), $format]). Программно добавляем к определению ExportManager вызов метода addExporter(), передавая туда ссылку на сервис экспортёра и строковый параметр (формат). Иными словами, когда Symfony будет строить ExportManager, он пропишет в нём вызовы $this->addExporter(<экспортёр>, <format>) для каждого найденного сервиса. Это стандартный приём в компилер‑пассах, через addMethodCall мы как бы описываем, что контейнер должен вызвать метод на нашем сервисе, подставив туда другие сервисы по ссылкеblog.logrocket.comblog.logrocket.com.
Осталось сделать две вещи: реализовать сам класс ExportManager с методом addExporter() и подключить наш компилер‑пасс к процессу сборки контейнера.
Реестр экспортёров: класс ExportManager
Подходов тут может быть несколько. Можно, например, воспользоваться Symfony Service Locator напрямую, но я для ясности напишу свой простой менеджер. Он будет хранить экспортёры в словаре по ключу (формату) и предоставлять к ним доступ.
namespace App\Exporter;
class ExportManager
{
/** @var ExporterInterface[] */
private array $exporters = [];
public function addExporter(ExporterInterface $exporter, ?string $format): void
{
// Если формат не указан, можно использовать класс в качестве ключа по умолчанию
$key = $format ?? get_class($exporter);
$this->exporters[$key] = $exporter;
}
public function getExporter(string $format): ?ExporterInterface
{
return $this->exporters[$format] ?? null;
}
public function getFormats(): array
{
return array_keys($this->exporters);
}
}
Метод addExporter() регистрирует новый экспортёр. Если вдруг формат не был передан (мало ли, забудем указать тегом), тогда я на всякий случай использую имя класса как ключ, чтобы не терять сервис. Метод getExporter($format) вернёт нужный экспортёр по ключу или null, если такого нет. Метод getFormats() просто для полноты картины, чтобы можно было узнать, какие форматы доступны (например, пригодится для вывода списка поддерживаемых форматов).
Теперь убедимся, что ExportManager зарегистрирован как сервис (иначе наш компилер‑пасс выйдет в первой строке):
# продолжение config/services.yaml
services:
App\Exporter\ExportManager:
tags: [] # можно без тегов, но пусть автоконфигурация сама разберётся
Не добавляем никаких специальных тегов, он сам не плагин, он сборщик плагинов. Главное, что он есть в контейнере.
Осталось подключить компилер‑пасс. Если у вас обычное приложение Symfony, это делается в классе Kernel. Переопределяем метод Kernel::build() и там вызываем $container->addCompilerPass(new ExporterPass())symfony.com. Например:
// src/Kernel.php
use App\DependencyInjection\Compiler\ExporterPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
// ...
protected function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new ExporterPass());
}
}
Этим мы регистрируем наш компилер‑пасс в систему. Теперь при сборке контейнера Symfony вызовет наш код, и ExportManager получит все экспортёры.
Кстати, у метода addCompilerPass есть параметры для приоритета и типа пасса. Symfony прогоняет множество внутренних компилер‑пассов в определённом порядке (сначала оптимизации, потом удаление неиспользуемых дефиниций и пр.). По умолчанию наш пасс добавится на поздней стадии, когда все сервисы уже известны. Нам это подходит. Если бы требовался иной момент выполнения, мы могли указать второй параметр (PassConfig::TYPE_BEFORE_OPTIMIZATION или другой) и третий (приоритет) при добавлении пасса. Но в большинстве случаев достаточно базового использования.
Проверяем работу плагинной системы. После запуска приложения контейнер соберётся, и ExportManager автоматически получит все экспортёры. Можно написать в контроллере что‑нибудь такое:
class ExportController extends AbstractController
{
public function __construct(private ExportManager $exportManager) { }
#[Route("/export/{format}")]
public function exportAction(string $format): Response
{
$exporter = $this->exportManager->getExporter($format);
if (!$exporter) {
throw new NotFoundHttpException("Формат {$format} не поддерживается.");
}
$data = [...]; // какие-то данные для экспорта
$output = $exporter->export($data);
// Вернём результат, например, как простой текст
return new Response($output, 200, ['Content-Type' => 'text/plain']);
}
}
Контроллер получает через DI наш ExportManager (спасибо автопроводке, он сам подтянется). В экшене берём из менеджера нужный экспортёр по формату и вызываем его. Если формат неизвестен, бросаем 404. Новый формат (плагин) можно добавить, просто реализовав новый класс ExporterInterface и зарегистрировав его в сервисах — всё остальное контейнер сделает сам.
Однако, может возникнуть вопрос: не слишком ли много кода ради такого результата? Нужно написать отдельный менеджер, компилер‑пасс... С Symfony это несложно, но было бы здорово, если бы контейнер умел всё это из коробки. Оказывается, умеет.
Tagged Locator
Начиная с Symfony 4.3 появился удобный инструмент Tagged Service Locator (тегированный локатор сервисов). По сути, это то, что мы делали руками, но уже реализовано в контейнере. Достаточно описать в конфигурации, что нам нужен сервис‑локатор, объединяющий все сервисы с определённым тегом, и Symfony сама сгенерирует такой локатор. Больше не нужно поддерживать вручную свой компилер‑пасс.
Вернёмся к нашему примеру экспортёров. Вместо того чтобы регистрировать ExportManager и вызывать в нём addExporter на каждом сервисе, мы попросим Symfony сгенерировать нам ServiceLocator, специальный маленький контейнер, реализующий PSR-11\ContainerInterface. Локатор будет содержать только наши экспортёры и больше ничего. Причём создаёт он конкретный экспортёр только когда мы его запрашиваем через $locator->get(...), так что лишних расходов нет, реализация ленивая. Даже если в локаторе сотня сервисов, будут созданы только те, за которыми обратились, поэтому всё эффективно по памяти и времени.
Для использования Tagged Locator в YAML‑конфигурации есть ключевое слово !tagged_locator. Заменим нашу ручную регистрацию на автоматическую. Уберём из контейнера ExportManager и компилер‑пасс, они больше не требуются. Вместо них добавим в конфигурацию сервис, который будет являться локатором:
services:
# ... (регистрируем экспортёры как раньше)
App\Exporter\ExportLocator:
class: Symfony\Component\DependencyInjection\ServiceLocator
arguments:
- !tagged_locator
tag: 'app.exporter'
index_by: 'format'
Здесь определяем сервис App\Exporter\ExportLocator. Можно назвать его как угодно, но пусть будет понятно, что это локатор экспортёров. В качестве класса указываем Symfony ServiceLocator (на самом деле это не обязательно, Symfony сама подставит нужный класс, но для наглядности пусть будет). Самое интересное аргумент: !tagged_locator { tag: 'app.exporter', index_by: 'format' }. Этим мы просим: «Создай аргумент, который является сервис‑локатором, содержащим все сервисы с тегом app.exporter. Используй атрибут format как ключ для индексации». Т.е если у сервиса JsonExporter в контейнере тег app.exporter с атрибутом format: json, то в локаторе он будет доступен под ключом 'json'. Если у другого будет 'csv', он будет под ключом 'csv', и так далее. Мы как бы описали мэппинг прямо декларативно в YAML. Symfony всё сделает сама: найдет все tagged‑сервисы и упакует их в объект‑локатор с методами get/has.
Теперь внедрим этот локатор в наш контроллер, вместо ExportManager:
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
class ExportController extends AbstractController
{
public function __construct(private ContainerInterface $exportLocator) {
// ContainerInterface, потому что ServiceLocator имплементирует PSR-11
}
#[Route("/export/{format}")]
public function exportAction(string $format): Response
{
// Проверяем наличие нужного экспортёра
if (!$this->exportLocator->has($format)) {
throw new NotFoundHttpException("Формат {$format} не поддерживается.");
}
/** @var ExporterInterface $exporter */
$exporter = $this->exportLocator->get($format);
$data = [...];
$output = $exporter->export($data);
return new Response($output, 200, ['Content-Type' => 'text/plain']);
}
}
Используем тип ContainerInterface (это PSR-11), чтобы не привязываться жёстко к конкретному классу локатора. Можно было указать и ServiceLocator, но через интерфейс гибче. Symfony сама внедрит сгенерированный локатор сюда, потому что тип совпадает и имя параметра $exportLocator соответствует ID сервиса (либо можно явно указать #[Required] или аргументом задать).
Получили ровно ту же функциональность, но без написания своего менеджера и компилер‑пасса. Контроллер просто просит некий контейнер с экспортёрами, а Symfony гарантирует, что это будет локатор со всеми нужными сервисами. Добавление нового экспортёра теперь тривиально, создаём класс, реализуем ExporterInterface, регистрируем как сервис (с автоконфигурацией он сам тег получит) и ничего больше делать не надо. Контроллер или любой другой код сразу сможет его получить из того же локатора. Не требуется править ни контроллер, ни конфиги YAML, достаточно одного нового класса, и готово.
В YAML примере использую index_by: 'format', опираясь на то, что каждый сервис помечен тегом с атрибутом format. Если вдруг какой‑то сервис не указал этот атрибут, Symfony подставит в качестве ключа его ID сервиса (обычно это FQCN). Есть и другие варианты: можно задать default_index_method, тогда Symfony вызовет указанный статический метод класса сервиса, чтобы получить ключ. Это как раз то, что сделали бы мы, добавив в ExporterInterface метод вроде public static function getFormat().
Кстати, Symfony позволяет вовсе не писать YAML, а использовать атрибуты. Например, есть атрибут #[AutoconfigureTag] для интерфейсов, чтобы автоконфигурация знала, каким тегом отмечать реализации. В нашем случае можно было пометить интерфейс ExporterInterface атрибутом AutoconfigureTag, тогда Symfony проставляла бы тег с именем интерфейса. А вместо YAML с !tagged_locator мы могли бы в конструкторе контроллера написать:
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
class ExportController extends AbstractController
{
public function __construct(
#[TaggedLocator(ExporterInterface::class, defaultIndexMethod: 'getFormat')]
private ServiceLocator $exporters
) { }
// ...
}
Этот атрибут говорит: инъектировать ServiceLocator, содержащий сервисы с тегом App\Exporter\ExporterInterface (то есть реализующие этот интерфейс), при этом ключи в локаторе брать из статического метода getFormat каждого класса. Symfony всё равно добавит нужный тег и сгенерирует локатор через свои компилер‑пассы, но для нас это полностью прозрачно.
Надеюсь, этот разбор был вам полезен. Теперь, если вы захотите встроить плагинную систему в своё Symfony‑приложение, у вас будет на руках понятный план действий.
Пользуйтесь тегами, доверяйте DI‑контейнеру рутинную работу, и ваши расширяемые модули будут встраиваться в приложение как по маслу.
Если вы работаете с Symfony или только планируете глубже разобраться во фреймворке, обратите внимание на курс «Symfony Framework». В рамках курса преподаватели проведут три бесплатных демо-урока:
5 ноября в 20:00 — «Делаем тонкие контроллеры на Symfony. Валидация» Записаться
11 ноября в 20:00 — «CQRS и идемпотентность в Symfony: пишем надёжные API» Записаться
20 ноября в 20:00 — «Надёжная отправка и получение сообщений через RabbitMQ в Symfony» Записаться

Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее