Symfony2: Конфигурация бандла через Extension класс [Перевод]

Это перевод оригинальной статьи «How to expose a Semantic Configuration for a Bundle» (Как влиять на семантические настройки бандла). В статье описаны способы конфигурации вашего бандла и его сервисов.

Если вы откроете ваш файл конфигурации приложения (app/config/config.yml) то увидите что параметры заключены в различные нэймспейсы, как framework, twig, propel. Каждая такая опция позволяет сначала задавать общие настройки для конкретного бандла, а затем провести более тонкую настройку.
Например, следующей опцией мы включаем интеграцию форм, которая включает в себя определение довольно много услуг, а также интеграцию других связанных компонентов.

framework:
    # ...
    form:    true

При создании бандла есть два варианта как управлять конфигурацией:

  1. Простая: Normal Service Configuration;

    Вы можете указывать сервисы в конфиг файле вашего бандла services.yml, а затем импортировать его из основного файла конфигурации приложения app/config/config.yml. Это быстро, просто и эффективно. Если вы используете параметры, тогда вы еще получаете гибкость при настройке вашего бандла из файла конфигурации приложения. Подробнее о «импорте конфигурации с помощью imports».

  2. Расширенная: Exposing Semantic Configuration;

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

Второй вариант более гибкий, однако отнимает больше времени в процессе начальной установки. Если вы решаетесь какой метод использовать, предлагаю начать с первого, а затем при необходимости переписать конфиг по второму варианту.

Также второй способ конфигурации имеет ряд специфических преимуществ:

  • Он более мощный нежели простое определение параметров — определенное значение опции может вызвать подключение множества сервисов;
  • Можно организовать иерархическую структуру параметров;
  • Умное слияние и переопределение параметров при наличии нескольких конфиг файлов, например config_dev.yml и config.yml;
  • Валидация конфига (при использовании Configuration Class);
  • Автодополнение настроек если вы создаете XSD, а разработчики используют XML.

Переопределение параметров бандла

Если бандл обеспечивает Extension класс, как правило, вы не должны переопределять параметры сервис-контейнера для этого бандла. Если бандл предоставляет Extension класс, то все настраиваемые параметры должны быть обработаны только через этот класс. Другими словами, экстеншн класс определяет все публичные (кастомизируемые) параметры для которых будет сохранена обратная совместимость.

Создание Extension класса

Если вы решили использовать Exposing Semantic Configuration (второй вариант конфигурации) для вашего бандла, нужно начать с создания нового Extension класса, который будет обрабатывать процесс загрузки бандла.
Этот класс должен размещаться в директории DependencyInjection. Имя класса и файла состоит из частей: Вендор+Бандл+Extension.php.
Пример Extension класса AcmeHelloExtension.php:

// Acme/HelloBundle/DependencyInjection/AcmeHelloExtension.php
namespace Acme\HelloBundle\DependencyInjection;

use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class AcmeHelloExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        // ... здесь вся сложная логика
    }

    // Эти два метода нужны только если бандл обеспечивает XSD's конфигурацию

    public function getXsdValidationBasePath()
    {
        return __DIR__.'/../Resources/config/';
    }

    public function getNamespace()
    {
        return 'http://www.example.com/symfony/schema/';
    }
}

Наличие Extension класса AcmeHelloExtension означает, что теперь вы можете определить нэйспейс acme_hello в любом конфигурационном файле. Как вы видите имя нэймспейса строится из имени класса без окончания Extension, вместо кэмелкейс формата — Underscore запись. Теперь можно задать конфигурацию для нашего бандла:

# app/config/config.yml
acme_hello: ~
Если вы будете следовать правилам именования, изложенным выше, метод load() вашего расширения всегда будет вызван после того, как бандл будет зарегистрирован в Kernel. Т.е. даже если не задана никакая конфигурация для бандла с расширением, метод load() все равно будет вызван с аргументом $configs = array(). По желанию вы можете указать некоторые значения по умолчанию.

Парсинг аргумента $configs

При каждом включении нэймспейса acme_hello в файлах конфигурации, параметры этого нэймспейса будут добавлены вложенными массивами в массив $configs — аргумент метода load(). Слияние настроек не происходит автоматически, настройки будут переданные вложенными массивами. Symfony2 автоматически конвертирует настройки разных форматов в массив.

Возьмем следующую конфигурацию:

# app/config/config.yml
acme_hello:
    foo: fooValue
    bar: barValue

Так будет выглядеть массив передан в метод load():

array(
  array(
    'foo' => 'fooValue',
    'bar' => 'barValue'
  )
)

Если настройки бандла будут указаны в другом файле конфига (например, config_dev.yml) массив будет выглядеть так:

array(
  array(
    'foo' => 'fooValue',
    'bar' => 'barValue'
  ),
  array(
    'foo' => 'fooDevValue',
    'baz' => 'newConfigEntry',
  )
)

Порядок массивов зависит от порядка обработки конфигурационных файлов.

Вложенные конфигурации сделали намеренно. Вы сами должны решить как обрабатывать и что объединять в конфигурациях. Дальше в разделе Configuration Class вы научитесь это делать. Сейчас же вы можете просто слить эти конфигурации:

public function load(array $configs, ContainerBuilder $container)
{
    $config = array();
    foreach ($configs as $subConfig) {
        $config = array_merge($config, $subConfig);
    }

    // ... теперь у нас есть плоский массив настроек $config
}
Это просто пример, не используйте его вслепую! Убедитесь, что этот вариант слияния вам подходит.

Использование метода load()

Внутри метода load() переменная $container ссылается на контейнер, который знает о конфигурации этого нэймспеса, но он не содержит служебную информацию о загрузке других бандлов. Цель метода load() — работа с контейнером, добавление и настройка каких-либо методов или сервисов, необходимых для вашего бандла.

Загрузка внешних ресурсов конфигурации

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

use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\Config\FileLocator;

public function load(array $configs, ContainerBuilder $container)
{
  // ... обработка установленных конфигураций $config

  $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));

  // ... загружаем внешний ресурс конфигурации по условию
  if (isset($config['enabled']) && $config['enabled']) {
    $loader->load('services.xml');
  }
}

Конфигурация сервисов и настройка параметров

Загрузив какую-то конфигурацию сервиса, вам может понадобиться изменить некоторые базовые параметры этой конфигурации в зависимости от входных параметров. Предположим, есть сервис, который принимает в качестве первого аргумента строку type, которая используется внутри сервиса.
И вы хотите чтобы это легко настраивалось пользователем бандла. Тогда в настройке этого сервиса (services.xml) вы объявляете сервис и устанавливаете пустой параметр acme_hello.my_service_type как первый аргумент:

<!-- src/Acme/HelloBundle/Resources/config/services.xml -->
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <parameters>
        <parameter key="acme_hello.my_service_type" />
    </parameters>

    <services>
        <service id="acme_hello.my_service" class="Acme\HelloBundle\MyService">
            <argument>%acme_hello.my_service_type%</argument>
        </service>
    </services>
</container>

Но зачем вам объявлять этот пустой параметр и передавать его в сервис? Это сделано для того, чтобы вы могли установить этот параметр внутри метода load() вашего Extension класса. Вы можете устанавливать параметры в зависимости от принятых конфигурационных параметров.
Предположим, что вы хотите разрешить пользователю бандла устанавливать опцию type по паблик параметру my_type:

public function load(array $configs, ContainerBuilder $container)
{
  // ... обработка установленных конфигураций $config

  $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
  $loader->load('services.xml');

  if (!isset($config['my_type'])) {
      throw new \InvalidArgumentException('The "my_type" option must be set');
  }

  // такой себе маппинг параметра
  $container->setParameter('acme_hello.my_service_type', $config['my_type']);
}

Теперь можно настроить сервис указав значение параметру my_type.

# app/config/config.yml
acme_hello:
    my_type: foo
    # ...

Глобальные параметры

При настройке контейнера ($container) помните, что у вам доступны следующие глобальные параметры конфигурации:

kernel.name
kernel.environment
kernel.debug
kernel.root_dir
kernel.cache_dir
kernel.logs_dir
kernel.bundle_dirs
kernel.bundles
kernel.charset
Имена параметров и сервисов, которые начинаются с _ зарезервированы фреймворком и не должны объявляться в бандлах.

Валидация и слияние конфигураций с Configuration классом

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

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

Чтобы воспользоваться преимуществами этой системой, вам нужно создать Configuration класс и создать дерево конфигурации для этого класса.

// src/Acme/HelloBundle/DependencyInjection/Configuration.php
namespace Acme\HelloBundle\DependencyInjection;

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

class Configuration implements ConfigurationInterface
{
  public function getConfigTreeBuilder()
  {
    $treeBuilder = new TreeBuilder();
    $rootNode = $treeBuilder->root('acme_hello');

    $rootNode
      ->children()
        ->scalarNode('my_type')
        ->defaultValue('bar')
        ->info('Что настраивает этот параметр')
        ->example('Пример настройки')
        ->end()
      ->end();

    return $treeBuilder;
  }
}

Это очень простой пример, однако он демонстрирует как использовать класс-конфигуратор внутри метода load() Extension-класса для объединения конфигураций и принужденной валидации. Если будут установленны любые опции, кроме my_type пользователь бандла получит исключение об этом — was passed unsupported option.

public function load(array $configs, ContainerBuilder $container)
{
  $configuration = new Configuration();

  // Метод использует конфигурацию дерева определенную вами в 
  // предыдущем Configuration классе для проверки, нормализации 
  // и объединения вместе всех массивов конфигураций
  $config = $this->processConfiguration($configuration, $configs);

  // ...
}

Класс Configuration может быть гораздо сложнее, чем показано здесь, например: поддержка массивов узлов (array nodes), prototype nodes, расширенная валидация, XML спецификация нормализации, продвинутое слияние. Подробнее в Config Component documentation.
Также можно помотреть на функционал классов-конфигураторов ядра, как FrameworkBundle Configuration, TwigBundle Configuration

Дамп дефолтной конфигурации

В Symfony 2.1 появилась консольная команда php app/console config:dump-reference, которая выводит в консоль дефолтную конфигурацию в формате yaml.

Пока конфигурация бандлов находится в стандартном месте YourBundle\DependencyInjection\Configuration и не имеет конструктора (__constructor()) все будет работать автоматически. Иначе в Extension классе необходимо переопределить метод Extension::getConfiguration(), в котором нужно возвращать экземпляр Configuration.

Соглашения для Extension классов

При создании расширения следуйте этим простым соглашениям:

  • Расширение должно храниться в подкаталоге бандла DependencyInjection;
  • Расширение нужно назвать как бандл, заменив окончание Bundle на Extension;
  • Расширение должно обеспечить XSD схемы.

Если придерживаться этих соглашений, расширения будут автоматически зарегистрированы Symfony2, иначе вам нужно будет переопределить метод build() в классе вашего бандла.

// ...
use Acme\HelloBundle\DependencyInjection\UnconventionalExtensionClass;

class AcmeHelloBundle extends Bundle
{
  public function build(ContainerBuilder $container)
  {
    parent::build($container);

    // register extensions that do not follow the conventions manually
    $container->registerExtension(new UnconventionalExtensionClass());
  }
}

В этом случае в классе расширения также нужно реализовать метод getAlias(), который должен возвращать уникальный алиас имени бандла (e.g. acme_hello). Это необходимо, если имя класса не соответствует стандартам, и не имеет окончания Extension.

При этом, метод load() вашего расширения будет вызван только в том случае, если пользователь указывает acme_hello алиас, хотя бы в одном файле конфигурации.

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