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

Дата обновления перевода 2024-07-27

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

Иногда, сервису необходим доступ к нескольким другим сервисам, не имея гарантий того, что они действительно будут использованы. В таких случаях, вам может захотеться ленивого запуска сервисов. Однако, это невозможно с использованием ясного внедрения зависимости, так как сервисы вообще не должны быть 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 = get_class($command);

        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\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')),

        // может ли событие использовать параметры
        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')),
    ];
}

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

        // ...
    }
}

Note

Чтобы получить итерируемое значение вместо локатора сервиса, вы можете поменять атрибут 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\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() ...
    }
}

Caution

При создании этих черт помощников, 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;

// 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);
// ...

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);
// ...