Подписчики и локаторы сервисов
Дата обновления перевода 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 применяет этот резервный процесс:
- Если класс сервиса определяет статический метод с именем
getDefault<CamelCase index_by value>Name
(в данном примереgetDefaultKeyName()
), вызовите его и используйте возвращенное значение; - В противном случае вернитесь к поведению по умолчанию и используйте идентификатор сервиса.
Опция 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 будет обрабатывать их в следующем порядке:
- Если сервис определяет опцию/атрибут, сконфигурированный в
index_by
, используйте его; - Если класс сервиса определяет метод, сконфигурированный в
default_index_method
, используйте его; - В противном случае вернитесь к использованию идентификатора сервиса в качестве его индекса в локаторе.
Черта подписчика сервисов
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);
// ...