sbuerk/typo3-symfony-di-test

提供作为TYPO3扩展的Symfony DI技术的概念验证实现

安装: 0

依赖者: 0

建议者: 0

安全性: 0

星标: 0

关注者: 1

分支: 0

开放问题: 0

类型:typo3-cms-extension

0.0.2 2024-09-08 00:40 UTC

This package is auto-updated.

Last update: 2024-09-08 00:42:22 UTC


README

序言

这个仓库和包仅仅是一个概念验证实现,并不打算作为一个长期的示例或演示。至少目前不是。因此,这个包目前缺少CGL、代码分析器和测试,并且在任何情况下都不进行维护。

如果您想做出贡献,请考虑这一点,因为实际上这可能是一个浪费时间的行为,因为这个包并不打算进行维护。

然而,尽管讨论这个贡献可能添加(pull-request)以共同合作,以便在手头有可测试的内容来讨论和决定是否可以将包含的样式(s)在TYPO3核心和/或扩展中使用并记录下来。

请注意,这个TYPO3扩展将不会发布在TYPO3扩展仓库中 - 对于非composer安装或单一代码库,请下载它/检出它。然而,它在公共packagist中注册,并且可以在基于composer的安装中使用

composer require --dev sbuerk/typo3-symfony-di-test

介绍

这个包包含一个TYPO3扩展,展示了不同的Symfony DI技术。

使用Symfony服务装饰来扩展服务工厂

使用服务工厂模式根据不同的配置参数创建实例是一种常见的方法。在这些情况下,ServiceFactory在消费者类中(注入的服务工厂)用于检索匹配的服务。

通常的TYPO3核心使用服务工厂的方法强迫扩展作者将DI定义替换为扩展的服务工厂,这只在服务工厂不是最终的或者工厂实现了工厂接口并且该接口在核心代码中用作类型注释时才可行。扩展作者采用了相同的技巧来为自己的扩展使用。

在多个扩展希望影响工厂以根据某些上下文数据检索调整(自定义)服务但仍然保留先前服务工厂的情况下,这是不可能的。

Symfony DI支持服务装饰,这可以用来提供一种更灵活的方法,让扩展作者根据上下文数据影响服务创建。这个包包含了这个技术的演示。

场景介绍

对于演示场景,我们假设需要一种根据默认服务创建基于字符串值作为上下文数据的专业化服务的方法。

这意味着,我们需要一个ServiceInterface,它可以在方法和方法返回类型中使用PHP类型声明,并确保存在所需的方法。服务本身应该能够检索自动装配的依赖项。

为了能够根据该上下文值创建服务实例,服务工厂是一个实现这一点的良好方式。

final class ServiceFactory {
    public function create(string $context): ServiceInterface
    {
        return match($context) {
          'special' => new SpecialService(),
          default => new DefaultService(),
        };
    }
}

如果服务工厂类是最终的,如上面的例子所示,扩展作者无法扩展服务工厂并在Symfony DI配置中替换(别名)它们。可以使用注册(注入)或标记的服务数组来通过在所有检索到的标记服务上调用方法来替换匹配/上下文确定,以确定要使用并返回的服务。

为了这个场景,如果扩展作者能够简单地添加自定义服务工厂检索先前服务工厂,那么它就可以回退到父工厂

final class ServiceFactory {
    public function __construct(
        private DefaultServiceFactory $serviceFactory,
    ) {}

    public function create(string $context): ServiceInterface
    {
        return match($context) {
          'special' => new SpecialService(),
          default => $this->serviceFactory->create($context),
        };
    }
}

这在一般情况下是可能的,但对于扩展来说几乎是不可能的,因为构造函数中的类型提示。

鉴于我们引入了ServiceFactoryInterface,它仍然是一个配置噩梦,实例(开发人员/维护人员)需要通过多个扩展调整链,这是一种相当糟糕的方法。

结合使用接口与《装饰器模式》,这将更加合适

final class ServiceFactory implements ServiceFactoryInterface  {
    public function __construct(
        private ServiceFactoryInterface $serviceFactory,
    ) {}

    public function create(string $context): ServiceInterface
    {
        return match($context) {
          'special' => new SpecialService(),
          default => $this->serviceFactory->create($context),
        };
    }
}

我们可以提供一个链。如何使用Symfony DI与装饰器模式是这个示例的背景。

Symfony DI服务装饰器模式 - 需要什么?

从TYPO3核心方面(或提供此功能的扩展)需要以下基本部分

  • ServiceInterface:定义所需的方法,并帮助进行类型声明
  • ServiceFactoryInterface:定义带上下文数据的工厂方法,例如 ServiceFactoryInterface->create(string $context): ServiceInterface;
  • DefaultService 实现 ServiceInterface 是默认服务。
  • DefaultServiceFactory 实现 ServiceFactoryInterface 是为 ServiceFactoryInterface 获取的默认服务工厂

上述要求的实现将在下一节中详细解释。

基于此,扩展作者将

  • 实现一个基于 ServiceInterface 的自定义服务 CustomService
  • 实现一个基于 ServiceFactoryInterface 的自定义服务工厂,配置为默认服务工厂实现 DefaultServiceFactory 的装饰器

扩展作者的示例实现也将在稍后解释。

注意,以下默认扩展 Configuration/Services.yaml 片段假定使用Symfony PHP属性进行说明

services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  SBUERK\DiTests\:
    resource: '../Classes/*'

阅读

基础:ServiceInterface

服务接口定义了所有服务需要实现的所需公开方法,为了演示目的,有一个简单的 ping() 方法

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

interface ServiceInterface
{
    public function ping(): string;
}
基础:ServiceFactoryInterface

我们需要确保一个固定的工厂方法,并为装饰服务提供额外的准备,为此提供一个接口是最佳方式

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

interface ServiceFactoryInterface
{
    public function create(string $someData): ServiceInterface;
}

注意,我们不做构造函数部分为接口的一部分,以允许Symfony DI自动注入工厂实现。

基础:DefaultServiceInterface

因为我们想提供一个默认实现或服务,我们实现了一个通用的。我们还使用PHP属性告诉Symfony DI使用此类作为ServiceInterface(默认服务)的实现

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Symfony\Component\DependencyInjection\Attribute\AsAlias;use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\ConnectionPool;

#[AsAlias(id: ServiceInterface::class, public: true)]
final readonly class DefaultService implements ServiceInterface
{
    public function __construct(
        private ConnectionPool $connectionPool,
    ) {}

    public function ping(): string
    {
        return __CLASS__;
    }
}

注意,我们需要将此服务标记为公共的,因为我们稍后将在DefaultServiceFactory中从DI容器中检索它 - 否则,DefaultService 将从DI容器中删除,导致错误。

此外,我们为服务工厂添加了一个别名,这样就可以为接口检索DefaultService,例如在自定义服务实现中(稍后为第二个服务实现查看)。

基础:DefaultServiceFactory

现在,我们实现一个默认服务工厂以检索默认服务,并且如果请求将 ServiceFactoryInterface 自动注入到类中,也是第一个自动注入的类

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(id: ServiceFactoryInterface::class)]
final readonly class DefaultServiceFactory implements ServiceFactoryInterface
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function create(string $someData): ServiceInterface
    {
        return $this->container->get(DefaultService::class);
    }
}

使用 #[AsAlias] 属性用于将此类定义为 ServiceFactoryInterface 的默认工厂。

注意,对于稍后加载的扩展,也可以使用此属性并覆盖 DefaultServiceFactory,如果需要。原始默认值仍然可以通过在构造函数中使用 DefaultServiceFactory 类型进行自动注入。这不是本演示的一部分。

扩展:提供自定义服务 - CustomService

扩展可能希望为特定的上下文值提供自定义服务。为此,它们实现基于 ServiceInterface 的自定义服务

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;

#[Autoconfigure(public: true)]
final readonly class SecondService implements ServiceInterface
{
    public function __construct(
        private ConnectionPool $connectionPool,
        private ResourceFactory $resourceFactory,
    ) {}

    public function ping(): string
    {
        return __CLASS__;
    }
}

请注意,此处需要 public: true 以避免从DI容器中删除 - 否则,工厂无法从DI容器中检索它。

在某些情况下,可能需要检索原始默认服务,这可以通过将其添加到服务构造函数中并通过名称而不是接口来实现

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;

#[Autoconfigure(public: true)]
final readonly class SecondService implements ServiceInterface
{
    public function __construct(
        private ConnectionPool $connectionPool,
        private ResourceFactory $resourceFactory,
        private ServiceInterface $defaultService,
        // ^^ or private DefaultService $defaultService,
    ) {}

    public function ping(): string
    {
        // use public methods from the default service
        return $this->defaultService->ping() , ' -> ' . __CLASS__;
    }
}
扩展:提供自定义服务工厂 SecondServiceFactory

通常在TYPO3社区中较为熟知,我们现在实现一个自定义服务工厂,以检索 DefaultFactory 以便调用它

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;use TYPO3\CMS\Install\FolderStructure\DefaultFactory;

#[AsAlias(id: ServiceFactoryInterface::class)]
final readonly class SecondServiceFactory implements ServiceFactoryInterface
{
    public function __construct(
        private ContainerInterface $container,
        private DefaultFactory $defaultFactory,
    ) {}

    public function create(string $someData): ServiceInterface
    {
        return match ($someData) {
            'second' => $this->container->get(SecondService::class),
            default => $this->defaultFactory->create($someData),
        };
    }
}

该功能替换了在扩展加载之后提供基本实现的扩展中由 composer.json 中的 require/suggest 或在旧模式下的 ext_emconf.php 中的 depends/suggest 控制的 ServiceFactoryInterfaceDefaultFactory

然而,当多个扩展需要这样做时,这种方法无法在链中全部实现。一些扩展可能会开始检查其他扩展,并根据某种方式随意实现,这仍然会给扩展作者带来持续的维护负担和沟通/协调努力。

为了缓解这些问题,扩展作者可以使用装饰器模式来装饰先前的服务工厂,从而简单地构建一个装饰器链。

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;

#[AsDecorator(decorates: ServiceFactoryInterface::class, priority: 0, onInvalid: SymfonyContainerInterface::IGNORE_ON_INVALID_REFERENCE)]
final readonly class SecondServiceFactory implements ServiceFactoryInterface
{
    public function __construct(
        private ContainerInterface $container,
        #[AutowireDecorated]
        private ServiceFactoryInterface $serviceFactory,
    ) {}

    public function create(string $someData): ServiceInterface
    {
        return match ($someData) {
            'second' => $this->container->get(SecondService::class),
            default => $this->serviceFactory->create($someData),
        };
    }
}

我们使用默认服务工厂作为装饰的基础,但请注意,检索到的 $serviceFactory 可能已经是从另一个扩展中装饰过的服务工厂,这允许在创建方法中调用链。

使用 #[AsDecorator] 属性告诉 Symfony DI 使用新的实例装饰当前的 DefaultServiceFactory 实例,无论是初始实例还是已经装饰过的实例。使用接口进行类型声明允许我们使所有装饰服务工厂成为最终的,因为不需要扩展。

#[AutowireDecorated] 标记了构造函数参数,该参数检索先前的服务工厂实例,该实例应由此实现进行装饰。根据选项(onInvalid),可能需要将属性标记为可空的并检查执行。

请注意,在类型提示和配置中使用 AbstractServiceFactory 而不是接口是可能的,所有自定义服务工厂都需要实现,这可能也是一个有效的方法。使用接口不会使抽象实现成为不可能,但扩展作者可以根据需要自由选择使用抽象或不使用抽象,只要他们实现了所有必需的接口方法。

其他扩展可以添加额外的服务工厂/工厂

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;

#[AsDecorator(decorates: DefaultServiceFactory::class, priority: 1, onInvalid: SymfonyContainerInterface::IGNORE_ON_INVALID_REFERENCE)]
final readonly class SomeServiceFactory implements ServiceFactoryInterface
{
    public function __construct(
        private ContainerInterface $container,
        #[AutowireDecorated]
        private ServiceFactoryInterface $serviceFactory,
    ) {}

    public function create(string $someData): ServiceInterface
    {
        return match ($someData) {
            'some' => $this->container->get(SomeService::class),
            'third' => $this->container->get(ThirdService::class),
            default => $this->serviceFactory->create($someData),
        };
    }
}
BASE:具有注入服务工厂的演示 CLI 命令

现在让我们实现一个 CLI 命令,该命令期望将自动注入的 ServiceFactoryInterface 作为构造函数参数,并使用多个上下文值调用 ServiceFactoryInterface->create(),并返回 ServiceInterface->ping() 方法的输出结果。

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Command;

use SBUERK\DiTests\Services\ServiceFactoryInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'di:test')]
final class TestCommand extends Command
{
    public function __construct(
        private ServiceFactoryInterface $serviceFactory,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln('');

        $output->writeln(sprintf('ServiceFactoryInterface implemetnation: %s', $this->serviceFactory::class));
        $output->writeln('');

        $someFactory = $this->serviceFactory->create('some');
        $output->writeln(sprintf('Service class for context "some": %s', $someFactory::class));
        $output->writeln('Service->ping() result: ' . $someFactory->ping());
        $output->writeln('');

        $secondService = $this->serviceFactory->create('second');
        $output->writeln(sprintf('Service class for context "second": %s', $secondService::class));
        $output->writeln('Service->ping() result: ' . $secondService->ping());
        $output->writeln('');

        return Command::SUCCESS;
    }
}

如果我们执行此命令,我们会得到以下输出

$ bin/typo3 di:test

ServiceFactoryInterface implemetnation: SBUERK\DiTests\Services\SecondServiceFactory

Service class for context "some": SBUERK\DiTests\Services\DefaultService
Service->ping() result: SBUERK\DiTests\Services\DefaultService

Service class for context "second": SBUERK\DiTests\Services\SecondService
Service->ping() result: SBUERK\DiTests\Services\DefaultService => SBUERK\DiTests\Services\SecondService

与其他技术的结合

此模式可以与 Symfony DI 服务的 Lazy 实例化功能混合使用。

例如,可以将 SecondService 注入到 SecondServiceFactory 中,并使用自动注入属性的延迟选项。服务工厂将类似于以下内容

<?php

declare(strict_types=1);

namespace SBUERK\DiTests\Services;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyContainerInterface;

#[AsDecorator(decorates: ServiceFactoryInterface::class, priority: 0, onInvalid: SymfonyContainerInterface::IGNORE_ON_INVALID_REFERENCE)]
final readonly class SecondServiceFactory implements ServiceFactoryInterface
{
    public function __construct(
        #[Autowire(service: SecondService::class, lazy: true)]
        private ServiceInterface $secondService,
        #[AutowireDecorated]
        private ServiceFactoryInterface $serviceFactory,
    ) {}

    public function create(string $someData): ServiceInterface
    {
        return match ($someData) {
            'second' => $this->secondService,
            default => $this->serviceFactory->create($someData),
        };
    }
}

这与先前的实现兼容,但将改变先前提到的测试命令的输出

$ bin/typo3 di:test

ServiceFactoryInterface implemetnation: SBUERK\DiTests\Services\SecondServiceFactory

Service class for context "some": SBUERK\DiTests\Services\DefaultService
Service->ping() result: SBUERK\DiTests\Services\DefaultService

Service class for context "second": ServiceInterfaceProxyD7aab2d
Service->ping() result: SBUERK\DiTests\Services\DefaultService => SBUERK\DiTests\Services\SecondService

一个小但重要的提示是第二个服务的服务类,现在是一个自动创建的服务代理,命名为 ServiceInterfaceProxyD7aab2d,当通过调用方法并转发来请求它时,返回 SecondService 实例。延迟代理是一个智能且隐藏的装饰器变体。

好处是,服务不再需要标记为公共,并且不需要将容器传递给工厂。

结论 / Fazit

在我看来,使用装饰器服务(工厂)模式和延迟服务注入,即使是对于默认服务工厂,对于新的实现来说都是一个好方法,允许 TYPO3 核心标记工厂和服务为最终版本,同时保持可扩展性和可测试性。

仍然开放考虑/验证

[AsDecorator()] 属性允许定义优先级。使用优先级的利弊有待调查。这应该在为服务或服务工厂使用 Decorator 模式之前完成,以便为 TYPO3 文档提供适当的材料和论据。