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


В предыдущей статье говорили о том, как расширять функциональность бандла в приложении-хосте с помощью тегов. В этой статье добавим бандлу гибкости: создадим конфигурационный файл и определим несколько параметров.


  • Параметры DI-контейнера и их переопределение
  • Файл конфигурации бандла
  • Работа с конфигурацией


Если вы не последовательно выполняете туториал, то скачайте приложение из репозитория и переключитесь на ветку 4-extend.


Инструкции по установке и запуску проекта в файле README.md. Финальную версию кода для этой статьи вы найдете в ветке 5-configuration.


Параметры DI-контейнера и их переопределение


Внутри бандла уже есть конфигурационный файл config/services.yaml, где определяется конфигурация сервисов DI-контейнера. Там же можно определить и параметры.


Любые параметры и сервисы бандла, могут быть переопределены в приложении-хосте.

Мы можем воспользоваться этим для того, чтобы позволить пользователю настраивать работу бандла. Например, мы хотим сделать опциональной возможность так называемого режима soft-delete (когда при удалении записи не удаляются из БД, а помечаются «архивными»). Эта фича уже реализована, но по умолчанию отключена.


Для этого введем параметр в config/services.yaml бандла:


parameters:
    bravik.calendar.enable_soft_delete: true

Обратите внимание на формат названия параметра venodor.package.parameter. Мы используем snake_case добавляя через точку префикс: имя вендора и пакета. Параметры определяются в общем пространстве имен для всего приложения, и использование префикса снижает вероятность коллизии имен.


Посмотрим на конструктор контроллера EditorController в бандле. В конструкторе есть опциональный параметр $enableSoftDelete, по умолчанию принимающий значение false:


public function __construct(
    EventRepository $eventRepository,
    bool $enableSoftDelete = false
) { 
    //... 
}

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


bravik\CalendarBundle\Controller\EditorController:
    arguments:
        $enableSoftDelete: '%bravik.calendar.enable_soft_delete%'

Чтобы проверить его работу, перейдите в демо-приложении на страницу «Редактор». Вы увидите, что кнопки удаления стали желтыми, а удаление события теперь переведет его в статус «В архиве», но не удалит. Попробуйте изменить параметр обратно на false, и кнопки снова станут красными, а удаление будет происходить «по-настоящему».


А теперь попробуем этот параметр переопределить в services.yaml приложения-хоста. Приоритет будет отдан параметру, указанному в конфигурации хоста, а не бандла!


Такой подход работает для простых случаев, но имеет недостатки:


  1. Во-первых, мы грубо вмешиваемся в работу бандла. У нас нет приватных параметров, мы не можем запретить пользователю что-то переопределять, и не можем защитить его от «выстрелов себе в ногу». Мы можем просто изменить параметр конфигурации в следующем релизе, а у пользователя возникнут неожиданные проблемы. А зная это, мы не сможем спокойно работать с параметрами, не опасаясь
  2. Во-вторых, мы не можем валидировать корректность конфигурации.

Удалите переопределенный параметр из конфигурации приложения.


Файл конфигурации бандла


Лучшей идеей было бы выделить строго определенный интерфейс: фиксированный набор параметров, которые пользователь может менять, а бандл может парсить, проверять, в случае чего выкидывать исключение и прокидывать в свои внутренние параметры.

После этой договоренности мы уже свободно могли бы менять наш внутренний конфиг, не опасаясь проблем у пользователей. Для этого в Symfony предусмотрено решение.


Если вы откроете в хосте config/packages/, то увидите, что для подключенных бандлов в вашем приложении создаются файлы конфигурации. Их структура и формат четко определены. Попробуйте добавить произвольный параметр в любой из конфигов, и вы получите исключение при запуске приложения.


Мы можем сделать такой же файл и для нашего приложения.


Взглянем на метод load() в файле DependencyInjection/CalendarExtension.php бандла:


public function load(array $configs, ContainerBuilder $container) {}

Мы видим, что помимо ContainerBuilder первым аргументом в него передается массив $configs. Добавим в начало метода dd($configs) и посмотрим на его содержимое: пока что там пустой масив.


Создадим в папке config/packages/ конфиг для бандла calendar.yaml:


calendar:                     # extension key
  enable_soft_delete: false

Название файла значения не имеет, Symfony автоматически пропарсит все конфиг файлы в папке config/packages/ вашего приложения. Но чтобы его содержимое было передано бандлу в CalendarExtension::load(), корневой ключ в файле должен называться так же как Extension-файл, но без слова Extension и в snake_case. На самом деле даже это поведение можно переопределить, но это останется за рамками статьи.


Посмотрим, что теперь попадает в массив $configs:


^ array:1 [Ў
  0 => array:1 [Ў
    "enable_soft_delete" => false
  ]
]

Мы видим наш конфигурационный файл в виде PHP массива, но почему-то он обернут в еще один массив. Зачем?


Дело в том, что конфиг может быть определен не только в одном месте. Например в папке packages вы можете увидеть подпапки test, prod и dev для разных окружений.


И вообще, не обязательно создавать отдельные файлы для конфига. Попробуйте скопировать содержимое конфига нашего бандла, например, в конфиг framework и посмотреть, что будет в переменной $configs. Мы увидим там уже два массива конфигов.


Все найденные по ключу (extension key) версии конфига Symfony не сливает автоматически в один массив, а передает в виде массива конфигов в метод load(). За слияние отвечаете вы сами.


Но нам это не нужно. Оставляем 1 конфиг, убираем dd($configs) и обновляем страницу.


Получаем ошибку:


Если бы мы получали конфиг в виде простого массива, то в чем преимущество его создания над простым переопределением параметров? Нам нужно научить бандл понимать семантику конфига, валидировать его и сообщать пользователям человекопонятные ошибки.


Рядом с CalendarExtension создадим класс Configuration:


<?php
namespace bravik\CalendarBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder('calendar');

        $treeBuilder->getRootNode()
            ->children()
                ->booleanNode('enable_soft_delete')->end()
            ->end()
        ;

        return $treeBuilder;
    }
}

Здесь мы в создаем объект TreeBuilder, моделирующий конфигурационный файл, и объявляем в модели единственный параметр enable_soft_delete типа boolean.


TreeBuilder содержит набор методов, позволяющих объявлять как параметры примитивных типов, так и массивы и вложенные объекты. Кроме этого можно добавить их описания, правила валидации, значения по умолчанию и другие свойства параметров.

Чтобы подключить модель конфигурации, в методе CalendarExtension::load() в конец добавляем:


public function load(array $configs, ContainerBuilder $container)
{
    //...
    $configuration = new Configuration();
    $config = $this->processConfiguration($configuration, $configs);
}

Метод processConfiguration() на основе структуры, заданной в файле Configuration, загрузит все доступные конфиги, сольет их в один, провалидирует и выдаст финальный массив $config.


Теперь мы можем использовать $config, чтобы модифицировать контейнер или его сервисы.


Например, мы можем установить нужный нам параметр:


$container->setParameter(
    'bravik.calendar.enable_soft_delete',
    $config['enable_soft_delete']
);

Убедитесь, что вы удалили переопределенный параметр bravik.calendar.enable_soft_delete из конфигурации приложения config/service.yaml.


Попробуйте поменять теперь наш параметр в конфиге бандла и убедитесь, что кнопки «Удалить» в редакторе меняют свой цвет.


Мы можем немного оптимизировать наш services.yaml и убрать вообще параметр bravik.calendar.enable_soft_delete. Вместо этого мы напрямую передадим параметр enable_soft_delete из конфигурации в нужный сервис:


//$container->setParameter(
//    'bravik.calendar.enable_soft_delete',
//    $config['enable_soft_delete']
//);

$definition = $container->getDefinition(EditorController::class);
$definition->setArguments([
  '$enableSoftDelete' => $config['enable_soft_delete'],
]);

Работа с конфигурацией


Для начала, проверим, работает ли валидация параметров.


Попробуйте вместо true/false ввести в качестве значения строку:


Exception: Invalid type for path "calendar.enable_soft_delete". Expected boolean, but got string.

Проверим, что будет, если пользователь вообще не добавит этот параметр в конфиг


Exception: Undefined index: enable_soft_delete

Здесь уже наша недоработка: для пользователя это совершенно непонятная и неожиданная ошибка. Нам необходимо проверять есть ли заданный параметр в конфиге или нет. Но вместо этого давайте назначим этому параметру значение по умолчанию прямо в классе Configuration.


$treeBuilder->getRootNode()
    ->children()
        ->booleanNode('enable_soft_delete')
            ->defaultValue(false)
//            ->defaultFalse() // Сокращенная запись для booleanNode()
        ->end()
    ->end()
;

Или еще лучше, давайте потребуем, чтобы пользователь не смог проигнорировать этот параметр и самостоятельно принял решение. Сделаем параметр обязательным:


$treeBuilder->getRootNode()
    ->children()
        ->booleanNode('enable_soft_delete')
            ->isRequired()
        ->end()
    ->end()
;

Наш бандл постоянно совершенствуется, параметр может быть удален в следующей версии. Чтобы обратить на это внимание пользователя, можно объявить параметр deprecated.


Можно так же добавить немного документации. В Symfony можно использовать команду bin/console config:dump calendar, чтобы получить информацию о конфигурации бандла.


$treeBuilder->getRootNode()
    ->children()
        ->booleanNode('enable_soft_delete')
            ->isRequired()
            ->setDeprecated()
            ->info('Enables soft delete mode for articles. Articles would be marked as `archived` instead of deletion')
        ->end()
    ->end()
;

Добавим вложенный элемент с числовыми параметрами и правило валидации:


calendar:
    limits:
      per_day: 10
      per_month: 100

$treeBuilder->getRootNode()
    ->children()
        ->arrayNode('limits')
            ->addDefaultsIfNotSet()
            ->children()
                ->integerNode('per_day')
                    ->defaultValue(10)
                    ->validate()
                        ->ifTrue(function ($v) { return $v <= 0; })
                        ->thenInvalid('Number must be positive')
                    ->end()
                ->end()
                ->integerNode('per_month')
                    ->defaultValue(100)
                    ->validate()
                        ->ifTrue(function ($v) { return $v <= 0; })
                        ->thenInvalid('Number must be positive')
                    ->end()
                ->end()
            ->end()
        ->end()
    ->end()
    ;

Здесь мы объявляем секцию с двумя фиксированными числовыми параметрами. Каждый из них проверяем: является ли он позитивным числом.


Для валидации параметров Symfony предоставляет целый набор правил, подробнее о которых написано здесь.

Усложним пример: сделаем массив объектов.


Допустим у наc мультиязычный календарь, нам нужно передать коды локалей и их имена. Локалей может быть произвольное количество.


Тогда конфигурация может выглядеть так:


calendar:
    available_locales:
          locales:
              - { code: 'en', label: 'English' }
              - { code: 'ru', label: 'Русский' }

$treeBuilder->getRootNode()
    ->children()
        ->arrayNode('locales')
            ->addDefaultChildrenIfNoneSet()
            ->arrayPrototype()
                ->children()
                    ->scalarNode('code')
                        ->defaultValue('ru')
                    ->end()
                    ->scalarNode('label')
                        ->defaultValue('Русский')
                    ->end()
                ->end()
            ->end()
        ->end()
    ->end()
;

Здесь мы определяем поле-массив, а также определяем через прототип структуру каждого из его элементов.


Это лишь несколько примеров использования конфигурации на практике. Подробнее обо всех возможностях можно прочитать в документации.


Резюме


Мы разобрались как определять и переопределять параметры бандла, создали конфигурационный файл, научили Symfony его понимать и валидировать.


Семантический строго определенный конфигурационный файл, — это интерфейс или контракт между вашим бандлом и его пользователем. Определяя этот интерфейс, вы выносите возможные взаимодействия пользователя с бандлом в одну точку и сохраняете за собой свободу изменений в других частях внутри бандла.


Финальную версию кода для этой статьи вы найдете в ветке 5-configuration.


В следующей статье научимся тестировать бандл отдельно от хоста и создадим микроприложение Symfony для запуска тестов прямо внутри бандла.


Другие статьи серии:


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