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

Дата оновлення перекладу 2024-06-04

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

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

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

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
// 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')] ServiceLocator $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 (у цьому прикладі - getDefault<CamelCase index_by value>Name), викличте його і використовуйте повернуте значення;
  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\TaggedLocator;

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

Якщо якийсь клас сервісу не визначає метод, сконфігурований у default_index_method, Symfony повернеться до використання ID сервісу як індексу всередині локатора.

Поєднання опцій index_by та default_index_method

Ви можете поєднати обидві опції в одному локаторі. Symfony обробить їх у наступному порядку:

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

Риса підписників сервісів

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