Подписчики и локаторы сервисов
Дата обновления перевода 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);
// ...