Дата обновления перевода 2022-01-02

Компонент OptionsResolver (Разрешитель опций)

Компонент OptionsResolver - это улучшенная замена PHP-функции array_replace. на стероидах. Он позволяет вам создавать систему опций с обязательными опциями, значениями по умолчанию, валидацией (типа, значения), нормализаицей и больше.

Установка

1
$ composer require symfony/options-resolver

Note

If you install this component outside of a Symfony application, you must require the vendor/autoload.php file in your code to enable the class autoloading mechanism provided by Composer. Read this article for more details.

Использование

Представьте, что у вас есть класс Mailer, который имеет четыре опции: host, username, password и port:

class Mailer
{
    protected $options;

    public function __construct(array $options = [])
    {
        $this->options = $options;
    }
}

При получении доступа к``$options``, вам нужно добавить много рутинного кода, чтобы проверить, какие опции установлены:

class Mailer
{
    // ...
    public function sendMail($from, $to)
    {
        $mail = ...;

        $mail->setHost($this->options['host'] ?? 'smtp.example.org');
        $mail->setUsername($this->options['username'] ?? 'user');
        $mail->setPassword($this->options['password'] ?? 'pa$$word');
        $mail->setPort($this->options['port'] ?? 25);

        // ...
    }
}

Также, значения опций по умолчанию закапываются под бизнес-логику вашего кода. Используйте array_replace, чтобы исправить это:

class Mailer
{
    // ...

    public function __construct(array $options = [])
    {
        $this->options = array_replace([
            'host'     => 'smtp.example.org',
            'username' => 'user',
            'password' => 'pa$$word',
            'port'     => 25,
        ], $options);
    }
}

Теперь все четыре опции точно будут установлены, но вы все еще можете сделать ошибку, вроде следующей, используя класс Mailer:

$mailer = new Mailer([
    'usernme' => 'johndoe',  // 'username' is wrongly spelled as 'usernme'
]);

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

К счастью, класс OptionsResolver помогает вам исправить эту проблему:

use Symfony\Component\OptionsResolver\OptionsResolver;

class Mailer
{
    // ...

    public function __construct(array $options = [])
    {
        $resolver = new OptionsResolver();
        $resolver->setDefaults([
            'host'     => 'smtp.example.org',
            'username' => 'user',
            'password' => 'pa$$word',
            'port'     => 25,
        ]);

        $this->options = $resolver->resolve($options);
    }
}

Как и раньше, всеопции будут обязательно установлены. Кроме того, вызывается UndefinedOptionsException, если передаётся неизвестная опция:

$mailer = new Mailer([
    'usernme' => 'johndoe',
]);

// UndefinedOptionsException: Опция "usernme" не существует.
// Известные опции: "host", "password", "port", "username"

Остаток вашего кода может получить доступ к значениям опций без рутинного кода:

// ...
class Mailer
{
    // ...

    public function sendMail($from, $to)
    {
        $mail = ...;
        $mail->setHost($this->options['host']);
        $mail->setUsername($this->options['username']);
        $mail->setPassword($this->options['password']);
        $mail->setPort($this->options['port']);
        // ...
    }
}

Хорошей практикой является разделение конфигурации опций в отдельные методы:

// ...
class Mailer
{
    // ...

    public function __construct(array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);

        $this->options = $resolver->resolve($options);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'host'       => 'smtp.example.org',
            'username'   => 'user',
            'password'   => 'pa$$word',
            'port'       => 25,
            'encryption' => null,
        ]);
    }
}

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

// ...
class GoogleMailer extends Mailer
{
    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->setDefaults([
            'host' => 'smtp.google.com',
            'encryption' => 'ssl',
        ]);
    }
}

Обязательные опции

Если опция должна быть установлена инициатором вызова, передайте эту опцию методу setRequired(). Например, чтобы сделать опцию host обязательной, вы можете:

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setRequired('host');
    }
}

Если вы опустите обязательную опцию, будет вызыван MissingOptionsException:

$mailer = new Mailer();

// MissingOptionsException: Отсутствует обязательная опция "host".

Метод setRequired() принимае одно имя или массив имён опций, еслиу вас более одной обязателной опции:

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setRequired(['host', 'username', 'password']);
    }
}

Используйте isRequired(), чтобы узнать, является ли опция обязательной. Вы можете использовать getRequiredOptions(), чтобы ищвлечь имена всех обзяательных опций:

// ...
class GoogleMailer extends Mailer
{
    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        if ($resolver->isRequired('host')) {
            // ...
        }

        $requiredOptions = $resolver->getRequiredOptions();
    }
}

Если вы хотите проверить, отстствует ли всё ещё обязательная опция в опциях по умолчанию, вы можете использовать isMissing(). Разница между этим и isRequired() заключается в том, что этот метод вернёт “false”, если обязательная опция уже была установлена:

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setRequired('host');
    }
}

// ...
class GoogleMailer extends Mailer
{
    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->isRequired('host');
        // => true

        $resolver->isMissing('host');
        // => true

        $resolver->setDefault('host', 'smtp.google.com');

        $resolver->isRequired('host');
        // => true

        $resolver->isMissing('host');
        // => false
    }
}

Метод getMissingOptions() позволяет вам получить доступ к именам всех отсутствующих опций.

Валидация типа

Вы можете провести дополнительные проверки опций, чтобы убедиться, что они были переданы правильно. Чтобы валидировать типы опций, вызовите setAllowedTypes():

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...

        // укажите один разрешённый тип
        $resolver->setAllowedTypes('host', 'string');

        // укажите несколько разрешённых типов
        $resolver->setAllowedTypes('port', array('null', 'int'));

        // рекурсивно проверьте все объекты в массиве на тип
        $resolver->setAllowedTypes('dates', 'DateTime[]');
        $resolver->setAllowedTypes('ports', 'int[]');
    }
}

Вы можете передать любой тип, для которого функция is_<type>() определена в PHP. Вы можете также передать полное имя класса или интерфейса (который проверяется, используя instanceof). Кроме того, вы можете валидировать все объекты в массиве рекурсивно, добавив к типу суффикс [].

Если вы сейчас передадите невалидную опцию, будет вызван InvalidOptionsException:

$mailer = new Mailer([
    'host' => 25,
]);

// InvalidOptionsException: Опция "host" со значение "25" должна
// иметь тип "string"

В подклассах вы можете использовать addAllowedTypes(), чтобы добавить дополнительные разрешённые типы, не стирая те, что уже установлены.

Валидация значения

Некоторые опции могут использовать только один из списков предопределённых значений. Например, представьте, чтоб класс Mailer имеет опцию transport, которая может быть одним из sendmail, mail и smtp. Используйте метод setAllowedValues(), чтобы убедиться, что переданная опция содержит одно из этих значений:

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefault('transport', 'sendmail');
        $resolver->setAllowedValues('transport', ['sendmail', 'mail', 'smtp']);
    }
}

Если вы передадите невалидный транспорт, будет вызван InvalidOptionsException:

$mailer = new Mailer([
    'transport' => 'send-mail',
]);

// InvalidOptionsException: Опция "transport" имеет значение
// "send-mail", но должна быть одним из "sendmail", "mail", "smtp"

Для опций с более сложными схемами валидации, передайте завершитель, который возвращает true для приемлемых значений, и false - для невалидных:

// ...
$resolver->setAllowedValues('transport', function ($value) {
    // вернуть true или false
});

Tip

Вы даже можете использовать компонент Валидатор, чтобы валидировать ввод, используя метод createIsValidCallable():

use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Validation;

// ...
$resolver->setAllowedValues('transport', Validation::createIsValidCallable(
    new Length(['min' => 10 ])
));

В подклассах вы можете использовать addAllowedValues(), чтобы добавить дополнительные разрешённые значения, не стирая уже установленные.

Нормализация опций

Иногда, значения опций нужно нормализовать перед тем, как использовать. Например, представьте, что host должен всегда начинаться с http://. Чтобы сделать это, вы можете написать нормализаторы. Нормализаторы выполняются после валидации опции. Вы можете сконфигурировать нормализатор, вызвав setNormalizer():

use Symfony\Component\OptionsResolver\Options;

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...

        $resolver->setNormalizer('host', function (Options $options, $value) {
            if ('http://' !== substr($value, 0, 7)) {
                $value = 'http://'.$value;
            }

            return $value;
        });
    }
}

Нормализатор получает настоящее $value и возвращает нормализованную форму. Вы видите, что завершитель также использует параметр $options. Это полезно, если вам нужно использовать другие опции во время нормализации:

// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setNormalizer('host', function (Options $options, $value) {
            if ('http://' !== substr($value, 0, 7) && 'https://' !== substr($value, 0, 8)) {
                if ('ssl' === $options['encryption']) {
                    $value = 'https://'.$value;
                } else {
                    $value = 'http://'.$value;
                }
            }

            return $value;
        });
    }
}

Чтобы нормализовать новое разрешенное значение в суб-классах, которые нормализуются в родительских классах, используйте addNormalizer(). Таким образом, аргумент $value будет получать ранее нормализованное значение, или же вы можете добавить к началу нового нормализатора, передав true в качестве третьего аргумента.

Значения по умолчанию, которые зависят от другой опции

Представтье, что вы хотите установить значение по умолчанию для опции port, основанное на шифровании, выбранном пользователем класса Mailer. Точнее, вы хотите установить порт 465, если используется SSL, и 25 - в других случаях.

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

use Symfony\Component\OptionsResolver\Options;

// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefault('encryption', null);

        $resolver->setDefault('port', function (Options $options) {
            if ('ssl' === $options['encryption']) {
                return 465;
            }

            return 25;
        });
    }
}

Caution

Аргумент вызываемоего должен быть типизирован как Options. Иначе, само вызываемое рассматривается, как значение опции по умолчанию.

Note

Завершитель выполняется только, если опция port не установлена пользователем, или перезаписана в подклассе.

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

// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefaults([
            'encryption' => null,
            'host' => 'example.org',
        ]);
    }
}

class GoogleMailer extends Mailer
{
    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        $resolver->setDefault('host', function (Options $options, $previousValue) {
            if ('ssl' === $options['encryption']) {
                return 'secure.example.org';
            }

            // Взять значение по умолчанию, сконфигурированное в базовом классе
            return $previousValue;
        });
    }
}

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

Опции без значений по умолчанию

В некоторых случаях, полезно определять опцию, не устанавливая значения по умолчанию. Это полезно, если вам нужно знать, действительно ли пользователь установил опцию. Например, если вы установите значене по умолчанию для опции, невозможно узнать, передал ли пользователь значение, или оно просто является значением по умолчанию:

// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefault('port', 25);
    }

    // ...
    public function sendMail($from, $to)
    {
        // Это значение по умолчанию, или инициатор вызова класса действительно
        // установил порт 25?
        if (25 === $this->options['port']) {
            // ...
        }
    }
}

Вы можете использовать setDefined(), чтобы определить опцию, не устанавливая значения по умолчанию. Тогда опция будет включена в разрешённые опции только если она действительно была передана resolve():

// ...
class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefined('port');
    }

    // ...
    public function sendMail($from, $to)
    {
        if (array_key_exists('port', $this->options)) {
            echo 'Set!';
        } else {
            echo 'Not Set!';
        }
    }
}

$mailer = new Mailer();
$mailer->sendMail($from, $to);
// => Не установлено!

$mailer = new Mailer([
    'port' => 25,
]);
$mailer->sendMail($from, $to);
// => Установлено!

Вы также можете передать массив имён опций, если вы хотите определять несколько опций за раз:

// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefined(['port', 'encryption']);
    }
}

Методы isDefined() и getDefinedOptions() позволяют вам узнать, какие опции определены:

// ...
class GoogleMailer extends Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        parent::configureOptions($resolver);

        if ($resolver->isDefined('host')) {
            // Было вызвано одно из следующих:

            // $resolver->setDefault('host', ...);
            // $resolver->setRequired('host');
            // $resolver->setDefined('host');
        }

        $definedOptions = $resolver->getDefinedOptions();
    }
}

Вложенный опции

Представьте, что у вас есть опция под названием spool, которая имеет две подопции type и path. Вместо того, чтобы определять ее как простой массив значений, вы можете пердеать замыкание в качестве значения по умолчанию опции spool с аргументом OptionsResolver. Основываясь на этом экземпляре, вы можете определить опции под spool и его желаемое значение по умолчанию:

class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('spool', function (OptionsResolver $spoolResolver) {
            $spoolResolver->setDefaults([
                'type' => 'file',
                'path' => '/path/to/spool',
            ]);
            $spoolResolver->setAllowedValues('type', ['file', 'memory']);
            $spoolResolver->setAllowedTypes('path', 'string');
        });
    }

    public function sendMail($from, $to)
    {
        if ('memory' === $this->options['spool']['type']) {
            // ...
        }
    }
}

$mailer = new Mailer([
    'spool' => [
        'type' => 'memory',
    ],
]);

Вложенные опции также поддерживают обязательные опции, валидацию (типа, значения) и нормализацию их значений. Если значение по умолчанию вложенной опции зависит от другой опции, определенной на родительском уровне, добавьте второй аргумент Options к ее замыканию, чтобы получить к ним доступ:

class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('sandbox', false);
        $resolver->setDefault('spool', function (OptionsResolver $spoolResolver, Options $parent) {
            $spoolResolver->setDefaults([
                'type' => $parent['sandbox'] ? 'memory' : 'file',
                // ...
            ]);
        });
    }
}

Caution

Аргументы замыкания должны иметь подсказки OptionsResolver и Options, соответственно. Иначе, само замыкание будет считаться значением опции по умолчанию.

Таким же образом, родительскии опции могут получить доступ к вложенным опциям, как к нормальным массивам:

class Mailer
{
    // ...

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('spool', function (OptionsResolver $spoolResolver) {
            $spoolResolver->setDefaults([
                'type' => 'file',
                // ...
            ]);
        });
        $resolver->setDefault('profiling', function (Options $options) {
            return 'file' === $options['spool']['type'];
        });
    }
}

Note

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

Прототипы опций

New in version 5.3: Прототипы опций были представлены в Symfony 5.3.

Бывают ситуации, когда вам нужно будет разрешить и валидировать набор опций, которые могут повторяться много раз в другой опции. Давайте представим опцию connections, которая будет принимать массив соединений базы данных с host, database, user и password в каждой.

Лучший способ реализовать это - определить опцию connections в качестве прототипа:

$resolver->setDefault('connections', function (OptionsResolver $connResolver) {
    $connResolver
        ->setPrototype(true)
        ->setRequired(['host', 'database'])
        ->setDefaults(['user' => 'root', 'password' => null]);
});

В соответствии с определением прототипа в примере выше, возможно иметь множество массивов соединения вроде следующего:

$resolver->resolve([
    'connections' => [
        'default' => [
            'host' => '127.0.0.1',
            'database' => 'symfony',
        ],
        'test' => [
            'host' => '127.0.0.1',
            'database' => 'symfony_test',
            'user' => 'test',
            'password' => 'test',
        ],
        // ...
    ],
]);

Ключи массива (default, test, и др.) этого прототипа опции не подлежат валидации и могут быть любым произвольным значением, помогающим дифференциировать соединения.

Note

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

Устаревание опции

New in version 5.1: Подпись метода setDeprecated() изменилась с setDeprecated(string $option, ?string $message) на setDeprecated(string $option, string $package, string $version, $message) в Symfony 5.1.

Как только опция устарела или вы решили больше её не поддерживать, вы можете пометить её устаревшей используя метод setDeprecated():

$resolver
    ->setDefined(['hostname', 'host'])

    // это выведет следующее общее сообщение об устаревании:
    // Начиная с acme/пакета 1.2: Опция "hostname" устарела.
    ->setDeprecated('hostname', 'acme/package', '1.2')

    // вы можете также передать своё сообщение об устаревании(%name% placeholder is available)
    ->setDeprecated(
        'hostname',
        'acme/package',
        '1.2',
        'The option "hostname" is deprecated, use "host" instead.'
    )
;

Note

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

Note

При использовании опции, помеченной устаревшей вами в собственной библиотеке, вы можете передать false в качестве второго аргумента метода offsetGet(), чтобы не вызывать предупреждение об устаревании.

Вместо передачи сообщения вы можете также передать замыкание, которе вернёт строку (сообщение об устаревании) или пустую строку для игнорирования устаревания. Это замыкание удобно для устаревания только некоторых типов или значений опции:

$resolver
    ->setDefault('encryption', null)
    ->setDefault('port', null)
    ->setAllowedTypes('port', ['null', 'int'])
    ->setDeprecated('port', 'acme/package', '1.2', function (Options $options, $value) {
        if (null === $value) {
            return 'Passing "null" to option "port" is deprecated, pass an integer instead.';
        }

        // устаревание также может зависеть от другой опции
        if ('ssl' === $options['encryption'] && 456 !== $value) {
            return 'Passing a different port than "456" when the "encryption" option is set to "ssl" is deprecated.';
        }

        return '';
    })
;

Note

Устаревание, основанное на значении, запускается только тогда, когда опция предоставляется пользователем.

Это замыкание получает в качестве аргумента значение опции после ее валидации и перед ее нормализацией, когда опция разрешается.

Создание цепочки конфигураций опции

Во многих случаях вам может понадобиться определить множество конфигураций для каждой опции. К примеру, представьте, что класс InvoiceMailer имеет обязательную опцию host, и опцию transport, которая может быть sendmail, mail или smtp. Вы можете улучшить читаемость кода, избежав дублирования имени опции для каждой конфигурации, используя метод define():

// ...
class InvoiceMailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->define('host')
            ->required()
            ->default('smtp.example.org')
            ->allowedTypes('string')
            ->info('The IP address or hostname');

        $resolver->define('transport')
            ->required()
            ->default('transport')
            ->allowedValues('sendmail', 'mail', 'smtp');
    }
}

New in version 5.1: Методы define() и info() были представлены в Symfony 5.1.

Настройки производительности

С текущей реализацией, методй configureOptions() будет вызываться для каждого экземпляра класса Mailer. В зависимости от объема конфигурации опции и количества созданных экземпляров, это может создать дополнительную нагрузку на ваше приложение. Если эта нагрузка станет проблемой, вы можете изменить ваш код, чтобы конфигурация делалась только один раз для одного класса:

// ...
class Mailer
{
    private static $resolversByClass = [];

    protected $options;

    public function __construct(array $options = [])
    {
        // Какой это тип Mailer: Mailer, GoogleMailer, ... ?
        $class = get_class($this);

        // Была ли выполнена configureOptions() до этого класса?
        if (!isset(self::$resolversByClass[$class])) {
            self::$resolversByClass[$class] = new OptionsResolver();
            $this->configureOptions(self::$resolversByClass[$class]);
        }

        $this->options = self::$resolversByClass[$class]->resolve($options);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
    }
}

Теперь экземпляр OptionsResolver будет создан один раз для одного класса и далее использован повторно. Имейте в виду, что это может привести к пробелам в памяти в долгосрочных приложениях, если опции по умолчанию содержат ссылки на объекты или графики объектов. Если это такой случай, реализуйте метод clearOptionsConfig() и периодически вызывайте его:

// ...
class Mailer
{
    private static $resolversByClass = [];

    public static function clearOptionsConfig()
    {
        self::$resolversByClass = [];
    }

    // ...
}

Вот и всё! Теперь у вас есть все инструменты и знания, необходимые для лёгкой обработки опций в вашем коде.

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.