跳到内容

服务订阅者 & 定位器

编辑此页

有时,一个服务需要访问其他多个服务,但不确定所有这些服务是否都会实际被使用。在这些情况下,您可能希望服务的实例化是延迟加载的。然而,这在使用显式依赖注入时是不可能的,因为并非所有服务都旨在成为延迟加载的(参见 延迟服务)。

另请参阅

另一种延迟注入服务的方式是通过 服务闭包

这通常发生在你的控制器中,你可能在构造函数中注入了多个服务,但调用的 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_bydefault_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 将应用以下回退过程

  1. 如果服务类定义了一个名为 getDefault<CamelCase index_by value>Name 的静态方法(在本例中为 getDefaultKeyName()),则调用它并使用返回的值;
  2. 否则,回退到默认行为并使用服务 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_bydefault_index_method 选项

您可以在同一定位器中组合这两个选项。Symfony 将按以下顺序处理它们

  1. 如果服务定义了在 index_by 中配置的选项/属性,则使用它;
  2. 如果服务类定义了在 default_index_method 中配置的方法,则使用它;
  3. 否则,回退到使用服务 ID 作为其在定位器内的索引。

服务订阅者 Trait

ServiceMethodsSubscriberTraitServiceSubscriberInterface 提供了一个实现,该实现会查找类中所有标有 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 属性

您可以使用 SubscribedServiceattributes 参数来添加以下任何依赖注入属性

这是一个例子

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);
// ...
本作品,包括代码示例,根据 Creative Commons BY-SA 3.0 许可协议获得许可。
目录
    版本