服务订阅者 & 定位器
有时,一个服务需要访问其他多个服务,但不确定所有这些服务是否都会实际被使用。在这些情况下,您可能希望服务的实例化是延迟加载的。然而,这在使用显式依赖注入时是不可能的,因为并非所有服务都旨在成为延迟加载的(参见 延迟服务)。
另请参阅
另一种延迟注入服务的方式是通过 服务闭包。
这通常发生在你的控制器中,你可能在构造函数中注入了多个服务,但调用的 action 只使用了其中的一部分。另一个例子是应用程序实现了 命令模式,使用 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());
考虑到一次只处理一个命令,实例化所有其他命令处理器是不必要的。延迟加载处理程序的一个可能的解决方案是注入主依赖注入容器。
然而,不鼓励注入整个容器,因为它会提供对现有服务的过于广泛的访问权限,并且会隐藏服务的实际依赖关系。这样做还需要将服务设为公开,这在 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);
}
}
}
提示
如果容器不包含订阅的服务,请仔细检查您是否启用了 自动配置。您也可以手动添加 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,
) {
}
}
在编译器 Pass 中使用服务定位器
在 编译器 pass 中,建议使用 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);
// ...