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

Дата обновления перевода 2023-01-11

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

Иногда, сервису необходим доступ к нескольким другим сервисам, не имея гарантий того, что они действительно будут использованы. В таких случаях, вам может захотеться ленивого запуска сервисов. Однако, это невозможно с использованием ясного внедрения зависимости, так как сервисы вообще не должны быть 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
29
30
// src/CommandBus.php
namespace App;

// ...
class CommandBus
{
    /**
     * @var CommandHandler[]
     */
    private $handlerMap;

    public function __construct(array $handlerMap)
    {
        $this->handlerMap = $handlerMap;
    }

    public function handle(Command $command)
    {
        $commandClass = get_class($command);

        if (!isset($this->handlerMap[$commandClass])) {
            return;
        }

        return $this->handlerMap[$commandClass]->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
35
36
// 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
{
    private $locator;

    public function __construct(ContainerInterface $locator)
    {
        $this->locator = $locator;
    }

    public static function getSubscribedServices(): array
    {
        return [
            'App\FooCommand' => FooHandler::class,
            'App\BarCommand' => BarHandler::class,
        ];
    }

    public function handle(Command $command)
    {
        $commandClass = get_class($command);

        if ($this->locator->has($commandClass)) {
            $handler = $this->locator->get($commandClass);

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

Tip

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

Внедренный сервис является экземпляром ServiceLocator, который реализует PSR-11 ContainerInterface, но также является вызываемым:

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

return $handler->handle($command);

Добавление сервисов

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

  • YAML
  • XML
  • PHP
1
2
3
4
5
# config/services.yaml
services:
    App\CommandBus:
        tags:
            - { name: 'container.service_subscriber', key: 'logger', id: 'monolog.logger.event' }

Tip

Атрибут key может быть опущен, если внутренне имя сервиса совпадает с именем в сервис-контейнере.

Добавление атрибутов внедрения зависимости

6.2

Возможность добавлять атрибуты была представлена в Symfony 6.2.

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

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

Чтобы вручную определеить локатор сервисов, и внедрить его в другой сервис, создайте аргумент типа service_locator:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
# config/services.yaml
services:
    App\CommandBus:
        arguments: !service_locator
            App\FooCommand: '@app.command_handler.foo'
            App\BarCommand: '@app.command_handler.bar'

Как показано в предыдущих разделах, конструктор класса CommandBus должен типизировать свой аргумент с помощью ContainerInterface. Затем, вы можете получить любой из сервисов локатора сервисов через его ID (например, $this->locator->get('App\FooCommand')).

Повторное использование локатора сервисов в нескольких сервисах

Если вы внедряете один и тот же локатор сервисов в несколько сервисов, лучше определять локатор сервисов как отдельный сервис, а затем внедрять его в другие сервисы. Чтобы сделать это, создайте новое определение сервиса, используя класс ServiceLocator:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 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']

    # если элемент не имеет ключа, используется ID изначального сервиса
    app.another_command_handler_locator:
        class: Symfony\Component\DependencyInjection\ServiceLocator
        arguments:
            -
                - '@app.command_handler.baz'

Note

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

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

  • YAML
  • XML
  • PHP
1
2
3
4
# config/services.yaml
services:
    App\CommandBus:
        arguments: ['@app.command_handler_locator']

Использование локаторов сервисов в пропусках компилятора

В передачах компилятора рекомендуется использовать метод register() для создания локаторов сервисов. Это создаст вам некий шаблон, и будет иметь идентичные локаторы среди всех сервисов, ссылающихся на них:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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->addArgument(ServiceLocatorTagPass::register($container, $locateableServices));
}

Индексирование коллекции сервисов

Сервисы, передающиеся локатору сервисов, могут определять собственный индекс, используя произвольный атрибут, имя которого определятся в сервис-контейнере как index_by.

В следующем примере, локатор App\Handler\HandlerCollection получает все сервисы с тегом app.handler, и они индексируются, используя значение атрибута тега key (как определено в опции локатора index_by):

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
# config/services.yaml
services:
    App\Handler\One:
        tags:
            - { name: 'app.handler', key: 'handler_one' }

    App\Handler\Two:
        tags:
            - { name: 'app.handler', key: 'handler_two' }

    App\Handler\HandlerCollection:
        # внедрить все сервисы с тегом app.handler в качестве первого аргумента
        arguments: [!tagged_locator { tag: 'app.handler', index_by: 'key' }]

Внутри этого локатора, вы можете извлечь сервисы по индексу, используя значение атрибута key. Например, чтобы получить сервис App\Handler\Two:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/Handler/HandlerCollection.php
namespace App\Handler;

use Symfony\Component\DependencyInjection\ServiceLocator;

class HandlerCollection
{
    public function __construct(ServiceLocator $locator)
    {
        $handlerTwo = $locator->get('handler_two');
    }

    // ...
}

Вместо определения индекса в определении сервиса, вы можете вернуть его значение в методе под названием getDefaultIndexName() внутри класса, ассоциированного с сервисом:

1
2
3
4
5
6
7
8
9
10
11
12
// src/Handler/One.php
namespace App\Handler;

class One
{
    public static function getDefaultIndexName(): string
    {
        return 'handler_one';
    }

    // ...
}

Если вы хотите использоваь другое имя метода, добавьте атрибут default_index_method к локатору сервисов, определяя имя его пользовательского метода:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
# config/services.yaml
services:
    # ...

    App\HandlerCollection:
        arguments: [!tagged_locator { tag: 'app.handler', index_by: 'key', default_index_method: 'myOwnMethodName' }]

Note

Так как код не должен отвечать за определение того, как будут использованы локаторы, ключ конфигурации (key - в примере выше) должен быть установлен так, чтобы пользовательский метод мог вызываться в качестве резервного.

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

ServiceSubscriberTrait предоставляет реализацию для 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\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

class MyService implements ServiceSubscriberInterface
{
    use ServiceSubscriberTrait;

    public function doSomething()
    {
        // $this->router() ...
        // $this->logger() ...
    }

    #[SubscribedService]
    private function router(): RouterInterface
    {
        return $this->container->get(__METHOD__);
    }

    #[SubscribedService]
    private function logger(): LoggerInterface
    {
        return $this->container->get(__METHOD__);
    }
}

Это позволяет вам создавать черты помощников, вроде 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\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

class MyService implements ServiceSubscriberInterface
{
    use ServiceSubscriberTrait, LoggerAware, RouterAware;

    public function doSomething()
    {
        // $this->router() ...
        // $this->logger() ...
    }
}

Caution

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

Атрибуты SubscribedService

6.2

Возможность добавлять атрибуты была представлена в Symfony 6.2.

Ви можете использовать аргумент 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\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

class MyService implements ServiceSubscriberInterface
{
    use ServiceSubscriberTrait;

    public function doSomething()
    {
        // $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 или новее.

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

Чтобы модульно тестировать подписчика сервисов, вы можете создать фальшивый ServiceLocator:

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
use Symfony\Component\DependencyInjection\ServiceLocator;

$container = new class() extends ServiceLocator {
    private $services = [];

    public function __construct()
    {
        parent::__construct([
            'foo' => function () {
                return $this->services['foo'] = $this->services['foo'] ?? new stdClass();
            },
            'bar' => function () {
                return $this->services['bar'] = $this->services['bar'] ?? $this->createBar();
            },
        ]);
    }

    private function createBar()
    {
        $bar = new stdClass();
        $bar->foo = $this->get('foo');

        return $bar;
    }
};

$serviceSubscriber = new MyService($container);
// ...

Другой альтернативой яляется его имитация с использованием 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);
// ...