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

Дата оновлення перекладу 2022-12-23

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

Іноді сервісу необхідний доступ до декількох інших сервісів, не маючи гарантій того, що вони дійсно будуть використані. У таких випадках, вам може захотітися лінивого запуску сервісів. Однак це неможливо з використанням чіткого впровадження залежності, так як сервіси взагалі не повинні бути 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,
    ];
}

Типи сервісів також можуть мати імʼя сервісу для внутрішнього використання:

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