跳到內容

服務訂閱者 & 定位器

編輯此頁

有時,一個服務需要存取數個其他服務,但不確定是否會實際使用所有這些服務。在這些情況下,您可能會希望延遲服務的實例化。然而,使用顯式的依賴注入是無法實現的,因為並非所有服務都旨在 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
// 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());

考慮到一次只處理一個命令,實例化所有其他命令處理器是不必要的。延遲載入處理器的一個可能的解決方案是注入主要的依賴注入容器。

然而,不鼓勵注入整個容器,因為它給予對現有服務過於廣泛的存取權限,並且它隱藏了服務的實際依賴關係。這樣做也需要將服務設為 public,這在 Symfony 應用程式中預設情況下並非如此。

服務訂閱者 旨在透過提供對一組預定義服務的存取權限來解決此問題,同時僅在實際需要時透過 服務定位器(一個單獨的延遲載入容器)來實例化它們。

定義服務訂閱者

首先,將 CommandBus 轉換為 ServiceSubscriberInterface 的實作。使用其 getSubscribedServices() 方法,在服務訂閱者中包含盡可能多的所需服務

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);
        }
    }
}

提示

如果容器包含訂閱的服務,請仔細檢查您是否已啟用 autoconfigure。您也可以手動新增 container.service_subscriber 標籤。

服務定位器是一個 PSR-11 容器,其中包含一組服務,但僅在實際使用時才實例化它們。考慮以下程式碼

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

return $handler->handle($command);

在這個範例中,只有在呼叫 $this->locator->get($commandClass) 方法時,才會實例化 $handler 服務。

您也可以使用 ServiceCollectionInterface 而不是 Psr\Container\ContainerInterface 來進行服務定位器引數的類型提示。這樣做,您將能夠計算和迭代定位器的服務

1
2
3
4
5
6
7
8
// ...
$numberOfHandlers = count($this->locator);
$nameOfHandlers = array_keys($this->locator->getProvidedServices());

// you can iterate through all services of the locator
foreach ($this->locator as $serviceId => $service) {
    // do something with the service, the service id or both
}

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,
    ];
}

注意

在呼叫服務本身之前,請務必透過在服務定位器上呼叫 has() 來確保可選服務存在。

別名服務

預設情況下,自動裝配用於將服務類型與服務容器中的服務進行匹配。如果您不使用自動裝配,或需要新增非傳統服務作為依賴項,請使用 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' }

提示

如果服務名稱在內部與服務容器中的名稱相同,則可以省略 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')),

        // can event use parameters
        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')),
    ];
}

7.1

TaggedIteratorTaggedLocator 屬性在 Symfony 7.1 中已棄用,轉而使用 AutowireIteratorAutowireLocator

注意

上述範例需要使用 symfony/service-contracts3.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');

        // ...
    }
}

注意

若要接收可迭代物件而不是服務定位器,您可以將 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\AutowireLocator;

class CommandBus
{
    public function __construct(
        // creates a service locator with all the services tagged with 'app.handler'
        #[AutowireLocator('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'
        # if you are not using the default service autoconfiguration,
        # add the following tag to the service definition:
        # tags: ['container.service_locator']

注意

在服務定位器引數中定義的服務必須包含鍵,這些鍵稍後將成為它們在定位器內的唯一識別碼。

現在您可以將服務定位器注入到任何其他服務中

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_bydefault_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\AutowireLocator;

class CommandBus
{
    public function __construct(
        #[AutowireLocator('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
    {
        // this value is defined in the `key` option of the service
        return $locator->get('handler_two');
    }

    // ...
}

如果某些服務未定義在 index_by 中設定的選項/屬性,Symfony 會套用此回退程序

  1. 如果服務類別定義了一個名為 getDefault<CamelCase index_by value>Name 的靜態方法(在本範例中為 getDefaultKeyName()),則呼叫它並使用傳回的值;
  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\AutowireLocator;

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

如果某些服務類別未定義在 default_index_method 中設定的方法,Symfony 將回退為使用服務 ID 作為其在定位器內的索引。

組合 index_bydefault_index_method 選項

您可以在同一個定位器中組合這兩個選項。Symfony 將按以下順序處理它們

  1. 如果服務定義了在 index_by 中設定的選項/屬性,則使用它;
  2. 如果服務類別定義了在 default_index_method 中設定的方法,則使用它;
  3. 否則,回退為使用服務 ID 作為其在定位器內的索引。

服務訂閱者 Trait

ServiceMethodsSubscriberTraitServiceSubscriberInterface 提供了一個實作,它會查找您的類別中所有標記有 SubscribedService 屬性的方法。它根據每個方法的傳回類型描述類別所需的服務。服務 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 等輔助 trait,並使用它們組合您的服務

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() ...
    }
}

警告

在建立這些輔助 trait 時,服務 ID 不能是 __METHOD__,因為這將包含 trait 名稱,而不是類別名稱。相反,請使用 __CLASS__.'::'.__FUNCTION__ 作為服務 ID。

SubscribedService 屬性

您可以使用 SubscribedServiceattributes 引數來新增以下任何依賴注入屬性

這是一個範例

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__);
    }
}

注意

上述範例需要使用 symfony/service-contracts3.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);
// ...

注意

當像這樣定義服務定位器時,請注意您的容器的 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);
// ...
本作品,包括程式碼範例,依據 Creative Commons BY-SA 3.0 授權條款授權。
TOC
    版本