OptionsResolver 组件
OptionsResolver 组件是对 array_replace PHP 函数的改进替代品。它允许你创建一个选项系统,包含必需的选项、默认值、验证(类型、值)、规范化等功能。
安装
1
$ composer require symfony/options-resolver
注意
如果在 Symfony 应用之外安装此组件,你必须在代码中引入 vendor/autoload.php
文件,以启用 Composer 提供的类自动加载机制。阅读这篇文章了解更多详情。
用法
假设你有一个 Mailer
类,它有四个选项:host
、username
、password
和 port
1 2 3 4 5 6 7 8 9
class Mailer
{
protected array $options;
public function __construct(array $options = [])
{
$this->options = $options;
}
}
当访问 $options
时,你需要添加一些样板代码来检查哪些选项已设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
class Mailer
{
// ...
public function sendMail($from, $to): void
{
$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 来修复这个问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14
class Mailer
{
// ...
public function __construct(array $options = [])
{
$this->options = array_replace([
'host' => 'smtp.example.org',
'username' => 'user',
'password' => 'pa$$word',
'port' => 25,
], $options);
}
}
现在,所有四个选项都保证会被设置,但当使用 Mailer
类时,你仍然可能会犯如下错误
1 2 3
$mailer = new Mailer([
'usernme' => 'johndoe', // 'username' is wrongly spelled as 'usernme'
]);
不会显示任何错误。在最好的情况下,bug 会在测试期间出现,但开发人员将花费时间寻找问题。在最坏的情况下,bug 可能直到部署到生产系统后才会出现。
幸运的是,OptionsResolver 类可以帮助你解决这个问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
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 异常
1 2 3 4 5 6
$mailer = new Mailer([
'usernme' => 'johndoe',
]);
// UndefinedOptionsException: The option "usernme" does not exist.
// Defined options are: "host", "password", "port", "username"
你代码的其余部分可以访问选项的值,而无需样板代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// ...
class Mailer
{
// ...
public function sendMail($from, $to): void
{
$mail = ...;
$mail->setHost($this->options['host']);
$mail->setUsername($this->options['username']);
$mail->setPassword($this->options['password']);
$mail->setPort($this->options['port']);
// ...
}
}
将选项配置拆分到单独的方法中是一个好习惯
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// ...
class Mailer
{
// ...
public function __construct(array $options = [])
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$this->options = $resolver->resolve($options);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'host' => 'smtp.example.org',
'username' => 'user',
'password' => 'pa$$word',
'port' => 25,
'encryption' => null,
]);
}
}
首先,你的代码变得更易于阅读,特别是当构造函数不仅仅处理选项时。其次,子类现在可以重写 configureOptions()
方法来调整选项的配置
1 2 3 4 5 6 7 8 9 10 11 12 13
// ...
class GoogleMailer extends Mailer
{
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'host' => 'smtp.google.com',
'encryption' => 'ssl',
]);
}
}
必需的选项
如果某个选项必须由调用者设置,请将该选项传递给 setRequired()。例如,要使 host
选项成为必需项,你可以这样做
1 2 3 4 5 6 7 8 9 10 11
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setRequired('host');
}
}
如果省略了必需的选项,将抛出 MissingOptionsException 异常
1 2 3
$mailer = new Mailer();
// MissingOptionsException: The required option "host" is missing.
setRequired() 方法接受单个名称或选项名称数组,如果你有多个必需的选项
1 2 3 4 5 6 7 8 9 10 11
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setRequired(['host', 'username', 'password']);
}
}
使用 isRequired() 来查找选项是否为必需项。你可以使用 getRequiredOptions() 来检索所有必需选项的名称
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// ...
class GoogleMailer extends Mailer
{
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
if ($resolver->isRequired('host')) {
// ...
}
$requiredOptions = $resolver->getRequiredOptions();
}
}
如果你想检查默认选项中是否仍然缺少必需的选项,可以使用 isMissing()。此方法与 isRequired() 的区别在于,如果必需的选项已设置,则此方法将返回 false
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setRequired('host');
}
}
// ...
class GoogleMailer extends Mailer
{
public function configureOptions(OptionsResolver $resolver): void
{
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()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
// specify one allowed type
$resolver->setAllowedTypes('host', 'string');
// specify multiple allowed types
$resolver->setAllowedTypes('port', ['null', 'int']);
// check all items in an array recursively for a type
$resolver->setAllowedTypes('dates', 'DateTime[]');
$resolver->setAllowedTypes('ports', 'int[]');
}
}
你可以传递 PHP 中定义了 is_<type>()
函数的任何类型。你也可以传递完全限定的类名或接口名(使用 instanceof
进行检查)。此外,你可以通过在类型后附加 []
来递归地验证数组中的所有项。
如果你现在传递无效的选项,将抛出 InvalidOptionsException 异常
1 2 3 4 5 6
$mailer = new Mailer([
'host' => 25,
]);
// InvalidOptionsException: The option "host" with value "25" is
// expected to be of type "string", but is of type "int"
在子类中,你可以使用 addAllowedTypes() 来添加额外的允许类型,而无需擦除已设置的类型。
值验证
某些选项只能接受预定义值的固定列表中的一个。例如,假设 Mailer
类有一个 transport
选项,它可以是 sendmail
、mail
和 smtp
之一。使用 setAllowedValues() 方法来验证传递的选项是否包含这些值之一
1 2 3 4 5 6 7 8 9 10 11 12
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefault('transport', 'sendmail');
$resolver->setAllowedValues('transport', ['sendmail', 'mail', 'smtp']);
}
}
如果你传递了无效的传输方式,将抛出 InvalidOptionsException 异常
1 2 3 4 5 6
$mailer = new Mailer([
'transport' => 'send-mail',
]);
// InvalidOptionsException: The option "transport" with value "send-mail"
// is invalid. Accepted values are: "sendmail", "mail", "smtp"
对于具有更复杂验证方案的选项,请传递一个闭包,该闭包对于可接受的值返回 true
,对于无效值返回 false
1 2 3 4
// ...
$resolver->setAllowedValues('transport', function (string $value): bool {
// return true or false
});
提示
你甚至可以使用 Validator 组件,通过使用 createIsValidCallable() 方法来验证输入
1 2 3 4 5 6 7 8
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() 来配置规范化器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
use Symfony\Component\OptionsResolver\Options;
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setNormalizer('host', function (Options $options, string $value): string {
if (!str_starts_with($value, 'http://')) {
$value = 'http://'.$value;
}
return $value;
});
}
}
规范化器接收实际的 $value
并返回规范化后的形式。你可以看到闭包也接受一个 $options
参数。如果需要在规范化期间使用其他选项,这将非常有用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setNormalizer('host', function (Options $options, string $value): string {
if (!str_starts_with($value, 'http://') && !str_starts_with($value, 'https://')) {
if ('ssl' === $options['encryption']) {
$value = 'https://'.$value;
} else {
$value = 'http://'.$value;
}
}
return $value;
});
}
}
要在父类中被规范化的子类中规范化新的允许值,请使用 addNormalizer() 方法。这样,$value
参数将接收先前规范化的值,否则你可以通过传递 true
作为第三个参数来预先添加新的规范化器。
依赖于其他选项的默认值
假设你想根据 Mailer
类的用户选择的加密方式来设置 port
选项的默认值。更准确地说,如果使用 SSL,你希望将端口设置为 465
,否则设置为 25
。
你可以通过传递闭包作为 port
选项的默认值来实现此功能。闭包接收选项作为参数。根据这些选项,你可以返回所需的默认值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
use Symfony\Component\OptionsResolver\Options;
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefault('encryption', null);
$resolver->setDefault('port', function (Options $options): int {
if ('ssl' === $options['encryption']) {
return 465;
}
return 25;
});
}
}
警告
可调用参数的类型必须提示为 Options
。否则,可调用参数本身将被视为选项的默认值。
注意
仅当用户未设置 port
选项或在子类中被覆盖时,才会执行闭包。
可以通过向闭包添加第二个参数来访问先前设置的默认值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefaults([
'encryption' => null,
'host' => 'example.org',
]);
}
}
class GoogleMailer extends Mailer
{
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
$resolver->setDefault('host', function (Options $options, string $previousValue): string {
if ('ssl' === $options['encryption']) {
return 'secure.example.org';
}
// Take default value configured in the base class
return $previousValue;
});
}
}
如示例所示,如果你想在子类中重用父类中设置的默认值,此功能非常有用。
没有默认值的选项
在某些情况下,定义一个选项而不设置默认值很有用。如果你需要知道用户是否实际设置了某个选项,这将非常有用。例如,如果你为某个选项设置了默认值,则无法知道用户是否传递了此值,或者它是否来自默认值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefault('port', 25);
}
// ...
public function sendMail(string $from, string $to): void
{
// Is this the default value or did the caller of the class really
// set the port to 25?
if (25 === $this->options['port']) {
// ...
}
}
}
你可以使用 setDefined() 来定义一个选项,而不设置默认值。然后,只有当选项实际传递给 resolve() 时,该选项才会包含在已解析的选项中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefined('port');
}
// ...
public function sendMail(string $from, string $to): void
{
if (array_key_exists('port', $this->options)) {
echo 'Set!';
} else {
echo 'Not Set!';
}
}
}
$mailer = new Mailer();
$mailer->sendMail($from, $to);
// => Not Set!
$mailer = new Mailer([
'port' => 25,
]);
$mailer->sendMail($from, $to);
// => Set!
如果要一次定义多个选项,你也可以传递选项名称数组
1 2 3 4 5 6 7 8 9 10
// ...
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$resolver->setDefined(['port', 'encryption']);
}
}
isDefined() 和 getDefinedOptions() 方法让你找出哪些选项已定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// ...
class GoogleMailer extends Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver);
if ($resolver->isDefined('host')) {
// One of the following was called:
// $resolver->setDefault('host', ...);
// $resolver->setRequired('host');
// $resolver->setDefined('host');
}
$definedOptions = $resolver->getDefinedOptions();
}
}
嵌套选项
假设你有一个名为 spool
的选项,它有两个子选项 type
和 path
。你可以传递一个闭包作为 spool
选项的默认值,并使用 OptionsResolver 参数,而不是将其定义为简单的值数组。基于此实例,你可以定义 spool
下的选项及其所需的默认值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('spool', function (OptionsResolver $spoolResolver): void {
$spoolResolver->setDefaults([
'type' => 'file',
'path' => '/path/to/spool',
]);
$spoolResolver->setAllowedValues('type', ['file', 'memory']);
$spoolResolver->setAllowedTypes('path', 'string');
});
}
public function sendMail(string $from, string $to): void
{
if ('memory' === $this->options['spool']['type']) {
// ...
}
}
}
$mailer = new Mailer([
'spool' => [
'type' => 'memory',
],
]);
嵌套选项也支持必需的选项、验证(类型、值)和值的规范化。如果嵌套选项的默认值依赖于父级别中定义的另一个选项,请向闭包添加第二个 Options
参数以访问它们
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('sandbox', false);
$resolver->setDefault('spool', function (OptionsResolver $spoolResolver, Options $parent): void {
$spoolResolver->setDefaults([
'type' => $parent['sandbox'] ? 'memory' : 'file',
// ...
]);
});
}
}
警告
闭包的参数类型必须分别提示为 OptionsResolver
和 Options
。否则,闭包本身将被视为选项的默认值。
同样,父选项可以将嵌套选项作为普通数组访问
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
class Mailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('spool', function (OptionsResolver $spoolResolver): void {
$spoolResolver->setDefaults([
'type' => 'file',
// ...
]);
});
$resolver->setDefault('profiling', function (Options $options): void {
return 'file' === $options['spool']['type'];
});
}
}
注意
将选项定义为嵌套选项意味着你必须传递一个值数组才能在运行时解析它。
原型选项
在某些情况下,你将不得不解析和验证一组选项,这些选项可能在另一个选项中重复多次。让我们想象一个 connections
选项,它将接受数据库连接数组,每个连接都包含 host
、database
、user
和 password
。
实现此目的的最佳方法是将 connections
选项定义为原型
1 2 3 4 5 6
$resolver->setDefault('connections', function (OptionsResolver $connResolver): void {
$connResolver
->setPrototype(true)
->setRequired(['host', 'database'])
->setDefaults(['user' => 'root', 'password' => null]);
});
根据上面示例中的原型定义,可以有多个连接数组,如下所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
$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
等)是无验证的,可以是任何有助于区分连接的任意值。
注意
原型选项只能在嵌套选项内定义,并且在其解析期间,它将期望一个数组数组。
弃用选项
一旦某个选项过时或你决定不再维护它,你可以使用 setDeprecated() 方法将其弃用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
$resolver
->setDefined(['hostname', 'host'])
// this outputs the following generic deprecation message:
// Since acme/package 1.2: The option "hostname" is deprecated.
->setDeprecated('hostname', 'acme/package', '1.2')
// you can also pass a custom deprecation message (%name% placeholder is available)
// %name% placeholder will be replaced by the deprecated option.
// This outputs the following deprecation message:
// Since acme/package 1.2: The option "hostname" is deprecated, use "host" instead.
->setDeprecated(
'hostname',
'acme/package',
'1.2',
'The option "%name%" is deprecated, use "host" instead.'
)
;
注意
只有当选项在某处被使用时,才会触发弃用消息,无论是用户提供其值,还是在延迟选项和规范化器的闭包中评估该选项。
注意
当在你自己的库中使用你弃用的选项时,你可以传递 false
作为 offsetGet() 方法的第二个参数,以不触发弃用警告。
注意
所有弃用消息都显示在分析器日志的“弃用”选项卡中。
除了传递消息之外,你还可以传递一个闭包,该闭包返回一个字符串(弃用消息)或一个空字符串以忽略弃用。此闭包对于仅弃用选项的某些允许类型或值很有用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
$resolver
->setDefault('encryption', null)
->setDefault('port', null)
->setAllowedTypes('port', ['null', 'int'])
->setDeprecated('port', 'acme/package', '1.2', function (Options $options, ?int $value): string {
if (null === $value) {
return 'Passing "null" to option "port" is deprecated, pass an integer instead.';
}
// deprecation may also depend on another option
if ('ssl' === $options['encryption'] && 456 !== $value) {
return 'Passing a different port than "456" when the "encryption" option is set to "ssl" is deprecated.';
}
return '';
})
;
注意
仅当用户提供选项时,才会触发基于值的弃用。
当选项正在解析时,此闭包接收选项的值作为参数,该值在验证后和规范化之前。
忽略未定义的选项
默认情况下,所有选项都会被解析和验证,如果传递了未知的选项,则会导致 UndefinedOptionsException 异常。你可以使用 ignoreUndefined() 方法来忽略未定义的选项
1 2 3 4 5 6 7 8 9 10 11
// ...
$resolver
->setDefined(['hostname'])
->setIgnoreUndefined(true)
;
// option "version" will be ignored
$resolver->resolve([
'hostname' => 'acme/package',
'version' => '1.2.3'
]);
链式选项配置
在许多情况下,你可能需要为每个选项定义多个配置。例如,假设 InvoiceMailer
类有一个必需的 host
选项和一个 transport
选项,它可以是 sendmail
、mail
和 smtp
之一。你可以使用 define() 方法来提高代码的可读性,避免为每个配置重复选项名称
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// ...
class InvoiceMailer
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
// ...
$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');
}
}
性能调整
在当前的实现中,对于 Mailer
类的每个实例,都会调用 configureOptions()
方法。根据选项配置的数量和创建的实例数量,这可能会给你的应用程序增加明显的开销。如果这种开销成为问题,你可以更改你的代码,以便每个类仅配置一次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ...
class Mailer
{
private static array $resolversByClass = [];
protected array $options;
public function __construct(array $options = [])
{
// What type of Mailer is this, a Mailer, a GoogleMailer, ... ?
$class = get_class($this);
// Was configureOptions() executed before for this class?
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): void
{
// ...
}
}
现在,每个类将创建一次 OptionsResolver 实例,并从此重用。请注意,如果默认选项包含对对象或对象图的引用,这可能会导致长时间运行的应用程序中出现内存泄漏。如果是这种情况,请实现 clearOptionsConfig()
方法并定期调用它
1 2 3 4 5 6 7 8 9 10 11 12
// ...
class Mailer
{
private static array $resolversByClass = [];
public static function clearOptionsConfig(): void
{
self::$resolversByClass = [];
}
// ...
}
就是这样!你现在拥有在代码中处理选项所需的所有工具和知识。
获取更多洞察
使用 OptionsResolverIntrospector
来检查 OptionsResolver
实例内部的选项定义
1 2 3 4 5 6 7 8 9 10 11
use Symfony\Component\OptionsResolver\Debug\OptionsResolverIntrospector;
use Symfony\Component\OptionsResolver\OptionsResolver;
$resolver = new OptionsResolver();
$resolver->setDefaults([
'host' => 'smtp.example.org',
'port' => 25,
]);
$introspector = new OptionsResolverIntrospector($resolver);
$introspector->getDefault('host'); // Retrieves "smtp.example.org"