Подписчики и локаторы сервисов

Дата обновления перевода 2025-09-21

Подписчики и локаторы сервисов

Иногда, сервису необходим доступ к нескольким другим сервисам, не имея гарантий того, что они действительно будут использованы. В таких случаях, вам может захотеться ленивого запуска сервисов. Однако, это невозможно с использованием ясного внедрения зависимости, так как сервисы вообще не должны быть lazy (см. Ленивые сервисы).

See also

Другой способ ленивого внедрения сервисов - через замыкание сервиса.

Это может быть типичным для ваших контроллеров, где вы можете захотеть внедрить несколько сервисов в конструктор, но вызываемое действие использует только некоторые из них. Другой пример - приложения, реализующие шаблон Команды, используя 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 = $command::class;

        if (!$handler = $this->handlerMap[$commandClass] ?? null) {
            return;
        }

        return $handler->handle($command);
    }
}

// ...
$commandBus->handle(new FooCommand());

Учитывая, что в один момент времени обрабатывается только одна команда, запуск всех других обработчиков команд неуместен. Возможным решением будет ленивой загрузки обработчиков будет их внедрение в главный контейнер внедрения зависимостей.

Однако, внедрение контейнера целиком не поощрятеся, так как это дает слишком широкий доступ к существующим сервисам, и скрывает реальные зависимости сервисов. Также это требует того, чтобы сервисы были публичными, что по умолчанию не так в приложениях Symfony.

Подписчики сервисов предназначены для того, чтобы решать эту проблему, предоставляя доступ к набору предопределенных сервисов, запуская их только тогда, когда нужно, через Локатор сервисов - отдельный лениво загружаемый контейнер.

Определение подписчика событий

Для начала, преобразуйте CommandBus в реализацию ServiceSubscriberInterface. Используйте его метод getSubscribedServices(), чтобы добавить столько сервисов, сколько необходимо, в подписчик событий, и измените подсказку контейнера на PSR-11 ContainerInterface:

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);
        }
    }
}

Tip

Если контейнер не содержит подписанные сервисы, перепроверьте, чтобы у вас была подключена автоконфигурация . Вы также можете вручную добавить тег container.service_subscriber.

Локатор сервиса - это контейнер PSR-11, который содержит набор сервисов, но инстанцирует их только тогда, когда они действительно используются. Рассмотрим следующий код:

1
2
3
4
// ...
$handler = $this->locator->get($commandClass);

return $handler->handle($command);

В этом примере сервис $handler инстанцируется только при вызове метода $this->locator->get($commandClass).

Вы также можете указать тип аргумента локатора сервиса с помощью ServiceCollectionInterface вместо Psr\Container\ContainerInterface. Таким образом, вы сможете подсчитывать и итерировать сервисы локатора:

1
2
3
4
5
6
7
8
// ...
$numberOfHandlers = count($this->locator);
$nameOfHandlers = array_keys($this->locator->getProvidedServices());

// вы можете итерировать через все сервисы локатора
foreach ($this->locator as $serviceId => $service) {
    // сделать что-то с сервисом, id сервиса, или ними обоими
}

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,
    ];
}

Типы сервисов также могут быть cнабжены именем сервиса для внутреннего использования:

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,
    ];
}

Note

Убедитесь, что дополнительный сервис существует, вызвав has() в локаторе сервиса до вызова самого сервиса.

Cервисы с псевдонимами

По умолчанию, для сопоставления типа сервиса с сервисом из сервис-контейнера используется автомонтирование. Если вы не используете автомонтирование, или вам нужно добавить нетрадиционный сервис в качестве зависимости, используйте тег 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' }

Tip

Атрибут 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\AutowireIterator;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
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')),

        // может ли событие использовать параметры
        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.

Note

Пример выше требует использования версии 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 = $command::class;

        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');

        // ...
    }
}

Note

Чтобы получить итерируемое значение вместо локатора сервиса, вы можете поменять атрибут AutowireLocator на атрибут AutowireIterator.

Атрибут AutowireIterator

Вариант AutowireLocator, который внедряет итерабельное сервисов, которые имеют определенный тег. Это полезно для цикличного прохождения набора тегированных сервисов вместо их индивидуального извлечения.

Например, чтоб собрать все обработчики для разных типов команд, используйте атрибут AutowireIterator и передайте тег, который используется этими сервисами:

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
// src/CommandBus.php
namespace App;

use App\CommandHandler\BarHandler;
use App\CommandHandler\FooHandler;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

class CommandBus
{
    public function __construct(
        #[AutowireIterator('command_handler')]
        private iterable $handlers, // собирает все сервисы с тегом 'command_handler'
    ) {
    }

    public function handle(Command $command): mixed
    {
        foreach ($this->handlers as $handler) {
            if ($handler->supports($command)) {
                return $handler->handle($command);
            }
        }
    }
}

Определение локатора сервисов

Чтобы вручную определить локатор сервисов, и внедрить его в другой сервис, создайте аргумент типа 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\TaggedLocator;

class CommandBus
{
    public function __construct(
        // создает локатор сервиса со всеми сервисами, тегированными с помощью 'app.handler'
        #[TaggedLocator('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'
        # если вы не используете автоконфигурацию сервиса по умолчанию,
        # добавьте следующий тег к определению сервиса:
        # tags: ['container.service_locator']

Note

Сервисы, определенные в аргументе локатора сервисов, должны включать в себя ключи, которые позже становятся их уникальными идентификаторами внутри локатора.

Теперь вы можете внедрить локатор сервисов в любые другие сервисы:

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\TaggedLocator;

class CommandBus
{
    public function __construct(
        #[TaggedLocator('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
    {
        // это значение определяется в опции сервиса `key`
        return $locator->get('handler_two');
    }

    // ...
}

Если какой-то сервис не определяет опцию/атрибут, сконфигурированный в index_by, Symfony применяет этот резервный процесс:

  1. Если класс сервиса определяет статический метод с именем getDefault<CamelCase index_by value>Name (в данном примере getDefaultKeyName()), вызовите его и используйте возвращенное значение;
  2. В противном случае вернитесь к поведению по умолчанию и используйте идентификатор сервиса.

Опция 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\TaggedLocator;

class CommandBus
{
    public function __construct(
        #[TaggedLocator('app.handler', 'defaultIndexMethod: 'getLocatorKey')]
        private ContainerInterface $locator,
    ) {
    }
}

Если какой-то класс сервиса не определяет метод, заданный в default_index_method, Symfony вернется к использованию идентификатора сервиса в качестве индекса в локаторе.

Объединение опций index_by и default_index_method

Вы можете объединить обе опции в одном локаторе. Symfony будет обрабатывать их в следующем порядке:

  1. Если сервис определяет опцию/атрибут, сконфигурированный в index_by, используйте его;
  2. Если класс сервиса определяет метод, сконфигурированный в default_index_method, используйте его;
  3. В противном случае вернитесь к использованию идентификатора сервиса в качестве его индекса в локаторе.

Черта подписчика сервисов

ServiceMethodsSubscriberTrait предоставляет реализацию для ServiceSubscriberInterface, которая просматривает все методы в вашем классе, маркированные атрибутом SubscribedService. Он предоставляет ServiceLocator для сервисов каждого типа возвращаемого значения метода. 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, и др... и компилировать с их помощью ваши сервисы:

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() ...
    }
}

Warning

При создании этих черт помощников, id сервиса не может быть __METHOD__, так как оно будет включать в себя имя черты, а не класса. Вместо этого, используйте в качестве id сервиса __CLASS__.'::'.__FUNCTION__.

Атрибуты SubscribedService

Ви можете использовать аргумент attributes в 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
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__);
    }
}

Note

Пример выше требует использования версии 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;

// Создать фальшивый сервис
$foo = new stdClass();
$bar = new stdClass();
$bar->foo = $foo;

// Создать фальшивый контейнер
$container = new class([
    'foo' => fn () => $foo,
    'bar' => fn () => $bar,
]) implements ServiceProviderInterface {
    use ServiceLocatorTrait;
};

// Создать подписчика сервиса
$serviceSubscriber = new MyService($container);
// ...

Note

При таком определении локатора сервисов следует помнить, что 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);
// ...