Правильная регистрация консольных команд Symfony в DI

Как добавить такую команду в DI?


<?phpdeclare(strict_types=1);namespace App\Console;use Symfony\Component\Console\Command\Command;final class DoStuffCommand extends Command
{
protected function configure(): void
{
$this
->setName('app:do:stuff')
->setDescription('This command does stuff')
;
}

// ...
}

Неправильный способ

services:
_defaults:
autoconfigure: true App\Console\DoStuffCommand: ~
# tags: [ console.command ]
# с тегом, если не используется автоконфигурация

В чем тут проблема? Symfony DI не знает имя вашей команды при компиляции! Если в проекте есть такие команды, консольное приложение будет все их инициализировать при каждом вызове bin/console с единственной целью — выяснить название запросом $command->getName().

Во-первых, это затратно, потому что при вызове любой одной команды вы поднимаете все сервисы non-lazy команд вместе с их зависимостями.

Во-вторых, это приводит к ошибкам вида Could not connect to PostgreSQL/Redis/… во время прогрева кэша, потому что cache:clear — это тоже консольная команда. Обычно такая проблема всплывает либо при разворачивании проекта локально, когда соответствующий сервис еще не создан или не настроен, либо в CI, где он, например, для юнит-тестов вообще не нужен.

Чтобы этого избежать, нужно всего лишь рассказать DI про имя команды заранее.

Старый неудобный способ

services:
App\Console\DoStuffCommand:
tags:
- { name: console.command, command: app:do:stuff }

В этом случае DI запомнит имя команды при компиляции и она станет lazy.

Минусы данного подхода — дублирование имени команды и провал автоконфигурации.

Правильный способ

Как еще можно задать имя команды без создания экземпляра? Статически, прости Господи! Что и было предложено 3 года назад.

Теперь наш класс выглядит так:

<?phpdeclare(strict_types=1);namespace App\Console;use Symfony\Component\Console\Command\Command;final class DoStuffCommand extends Command
{
protected static $defaultName = 'app:do:stuff'; protected function configure(): void
{
$this->setDescription('This command does stuff');
} // ...
}

Конфигурацию оставляем в первоначальном виде.

services:
_defaults:
autoconfigure: true App\Console\DoStuffCommand: ~
# tags: [ console.command ]
# с тегом, если не используется автоконфигурация

Про то же самое в документации Symfony.

Как это работает

Для команд, имя которых известно заранее, AddConsoleCommandPass формирует service locator. Если рассказывать на пальцах, то это ассоциативный массив, где ключ — имя сервиса, а значение — вызов $container->get('service_id'), обернутый в Closure (шаблон lazy initialization). Поиск команд по частичному совпадению (вы ведь знаете, что можно писать bin/console d:m:di вместо bin/console doctrine:migrations:diff?) выполняется на базе ключей. Когда имя команды окончательно разрешено, консольное приложение получает объект команды вызовом Closure по соответствующему ключу. Именно в этот самый последний момент DI собирает сервис и все его зависимости. При этом, очевидно, другие команды остаются нетронутыми и продолжают тихо спать в своих Closure. Если у вас есть команда, которая требует EntityManager, который в свою очередь запрашивает подключение к БД, то теперь она не выстрелит ошибкой при чистке кэша в CI без сервиса БД.

Service locator в таком контексте не является антипаттерном. По сути, это коллекция объектов одного типа со встроенной отложенной инициализацией. Начиная с Symfony 3.3 локаторы сервисов, отмеченных определенным тегом, легко и удобно объявлять в конфигурации DI. Я часто пользуюсь этим инструментом в проектах и библиотеках.

Что делать, если у меня не так

Рефакторить! Особенно если у вас есть проблемы с подключением к I/O. Всё что нужно сделать — перенести имя команды из метода configure в статическое свойство $defaultName. Для поиска non-lazy команд в Symfony 4/5 можно использовать отладку параметров через консоль: bin/console debug:container --parameter=console.command.ids (это следует из кода AddConsoleCommandPass). Если проблемных команд немного, проще исправить руками. Год назад в одном большом проекте я прошелся замысловатой regexp-заменой по папке src.