Сокращаем размер конфигов Symfony до минимума
Конфиги — одна из самых недооценённых частей проектов Symfony, и заслуживают быть сделанными правильно. Подобно здоровому стволу дерева, дающему силу ветвям и листьям, ясные конфиги сохраняют архитектуру дизайна ясной и легко развивающейся.
Но прежде чем приступать к модернизации, необходим прочный фундамент. Это означает, что у нас есть конфиги на PHP, мы используем сгенерированные конфиги и load()
для регистрации сервисов.
У вас всё ещё есть конфиги с более чем 10 строками? Или более 100, или 200… строк? Можно сделать лучше. Я поделюсь двумя техниками, которые использую последние пару лет для достижения наилучшей архитектуры с наименьшим количеством строк.
1. От именованных сервисов к уникальным типам
Во времена Symfony 2.8 предполагалось придумывать строковые имена для сервисов, чтобы передавать их в качестве аргументов другим сервисам:
$services->set('app.data_analyser', DataAnalyser::class);
$services->set('app.homepage_controller', HomepageController::class)
->arg('$dataAnalyser', 'app.data_analyser');
Начиная с Symfony 3.0, в этом нет реальной необходимости, поскольку каждый сервис либо уникален, например, наш DataAnalyser
, либо имеет собирательный тип, например, подписчики событий.
Удалите имена сервисов, чтобы их ссылки автоматически подключались по уникальному типу:
-$services->set('app.data_analyser', DataAnalyser::class);
+$services->set(DataAnalyser::class);
-$services->set('app.homepage_controller', HomepageController::class)
- ->arg('$dataAnalyser', 'app.data_analyser');
+$services->set(HomepageController::class);
Таким образом, можно убрать большую часть бесполезного кода в конфигах. Это очевидный путь к единственному уникальному типу.
Но что, если есть несколько экземпляров одного типа?
$services->set('app.data_analyser', DataAnalyser::class)
->arg('$scope', 'production');
$services->set('app.dev_data_analyser', DataAnalyser::class)
->arg('$scope', 'dev');
Необходимо использовать строковые имена, чтобы можно было передавать эти сервисы в разные места:
$services->set(HomepageController::class);
->arg('$dataAnalyser', 'app.data_analyser');
$services->set(AnalyseCommand::class);
->arg('$dataAnalyser', 'app.dev_data_analyser');
Но так ли это? Это типичный паттерн фабрики. Создаётся несколько экземпляров одного и того же типа, но с разными значениями в __construct()
.
Избегайте использования паттерна фабрика в конфигах
Конфиги — не то место, где следует использовать паттерн фабрика. HomepageController
всегда будет принимать один и тот же экземпляр DataAnalyser
, а AnalyseCommand
всегда будет принимать другой экземпляр DataAnalyser
.
Что можно увидеть, прочитав конфиг?
- существует постоянное количество экземпляров одного и того же сервиса — в нашем случае 2 сервиса типа
DataAnalyser
- они нуждаются в выдуманном строковом имени, чтобы быть уникальными
- они всегда используются в качестве аргумента явно в одних и тех же местах
- нет других сервисов с тем же родительским типом, передаваемых в конструктор
- это скрывает архитектуру проекта от самого PHP класса до конфига
Я вижу в этом неправильное применение паттерна фабрика для кодирования конфига.
Какой же выход?
Сделать DataAnalyser
абстрактным и создать уникальные дочерние типы:
final class AppDataAnalyser extends DataAnalyser
{
public function __construct()
{
parent::__construct('production');
}
}
final class DevDataAnalyser extends DataAnalyser
{
public function __construct()
{
parent::__construct('dev');
}
}
Теперь есть 2 уникальных экземпляра, определённых в PHP-коде, вне конфигурации. Теперь эти сервисы:
- не зависят от конфигурации
- автоматически подключаются по точному типу
- легко повторно использовать в других фреймворках
Можно добавить их в контроллеры:
final class HomepageController
{
public function __construct(
- private DataAnalyser $dataAnalyser,
+ private AppDataAnalyser $dataAnalyser,
) {
}
}
И можно очистить конфиги от всего того кода, который использовался:
-$services->set('app.data_analyser', DataAnalyser::class)
- ->arg('$scope', 'production');
-
-$services->set('app.dev_data_analyser', DataAnalyser::class)
- ->arg('$scope', 'dev');
-
-$services->set(HomepageController::class);
- ->arg('$dataAnalyser', 'app.dev_data_analyser');
-
-$services->set(AnalyseCommand::class);
- ->arg('$dataAnalyser', 'app.data_analyser');
Такой подход эффективен для обеспечения надёжности конфигов и дизайна приложений.
2. От ручного биндинга к атрибуту #[Autowire]
.
Автоматическое подключение по типу хорошо известно. Но иногда нужно передать скалярное значение, например имя маршрута или ключ API:
$services = $containerConfigurator->services();
$services->set(DataAnalyser::class)
->arg('$environment', '%kernel.environment%')
->arg('$secret', '%env(LOGGER_SECRET)%');
На каждую скалярную строку конфиг становится на 1 строку длиннее. 1 опасная строка, зависящая от нечёткого порядка аргументов или имени.
Атрибуты PHP 8.0 приходят на помощь
Функция #[Autowire]
была добавлена в Symfony 6.1. Сначала были сомнения, стоит ли переносить логику в сам сервис. Конфигурация должна быть в конфигурационном файле, верно?
После нескольких экспериментов я изменил мнение. Теперь сервисы чётко определяют свои зависимости; не нужно прыгать туда-сюда в файл конфигурации, чтобы узнать о них.
Чтобы автоматически подключить параметр к сервису, добавьте Autowire
и передайте param
или env
как значение аргумента:
+use \Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DataAnalyser
{
public __construct(
+ #[Autowire(param: 'kernel.environment')]
private $environment,
+ #[Autowire(env: 'LOGGER_SECRET')]
private $loggerSecret,
) {
}
}
Как только добавим автоматическое подключение параметров в сервисы, сможем отказаться от ручной регистрации. Можно пойти ещё дальше и отказаться от самой строки служб, поскольку мы уже регистрируем все службы через load()
:
$services = $containerConfigurator->services();
-$services->set(DataAnalyser::class)
- ->arg('$environment', '%kernel.environment%')
- ->arg('$secret', '%env(LOGGER_SECRET)%');
Вот и всё! В качестве бонуса, такой код, основанный на атрибутах, может быть также проанализирован статическим анализом. Можно создать правило PHPStan, проверяющее, определён ли параметр/env, и предупреждающее об этом на ранней стадии CI.