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'
]);
不會顯示錯誤。在最好的情況下,錯誤會在測試期間出現,但開發人員將花費時間尋找問題。在最壞的情況下,錯誤可能直到部署到線上系統才會出現。
幸運的是,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']);
}
}
如果您傳遞了無效的 transport,則會拋出 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,您想將 port 設定為 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() 方法的第二個引數,以不觸發棄用警告。
注意
所有棄用訊息都會顯示在分析器日誌的「Deprecations」分頁中。
除了傳遞訊息之外,您也可以傳遞一個閉包 (closure),該閉包會傳回一個字串 (棄用訊息) 或一個空字串以忽略棄用。這個閉包在您只想棄用選項的某些允許類型或值時非常有用。
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"