服務訂閱者 & 定位器
有時,一個服務需要存取數個其他服務,但不確定是否會實際使用所有這些服務。在這些情況下,您可能會希望延遲服務的實例化。然而,使用顯式的依賴注入是無法實現的,因為並非所有服務都旨在 lazy
(請參閱 延遲服務)。
另請參閱
另一種延遲注入服務的方式是透過服務閉包。
這通常會發生在您的控制器中,您可能會在建構子中注入數個服務,但呼叫的動作只會使用其中的一部分。另一個例子是應用程式實作了 命令模式,使用 CommandBus 依命令類別名稱對應命令處理器,並在需要時使用它們來處理各自的命令
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
// src/CommandBus.php
namespace App;
// ...
class CommandBus
{
/**
* @param CommandHandler[] $handlerMap
*/
public function __construct(
private array $handlerMap,
) {
}
public function handle(Command $command): mixed
{
$commandClass = get_class($command);
if (!$handler = $this->handlerMap[$commandClass] ?? null) {
return;
}
return $handler->handle($command);
}
}
// ...
$commandBus->handle(new FooCommand());
考慮到一次只處理一個命令,實例化所有其他命令處理器是不必要的。延遲載入處理器的一個可能的解決方案是注入主要的依賴注入容器。
然而,不鼓勵注入整個容器,因為它給予對現有服務過於廣泛的存取權限,並且它隱藏了服務的實際依賴關係。這樣做也需要將服務設為 public,這在 Symfony 應用程式中預設情況下並非如此。
服務訂閱者 旨在透過提供對一組預定義服務的存取權限來解決此問題,同時僅在實際需要時透過 服務定位器(一個單獨的延遲載入容器)來實例化它們。
定義服務訂閱者
首先,將 CommandBus
轉換為 ServiceSubscriberInterface 的實作。使用其 getSubscribedServices()
方法,在服務訂閱者中包含盡可能多的所需服務
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
// src/CommandBus.php
namespace App;
use App\CommandHandler\BarHandler;
use App\CommandHandler\FooHandler;
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
class CommandBus implements ServiceSubscriberInterface
{
public function __construct(
private ContainerInterface $locator,
) {
}
public static function getSubscribedServices(): array
{
return [
'App\FooCommand' => FooHandler::class,
'App\BarCommand' => BarHandler::class,
];
}
public function handle(Command $command): mixed
{
$commandClass = get_class($command);
if ($this->locator->has($commandClass)) {
$handler = $this->locator->get($commandClass);
return $handler->handle($command);
}
}
}
提示
如果容器不包含訂閱的服務,請仔細檢查您是否已啟用 autoconfigure。您也可以手動新增 container.service_subscriber
標籤。
服務定位器是一個 PSR-11 容器,其中包含一組服務,但僅在實際使用時才實例化它們。考慮以下程式碼
1 2 3 4
// ...
$handler = $this->locator->get($commandClass);
return $handler->handle($command);
在這個範例中,只有在呼叫 $this->locator->get($commandClass)
方法時,才會實例化 $handler
服務。
您也可以使用 ServiceCollectionInterface 而不是 Psr\Container\ContainerInterface
來進行服務定位器引數的類型提示。這樣做,您將能夠計算和迭代定位器的服務
1 2 3 4 5 6 7 8
// ...
$numberOfHandlers = count($this->locator);
$nameOfHandlers = array_keys($this->locator->getProvidedServices());
// you can iterate through all services of the locator
foreach ($this->locator as $serviceId => $service) {
// do something with the service, the service id or both
}
7.1
ServiceCollectionInterface 在 Symfony 7.1 中引入。
包含服務
為了向服務訂閱者新增新的依賴項,請使用 getSubscribedServices()
方法來新增要包含在服務定位器中的服務類型
1 2 3 4 5 6 7 8 9
use Psr\Log\LoggerInterface;
public static function getSubscribedServices(): array
{
return [
// ...
LoggerInterface::class,
];
}
服務類型也可以透過服務名稱進行鍵控以供內部使用
1 2 3 4 5 6 7 8 9
use Psr\Log\LoggerInterface;
public static function getSubscribedServices(): array
{
return [
// ...
'logger' => LoggerInterface::class,
];
}
當擴展也實作 ServiceSubscriberInterface
的類別時,您有責任在覆寫方法時呼叫父類別。這通常在擴展 AbstractController
時發生
1 2 3 4 5 6 7 8 9 10 11 12 13
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class MyController extends AbstractController
{
public static function getSubscribedServices(): array
{
return array_merge(parent::getSubscribedServices(), [
// ...
'logger' => LoggerInterface::class,
]);
}
}
可選服務
對於可選的依賴項,在服務類型前面加上 ?
,以防止在服務容器中找不到匹配的服務時發生錯誤
1 2 3 4 5 6 7 8 9
use Psr\Log\LoggerInterface;
public static function getSubscribedServices(): array
{
return [
// ...
'?'.LoggerInterface::class,
];
}
注意
在呼叫服務本身之前,請務必透過在服務定位器上呼叫 has()
來確保可選服務存在。
別名服務
預設情況下,自動裝配用於將服務類型與服務容器中的服務進行匹配。如果您不使用自動裝配,或需要新增非傳統服務作為依賴項,請使用 container.service_subscriber
標籤將服務類型對應到服務。
1 2 3 4 5
# config/services.yaml
services:
App\CommandBus:
tags:
- { name: 'container.service_subscriber', key: 'logger', id: 'monolog.logger.event' }
提示
如果服務名稱在內部與服務容器中的名稱相同,則可以省略 key
屬性。
新增依賴注入屬性
作為在組態中為服務設定別名的替代方案,您也可以直接在 getSubscribedServices()
方法中設定以下依賴注入屬性
這是透過讓 getSubscribedServices()
傳回 SubscribedService 物件陣列(這些可以與標準 string[]
值組合使用)來完成
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
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Service\Attribute\SubscribedService;
public static function getSubscribedServices(): array
{
return [
// ...
new SubscribedService('logger', LoggerInterface::class, attributes: new Autowire(service: 'monolog.logger.event')),
// can event use parameters
new SubscribedService('env', 'string', attributes: new Autowire('%kernel.environment%')),
// Target
new SubscribedService('event.logger', LoggerInterface::class, attributes: new Target('eventLogger')),
// TaggedIterator
new SubscribedService('loggers', 'iterable', attributes: new TaggedIterator('logger.tag')),
// TaggedLocator
new SubscribedService('handlers', ContainerInterface::class, attributes: new TaggedLocator('handler.tag')),
];
}
7.1
TaggedIterator 和 TaggedLocator 屬性在 Symfony 7.1 中已棄用,轉而使用 AutowireIterator 和 AutowireLocator。
注意
上述範例需要使用 symfony/service-contracts
的 3.2
或更新版本。
AutowireLocator 和 AutowireIterator 屬性
定義服務定位器的另一種方法是使用 AutowireLocator 屬性
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
// src/CommandBus.php
namespace App;
use App\CommandHandler\BarHandler;
use App\CommandHandler\FooHandler;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
class CommandBus
{
public function __construct(
#[AutowireLocator([
FooHandler::class,
BarHandler::class,
])]
private ContainerInterface $handlers,
) {
}
public function handle(Command $command): mixed
{
$commandClass = get_class($command);
if ($this->handlers->has($commandClass)) {
$handler = $this->handlers->get($commandClass);
return $handler->handle($command);
}
}
}
就像 getSubscribedServices()
方法一樣,可以透過陣列鍵定義別名服務和可選服務,並且您可以將其與 SubscribedService 屬性巢狀使用
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
// src/CommandBus.php
namespace App;
use App\CommandHandler\BarHandler;
use App\CommandHandler\BazHandler;
use App\CommandHandler\FooHandler;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Contracts\Service\Attribute\SubscribedService;
class CommandBus
{
public function __construct(
#[AutowireLocator([
'foo' => FooHandler::class,
'bar' => new SubscribedService(type: 'string', attributes: new Autowire('%some.parameter%')),
'optionalBaz' => '?'.BazHandler::class,
])]
private ContainerInterface $handlers,
) {
}
public function handle(Command $command): mixed
{
$fooHandler = $this->handlers->get('foo');
// ...
}
}
注意
若要接收可迭代物件而不是服務定位器,您可以將 AutowireLocator 屬性切換為 AutowireIterator 屬性。
定義服務定位器
若要手動定義服務定位器並將其注入到另一個服務,請建立 service_locator
類型的引數。
考慮以下 CommandBus
類別,您想要透過服務定位器將某些服務注入其中
1 2 3 4 5 6 7 8 9 10 11 12
// src/CommandBus.php
namespace App;
use Psr\Container\ContainerInterface;
class CommandBus
{
public function __construct(
private ContainerInterface $locator,
) {
}
}
Symfony 允許您使用 YAML/XML/PHP 組態或直接透過 PHP 屬性注入服務定位器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// src/CommandBus.php
namespace App;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
class CommandBus
{
public function __construct(
// creates a service locator with all the services tagged with 'app.handler'
#[AutowireLocator('app.handler')]
private ContainerInterface $locator,
) {
}
}
如先前章節所示,CommandBus
類別的建構子必須使用 ContainerInterface
進行類型提示。然後,您可以透過其 ID 取得任何服務定位器服務(例如 $this->locator->get('App\FooCommand')
)。
在多個服務中重複使用服務定位器
如果您在多個服務中注入相同的服務定位器,最好將服務定位器定義為獨立服務,然後將其注入到其他服務中。若要這樣做,請使用 ServiceLocator
類別建立新的服務定義
1 2 3 4 5 6 7 8 9 10 11
# config/services.yaml
services:
app.command_handler_locator:
class: Symfony\Component\DependencyInjection\ServiceLocator
arguments:
-
App\FooCommand: '@app.command_handler.foo'
App\BarCommand: '@app.command_handler.bar'
# if you are not using the default service autoconfiguration,
# add the following tag to the service definition:
# tags: ['container.service_locator']
注意
在服務定位器引數中定義的服務必須包含鍵,這些鍵稍後將成為它們在定位器內的唯一識別碼。
現在您可以將服務定位器注入到任何其他服務中
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/CommandBus.php
namespace App;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class CommandBus
{
public function __construct(
#[Autowire(service: 'app.command_handler_locator')]
private ContainerInterface $locator,
) {
}
}
在編譯器傳遞中使用服務定位器
在 編譯器傳遞 中,建議使用 register() 方法來建立服務定位器。這將為您節省一些樣板程式碼,並在所有參考它們的服務之間共享相同的定位器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
public function process(ContainerBuilder $container): void
{
// ...
$locateableServices = [
// ...
'logger' => new Reference('logger'),
];
$myService = $container->findDefinition(MyService::class);
$myService->addArgument(ServiceLocatorTagPass::register($container, $locateableServices));
}
為服務集合建立索引
預設情況下,傳遞給服務定位器的服務會使用其服務 ID 建立索引。您可以使用標籤定位器的兩個選項(index_by
和 default_index_method
)來變更此行為,這兩個選項可以獨立使用或組合使用。
index_by
/ indexAttribute
選項
此選項定義選項/屬性的名稱,該選項/屬性儲存用於為服務建立索引的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/CommandBus.php
namespace App;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
class CommandBus
{
public function __construct(
#[AutowireLocator('app.handler', indexAttribute: 'key')]
private ContainerInterface $locator,
) {
}
}
在這個範例中,index_by
選項是 key
。所有服務都定義了該選項/屬性,因此這將是用於為服務建立索引的值。例如,若要取得 App\Handler\Two
服務
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// src/Handler/HandlerCollection.php
namespace App\Handler;
use Psr\Container\ContainerInterface;
class HandlerCollection
{
public function getHandlerTwo(ContainerInterface $locator): mixed
{
// this value is defined in the `key` option of the service
return $locator->get('handler_two');
}
// ...
}
如果某些服務未定義在 index_by
中設定的選項/屬性,Symfony 會套用此回退程序
- 如果服務類別定義了一個名為
getDefault<CamelCase index_by value>Name
的靜態方法(在本範例中為getDefaultKeyName()
),則呼叫它並使用傳回的值; - 否則,回退到預設行為並使用服務 ID。
default_index_method
選項
此選項定義服務類別方法的名稱,將呼叫該方法以取得用於為服務建立索引的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/CommandBus.php
namespace App;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
class CommandBus
{
public function __construct(
#[AutowireLocator('app.handler', defaultIndexMethod: 'getLocatorKey')]
private ContainerInterface $locator,
) {
}
}
如果某些服務類別未定義在 default_index_method
中設定的方法,Symfony 將回退為使用服務 ID 作為其在定位器內的索引。
組合 index_by
和 default_index_method
選項
您可以在同一個定位器中組合這兩個選項。Symfony 將按以下順序處理它們
- 如果服務定義了在
index_by
中設定的選項/屬性,則使用它; - 如果服務類別定義了在
default_index_method
中設定的方法,則使用它; - 否則,回退為使用服務 ID 作為其在定位器內的索引。
服務訂閱者 Trait
ServiceMethodsSubscriberTrait 為 ServiceSubscriberInterface 提供了一個實作,它會查找您的類別中所有標記有 SubscribedService 屬性的方法。它根據每個方法的傳回類型描述類別所需的服務。服務 ID 為 __METHOD__
。這允許您根據類型提示的輔助方法將依賴項新增至您的服務
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
// src/Service/MyService.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
class MyService implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
public function doSomething(): void
{
// $this->router() ...
// $this->logger() ...
}
#[SubscribedService]
private function router(): RouterInterface
{
return $this->container->get(__METHOD__);
}
#[SubscribedService]
private function logger(): LoggerInterface
{
return $this->container->get(__METHOD__);
}
}
7.1
ServiceMethodsSubscriberTrait
在 Symfony 7.1 中引入。在先前的 Symfony 版本中,它被稱為 ServiceSubscriberTrait
。
這允許您建立 RouterAware、LoggerAware 等輔助 trait,並使用它們組合您的服務
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 35 36 37 38 39 40 41 42 43 44 45 46
// src/Service/LoggerAware.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
trait LoggerAware
{
#[SubscribedService]
private function logger(): LoggerInterface
{
return $this->container->get(__CLASS__.'::'.__FUNCTION__);
}
}
// src/Service/RouterAware.php
namespace App\Service;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
trait RouterAware
{
#[SubscribedService]
private function router(): RouterInterface
{
return $this->container->get(__CLASS__.'::'.__FUNCTION__);
}
}
// src/Service/MyService.php
namespace App\Service;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
class MyService implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait, LoggerAware, RouterAware;
public function doSomething(): void
{
// $this->router() ...
// $this->logger() ...
}
}
警告
在建立這些輔助 trait 時,服務 ID 不能是 __METHOD__
,因為這將包含 trait 名稱,而不是類別名稱。相反,請使用 __CLASS__.'::'.__FUNCTION__
作為服務 ID。
SubscribedService
屬性
您可以使用 SubscribedService
的 attributes
引數來新增以下任何依賴注入屬性
這是一個範例
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 35 36 37 38 39 40
// src/Service/MyService.php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
class MyService implements ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
public function doSomething(): void
{
// $this->environment() ...
// $this->router() ...
// $this->logger() ...
}
#[SubscribedService(attributes: new Autowire('%kernel.environment%'))]
private function environment(): string
{
return $this->container->get(__METHOD__);
}
#[SubscribedService(attributes: new Autowire(service: 'router'))]
private function router(): RouterInterface
{
return $this->container->get(__METHOD__);
}
#[SubscribedService(attributes: new Target('requestLogger'))]
private function logger(): LoggerInterface
{
return $this->container->get(__METHOD__);
}
}
注意
上述範例需要使用 symfony/service-contracts
的 3.2
或更新版本。
測試服務訂閱者
若要單元測試服務訂閱者,您可以建立一個假的容器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
use Symfony\Contracts\Service\ServiceLocatorTrait;
use Symfony\Contracts\Service\ServiceProviderInterface;
// Create the fake services
$foo = new stdClass();
$bar = new stdClass();
$bar->foo = $foo;
// Create the fake container
$container = new class([
'foo' => fn () => $foo,
'bar' => fn () => $bar,
]) implements ServiceProviderInterface {
use ServiceLocatorTrait;
};
// Create the service subscriber
$serviceSubscriber = new MyService($container);
// ...
注意
當像這樣定義服務定位器時,請注意您的容器的 getProvidedServices() 將使用閉包的傳回類型作為傳回陣列的值。如果未定義傳回類型,則值將為 ?
。如果您希望值反映服務的類別,則必須在閉包上設定傳回類型。
另一種替代方法是使用 PHPUnit
模擬它
1 2 3 4 5 6 7 8 9 10 11 12 13
use Psr\Container\ContainerInterface;
$container = $this->createMock(ContainerInterface::class);
$container->expects(self::any())
->method('get')
->willReturnMap([
['foo', $this->createStub(Foo::class)],
['bar', $this->createStub(Bar::class)],
])
;
$serviceSubscriber = new MyService($container);
// ...