OptionsResolver组件

3.4 版本
维护中的版本

OptionsResolver组件是打了激素的 array_replace。它允许你创建一个选项系统,包括必填项、默认选项、验证(类型、值)、标准化(normalization),乃至更多。

安装 

你可以通过下述两种方式安装:

然后,包容vendor/autoload.php文件,以开启Composer提供的自动加载机制。否则,你的程序将无法找到这个Symfony组件的类。

用法 

设想,你有一个 Mailer 类,它有四个选项:host, username, passwordport:

1
2
3
4
5
6
7
8
9
class Mailer
{
    protected $options;
 
    public function __construct(array $options = array())
    {
        $this->options = $options;
    }
}

在访问 $options 时,你需要添加大量模板代码以检查“哪些选项被设置”:

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
{
    // ...
    public function sendMail($from, $to)
    {
        $mail = ...;
 
        $mail->setHost(isset($this->options['host'])
            ? $this->options['host']
            : 'smtp.example.org');
 
        $mail->setUsername(isset($this->options['username'])
            ? $this->options['username']
            : 'user');
 
        $mail->setPassword(isset($this->options['password'])
            ? $this->options['password']
            : 'pa$$word');
 
        $mail->setPort(isset($this->options['port'])
            ? $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 = array())
    {
        $this->options = array_replace(array(
            'host'     => 'smtp.example.org',
            'username' => 'user',
            'password' => 'pa$$word',
            'port'     => 25,
        ), $options);
    }
}

现在,可以确保四个选项被设置。但是,如果 Mailer 类的用户名发生错误了该怎么办?

1
2
3
$mailer = new Mailer(array(
    'usernme' => 'johndoe',  // usernAme misspelled / 用户名误拼
));

不会有错误信息显示出来。最好的情况是,这个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 = array())
    {
        $resolver = new OptionsResolver();
        $resolver->setDefaults(array(
            '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(array(
    'usernme' => 'johndoe',
));
 
// UndefinedOptionsException: The option "usernme" does not exist.
// Known 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)
    {
        $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 = array())
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
 
        $this->options = $resolver->resolve($options);
    }
 
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            '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)
    {
        parent::configureOptions($resolver);
 
        $resolver->setDefaults(array(
            '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)
    {
        // ...
        $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)
    {
        // ...
        $resolver->setRequired(array('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)
    {
        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)
    {
        // ...
        $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():

1
2
3
4
5
6
7
8
9
10
11
12
// ...
class Mailer
{
    // ...
 
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setAllowedTypes('host', 'string');
        $resolver->setAllowedTypes('port', array('null', 'int'));
    }
}

对于每一个选项,你既可以定义某一个类型,也可以定义可接受类型的数组。你可以传入任何 is_<type>() 函数在PHP中所定义的类型,可以传入FQCN类名(fully qualified class name)或接口名:

如果现在你传入一个有效选项,一个 InvalidOptionsException 异常会抛出:

1
2
3
4
5
6
$mailer = new Mailer(array(
    'host' => 25,
));
 
// InvalidOptionsException: The option "host" with value "25" is
// expected to be of type "string"

在子类中,你可以使用 addAllowedTypes() 来添加附加的“许可类型”而毋须抹掉之前已经设置好的选项类型。

值验证 

某些选项只接受预定义值列表中的某一个。例如,假设 Mailer 类有一个 transport 选项,它可以是 sendmail, mailsmtp 中的一个。使用 setAllowedValues() 方法来验证传入的选项包含这些值中的一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefaults(array(
            'encryption' => null,
            'host' => 'example.org',
        ));
    }
}

如果你传入了无效的transport,InvalidOptionsException 异常会抛出:

1
2
3
4
5
6
$mailer = new Mailer(array(
    'transport' => 'send-mail',
));
 
// InvalidOptionsException: The option "transport" has the value
// "send-mail", but is expected to be one of "sendmail", "mail", "smtp"

对于更复杂验证场景下的选项,传入一个closure,它对于接受的值返回true,而对无效值返回false

1
2
3
4
// ...
$resolver->setAllowedValues('transport', function ($value) {
    // return true or false
});

在子类中,你可以使用 addAllowedTypes() 来添加附加的“许可类型”而毋须抹掉之前已经设置好的选项类型。

选项标准化 

有时,在你使用选项值之前,需要对它们进行标准化(normalize)。例如,假设 host 选项始终以 http:// 开头。要实现这个,你可以编写normalizers。Normalizers在选项被验证之后执行。你可以通过调用 setNormalizer() 来配置一个normalizer:

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

normalizer接收真正的 $value 并返回标准化之后的形式。你注意到closure也接收一个 $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)
    {
        // ...
        $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;
        });
    }
}

取决于另一选项的默认值 

假设你希望设置 port 选项的默认值,这个值取决于 Mailer 类的用户所选择的加密方式。更为巧思的是,你希望在使用SSL时设置端口号为 465 否则就是 25

通过传入一个closure作为 port 选项的默认值,你就可以实现这个功能。closure接收选项作为参数。根据这些选项,你可以返回需要的默认值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefault('port', 25);
    }
 
    // ...
    public function sendMail($from, $to)
    {
        // Is this the default value or did the caller of the class really
        // set the port to 25?
        // 这是默认值吗?或者,这个类的调用者是否真的把port设置成了25?
        if (25 === $this->options['port']) {
            // ...
        }
    }
}

回调中的参数,必须使用 options 这个类型提示(type hint)。否则,回调自身将被认为是选项的默认值。

闭包仅在 port 选项没有被用户设置时,或是在子类中被覆写时才执行。

前面设置过的默认值可以通过closure中的第二个参数来访问到:

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)
    {
        // ...
        $resolver->setDefaults(array(
            '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'
            }
 
            // 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
21
// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefault('port', 25);
    }
 
    // ...
    public function sendMail($from, $to)
    {
        // Is this the default value or did the caller of the class really
        // set the port to 25?
        // 这个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)
    {
        // ...
        $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);
// => Not Set!
 
$mailer = new Mailer(array(
    'port' => 25,
));
$mailer->sendMail($from, $to);
// => Set! 

如果你希望一步定义多个选项,可以传入一个选项名称的数组:

1
2
3
4
5
6
7
8
9
10
// ...
class Mailer
{
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
        // ...
        $resolver->setDefined(array('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)
    {
        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();
    }
}

性能调整 

目前的实现过程,configureOptions() 将被每一个 Mailer 实例所调用。根据配置选项的数量和创建的实例数量,这可能会导致你的程序增加可观的开销。如果这些开销成为一个问题的话,你可以改变代码以令每个类在配置时只执行一次。

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
// ...
class Mailer
{
    private static $resolversByClass = array();
 
    protected $options;
 
    public function __construct(array $options = array())
    {
        // What type of Mailer is this, a Mailer, a GoogleMailer, ... ?
        // 这个Mailer是何种类型的?是Mailer还是GoogleMailer, ...?
        $class = get_class($this);
 
        // Was configureOptions() executed before for this class?
        // 之前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 实例只创建一次,并从此复用。注意,这在程序长时间运行后可能导致内存泄露,如果默认选项包含了对“对象和对象图表(object graphs)”的引用的话。如果这正是你面临的情况,实现 clearOptionsConfig() 方法,并且阶段性调用它:

1
2
3
4
5
6
7
8
9
10
11
12
// ...
class Mailer
{
    private static $resolversByClass = array();
 
    public static function clearOptionsConfig()
    {
        self::$resolversByClass = array();
    }
 
    // ...
}

就是这样!现在你已经具备“在代码中轻松处理选项”的全部工具和知识。

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)