Woohoo Labs. Zen DI 容器和预加载文件生成器

3.1.1 2024-08-03 13:25 UTC

README

Latest Version on Packagist Software License Build Status Coverage Status Total Downloads Gitter

Woohoo Labs. Zen 是一个非常快速且易于使用的,PSR-11 (前称 Container-Interop) 兼容的 DI 容器。

目录

介绍

理由

尽管依赖注入是面向对象编程最基本的原则之一,但它并没有得到应有的关注。更糟糕的是,关于这个主题存在许多误解,这可能会阻止人们正确地应用理论。

除了使用服务定位之外,最大的误解无疑是依赖注入需要非常复杂的工具,即 DI 容器。我们都知道,它们的性能低得令人难以置信。Woohoo Labs. Zen 是在2016年意识到这些谬误确实存在之后诞生的。

因此,我尝试创建一个尽可能明确和方便配置的 DI 容器,在提供卓越性能的同时,强制执行依赖注入的正确使用。根据我的基准测试,这就是 Zen 诞生的原因。虽然我最初想象的是一个非常简单的容器,只有基本的功能集,但随着时间的推移,Zen 逐渐具备了目前最流行的 DI 容器的最重要的功能。

幸运的是,自从 Zen 诞生以来,DI 容器生态系统取得了很大的进步:许多容器几乎将性能翻了一番,自动装配和编译变得更加流行,但有一点没有改变:Zen 仍然是速度最快的 PHP 容器之一。

特性

  • PSR-11 (前称 Container-Interop) 兼容
  • 支持编译以获得最大性能
  • 支持构造函数和属性注入
  • 支持作用域的概念(单例和原型)
  • 支持自动装配
  • 支持标量和上下文相关注入
  • 支持开发中的动态使用
  • 支持生成预加载文件

安装

在开始之前,您只需要Composer。然后运行以下命令以获取最新版本

$ composer require woohoolabs/zen

注意:默认情况下,不会下载测试和示例。如果您需要它们,请使用composer require woohoolabs/zen --prefer-source或克隆仓库。

Zen 3 至少需要 PHP 8.0,但您可以使用 2.8.0 用于 PHP 7.4,Zen 2.7.2 用于 PHP 7.1+。

基本用法

使用容器

作为 PSR-11 兼容容器,Zen 支持由ContainerInterface定义的$container->has()$container->get()方法。

注入类型

Zen 仅支持对象和标量的构造函数和属性注入。

为了使用构造函数注入,您必须声明参数的类型或为它们添加一个 @param PHPDoc 标签。如果参数有一个默认值,则将注入此值。以下是一个具有有效参数的构造函数示例

/**
 * @param B $b
 */
public function __construct(A $a, $b, $c = true)
{
    // ...
}

为了使用属性注入,您必须使用 #[Inject] 注解您的属性(注意大小写敏感!),并通过类型声明或 @var PHPDoc 标签提供它们的类型,如下所示

#[Inject]
/** @var A */
private $a;

#[Inject]
private B $b;

一般来说,您应该只依赖构造函数注入,因为使用测试双代替您的真实依赖项使单元测试变得容易得多。对于未进行单元测试的类,属性注入可能可以接受。我更喜欢在控制器中使用这种类型的注入,但其他地方则不行。

构建容器

Zen 是一个编译型 DI 容器,这意味着每次您更新类的依赖项时,都必须重新编译容器,以便它反映这些更改。这是编译型容器在开发中的一个主要弱点,但 Zen 也提供了动态容器实现,这在稍后会介绍。

您可以通过从项目根目录运行以下命令来实现编译

$ ./vendor/bin/zen build CONTAINER_PATH COMPILER_CONFIG_CLASS_NAME

请确保在使用命名空间时转义 COMPILER_CONFIG_CLASS_NAME 参数,如下所示

./vendor/bin/zen build /var/www/app/Container/Container.php "App\\Container\\CompilerConfig"

这将生成一个新文件 CONTAINER_PATH(例如:"/var/www/app/Container/Container.php"),可以直接在项目中实例化(假设自动加载已正确设置),默认情况下不需要其他配置。

$container = new Container();

在大型项目中,构建容器时可能会耗尽内存。您可以通过手动设置内存限制来解决这个问题

./vendor/bin/zen --memory-limit="128M" build /var/www/app/Container/Container.php "App\\Container\\CompilerConfig"

除了通过 CLI,您还可以通过 PHP 本身来构建容器

$builder = new FileSystemContainerBuilder(new CompilerConfig(), "/var/www/src/Container/CompiledContainer.php");
$builder->build();

您可以在任何地方生成容器,但请注意,文件系统速度会影响编译的时间消耗以及应用程序的性能。另一方面,将容器放在易于访问的地方会更加方便,因为您偶尔可能需要对其进行调试。

配置编译器

那么 COMPILER_CONFIG_CLASS_NAME 参数呢?这必须是扩展 AbstractCompilerConfig 的类的完全限定名。让我们看看一个例子

class CompilerConfig extends AbstractCompilerConfig
{
    public function getContainerNamespace(): string
    {
        return "App\\Container";
    }

    public function getContainerClassName(): string
    {
        return "Container";
    }

    public function useConstructorInjection(): bool
    {
        return true;
    }

    public function usePropertyInjection(): bool
    {
        return true;
    }

    public function getContainerConfigs(): array
    {
        return [
            new ContainerConfig(),
        ];
    }
}

通过为构建命令提供先前的配置,将生成一个 App\Container\Container 类,编译器将通过类型提示和 PHPDoc 注释以及由注解标记的属性来解析构造函数依赖项。

配置容器

我们到目前为止只提到了如何配置编译器,但还没有谈到容器的配置。这可以通过在编译器的 getContainerConfigs() 方法中返回一个 AbstractContainerConfig 子实例数组来完成。让我们也看看容器配置的例子。

class ContainerConfig extends AbstractContainerConfig
{
    protected function getEntryPoints(): array
    {
        return [
            // Define all classes in a PSR-4 namespace as Entry Points
            Psr4NamespaceEntryPoint::singleton('WoohooLabs\Zen\Examples\Controller'),

            // Define all classes in a directory as Entry Points
            WildcardEntryPoint::singleton(__DIR__ . "/Controller"),

            // Define a class as Entry Point
            ClassEntryPoint::singleton(UserController::class),
        ];
    }

    protected function getDefinitionHints(): array
    {
        return [
            // Bind the Container class to the ContainerInterface (Singleton scope by default)
            ContainerInterface::class => Container::class,

            // Bind the Request class to the RequestInterface (Prototype scope)
            RequestInterface::class => DefinitionHint::prototype(Request::class),

            // Bind the Response class to the ResponseInterface (Singleton scope)
            ResponseInterface::class => DefinitionHint::singleton(Response::class),
        ];
    }

    protected function getWildcardHints(): array
    {
        return [
            // Bind all classes in the specified PSR-4 namespaces to each other based on patterns
            new Psr4WildcardHint(
                'WoohooLabs\Zen\Examples\Domain\*RepositoryInterface',
                'WoohooLabs\Zen\Examples\Infrastructure\Mysql*Repository'
            ),

            // Bind all classes in the specified directories to each other based on patterns
            new WildcardHint(
                __DIR__ . "/Domain",
                'WoohooLabs\Zen\Examples\Domain\*RepositoryInterface',
                'WoohooLabs\Zen\Examples\Infrastructure\Mysql*Repository'
            ),
        ];
    }
}

配置容器包括以下两个方面:定义您的入口点(在 getEntryPoints() 方法中)以及向编译器传递提示(通过 getDefinitionHints()getWildcardHints() 方法)。

入口点

入口点是那些要从 DI 容器直接检索的类(例如,控制器和中间件通常属于此类)。这意味着您只能通过 $container->get() 方法来获取入口点,而不能使用其他方法。

入口点之所以特殊,不仅是因为这个原因,还因为它们的依赖关系在编译阶段自动被发现,从而生成了完整的对象图(这个特性通常称为“自动装配”)。

以下示例显示了一种配置,指示编译器递归地搜索 Controller 目录下的所有类,并发现它们的所有依赖项。请注意,默认情况下仅包含具体类,并且检测是递归进行的。

protected function getEntryPoints(): array
{
    return [
        new WildcardEntryPoint(__DIR__ . "/Controller"),
    ];
}

如果您使用 PSR-4,有一种更方便且性能更高的方法可以一次性定义多个入口点

protected function getEntryPoints(): array
{
    return [
        new Psr4NamespaceEntryPoint('Src\Controller'),
    ];
}

这样,您可以将特定 PSR-4 命名空间中的所有类定义为入口点。请注意,默认情况下仅包含具体类,并且检测是递归进行的。

最后但同样重要的是,您也可以单独定义入口点

protected function getEntryPoints(): array
{
    return [
        new ClassEntryPoint(UserController::class),
    ];
}

提示

提示告诉编译器如何正确解析依赖关系。当您依赖于接口或抽象类时,这可能很有必要,因为它们显然是不可实例化的。使用提示,您可以将实现绑定到您的接口或具体类绑定到您的抽象类。以下示例将 Container 类绑定到 ContainerInterface(实际上,您不需要将这两个类绑定在一起,因为这种配置在编译期间自动设置)。

protected function getDefinitionHints(): array
{
    return [
        ContainerInterface::class => Container::class,
    ];
}

当您想大量绑定类时,可以使用通配符提示。基本上,它们会递归地搜索由第一个参数指定的目录中的所有类,并将与提供的模式匹配的类绑定在一起。以下示例

protected function getWildcardHints(): array
{
    return [
        new WildcardHint(
            __DIR__ . "/Domain",
            'WoohooLabs\Zen\Examples\Domain\*RepositoryInterface',
            'WoohooLabs\Zen\Examples\Infrastructure\Mysql*Repository'
        ),
    ];
}

将绑定

UserRepositoryInterfaceMysqlUserRepository

如果您使用 PSR-4,还有另一种更方便且性能更高的方式来定义上述设置

protected function getWildcardHints(): array
{
    return [
        new Psr4WildcardHint(
            'WoohooLabs\Zen\Examples\Domain\*RepositoryInterface',
            'WoohooLabs\Zen\Examples\Infrastructure\Mysql*Repository'
        ),
    ];
}

这正好与 WildcardHint 做的事情一样。

请注意,当前命名空间检测不是递归的;您只能使用通配符字符在类名部分,而不能在命名空间中(因此 WoohooLabs\Zen\Examples\*\UserRepositoryInterface 是无效的);并且只支持 * 作为通配符字符。

作用域

Zen 可以通过作用域的概念来控制容器条目的生命周期。默认情况下,从容器中检索的所有条目都具有 Singleton 作用域,这意味着它们仅在第一次检索时实例化,并且在后续检索中返回相同的实例。Singleton 作用域适用于无状态对象。

另一方面,Prototype 作用域的容器条目在每次检索时都会实例化,这使得在容器中存储有状态对象成为可能。您可以使用 DefinitionHint::prototype() 构造函数将容器条目提示为 Prototype,如下所示

protected function getDefinitionHints(): array
{
    return [
        ContainerInterface::class => DefinitionHint::prototype(Container::class),
    ];
}

您也可以使用 WildcardHint::prototype() 以相同的方式提示您的通配符提示。

高级用法

标量注入

标量注入使您能够将标量值作为构造函数参数或属性以对象的形式传递给对象。从 v2.5 版本开始,Zen 支持原生的标量注入。您可以使用以下示例中的 提示 来实现此目的

protected function getDefinitionHints(): array
{
    return [
        UserRepositoryInterface::class => DefinitionHint::singleton(MySqlUserRepository::class)
            ->setParameter("mysqlUser", "root")
            ->setParameter("mysqlPassword", "root"),
            ->setParameter("mysqlPort", 3306),
            ->setProperty("mysqlModes", ["ONLY_FULL_GROUP_BY", "STRICT_TRANS_TABLES", "NO_ZERO_IN_DATE"]),
    ];
}

在这里,我们指示 DI 容器将 MySQL 连接细节作为构造函数参数传递给 MySqlUserRepository 类。此外,我们使用数组初始化了 MySqlUserRepository::$mysqlModes 属性。

或者,您可以使用以下技术来模拟标量注入:扩展构造函数参数包含标量类型的类,并在子类的构造函数中通过parent::__construct()提供相关参数。最后,将适当的提示添加到容器配置中,以便使用子类而不是父类。

上下文相关的依赖注入

有时——通常用于大型项目——能够注入相同接口的不同实现作为依赖项可能很有用。在Zen 2.4.0之前,除非您使用一些技巧(例如扩展原始接口并相应地配置容器),否则无法实现此功能。现在,Zen原生支持上下文相关的注入!

想象以下情况

class NewRelicHandler implements LoggerInterface {}

class PhpConsoleHandler implements LoggerInterface {}

class MailHandler implements LoggerInterface {}

class ServiceA
{
    public function __construct(LoggerInterface $logger) {}
}

class ServiceB
{
    public function __construct(LoggerInterface $logger) {}
}

class ServiceC
{
    public function __construct(LoggerInterface $logger) {}
}

如果您想在ServiceA中使用NewRelicHandler,但在ServiceB中使用PhpConsoleHandler,在任何其他类(如ServiceC)中使用MailHandler,那么您必须这样配置定义提示

protected function getDefinitionHints(): array
{
    return [
        LoggerInterface::class => ContextDependentDefinitionHint::create()
            ->setClassContext(
                NewRelicHandler::class,
                [
                    ServiceA::class,
                ]
            ),
            ->setClassContext(
                new DefinitionHint(PhpConsoleHandler::class),
                [
                    ServiceB::class,
                ]
            )
            ->setDefaultClass(MailHandler::class),
    ];
}

上面的代码可以这样读取:当setClassContext()方法的第二个参数中列出的类依赖于指定数组项中的键的类/接口(例如,ServiceA依赖于示例中的LoggerInterface)时,容器将解析第一个参数中的类/定义提示。如果任何其他类依赖于它,则将解析setDefaultClass()方法第一个参数中的类/定义提示

请注意,如果您没有设置默认实现(无论是通过setDefaultClass()方法还是通过构造函数参数),那么如果将接口注入为除在setClassContext()方法调用第二个参数中列出的类之外的任何类的依赖项,则将抛出ContainerException

生成预加载文件

预加载是PHP 7.4中引入的一个特性,用于通过使用专用预加载文件编译PHP文件并将它们加载到启动时的共享内存中来优化性能。

根据初始基准测试,仅预加载“热”文件(最常使用的文件)可以实现最佳加速效果。另一个注意事项是,为了使预加载工作,预加载文件的每个类依赖项(父类、接口、特质、属性类型、参数类型和返回类型)也必须预加载。这意味着,必须有人解决这些依赖关系。这正是Zen可以做到的!

如果您想创建预加载文件,首先,通过添加以下方法配置您的编译器配置

public function getPreloadConfig(): PreloadConfigInterface
{
    return PreloadConfig::create()
        ->setPreloadedClasses(
            [
                Psr4NamespacePreload::create('WoohooLabs\Zen\Examples\Domain'),
                ClassPreload::create('WoohooLabs\Zen\Examples\Utils\AnimalUtil'),
            ]
        )
        ->setPreloadedFiles(
            [
                __DIR__ . "/examples/Utils/UserUtil.php",
            ]
        );
}

此配置表示我们想要预加载以下内容

  • WoohooLabs\Zen\Examples\Domain命名空间中的所有类及其所有依赖项
  • WoohooLabs\Zen\Examples\Utils\AnimalUtil类及其所有依赖项
  • examples/Utils/UserUtil.php文件(在文件的情况下不执行依赖关系解析)

默认情况下,预加载文件中的PHP文件将被绝对引用。但是,如果您为PreoadConfig提供基础路径(无论是通过其构造函数还是通过PreoadConfig::setRelativeBasePath()方法),则文件引用将成为相对的。

为了创建预加载文件,您有两种选择

  1. 与容器一起构建预加载文件
./vendor/bin/zen --preload="/var/www/examples/preload.php" build /var/www/examples/Container.php "WoohooLabs\\Zen\\Examples\\CompilerConfig"

这种方法是,首先创建容器作为/var/www/examples/Container.php,然后创建预加载文件作为/var/www/examples/preload.php

  1. 单独构建预加载文件
./vendor/bin/zen preload /var/www/examples/preload.php "WoohooLabs\\Zen\\Examples\\CompilerConfig"

这种方法是,仅创建预加载文件作为/var/www/examples/Container.php

基于文件的定义

这是另一种由Symfony启发的优化:如果你编译容器中有数百甚至数千个条目,那么将容器的内容分离到不同的文件中可能更好。

启用此功能有两种方式

public function getFileBasedDefinitionConfig(): FileBasedDefinitionConfigInterface
{
    return FileBasedDefinitionConfig::enableGlobally("Definitions");
}

这样,所有定义都将保存在单独的文件中。请注意,示例中第一个参数是定义生成的目录,相对于容器本身。此目录在编译过程中会自动创建和删除,因此请谨慎使用。

  • 选择性地:您可以选择将哪些入口点分离到不同的文件中。
protected function getEntryPoints(): array
{
    return [
        Psr4WildcardEntryPoint::create('Src\Controller')
            ->fileBased(),

        WildcardEntryPoint::create(__DIR__ . "/Controller")
            ->fileBased(),

        ClassEntryPoint::create(Class10::class)
            ->disableFileBased(),
    ];
}

动态容器

在开发过程中,您可能不想每次都重新编译容器。这就是动态容器能帮到您的所在。

$container = new RuntimeContainer(new CompilerConfig());

请注意,动态容器仅适用于开发目的,因为它比编译容器慢得多 - 然而,它仍然比一些最著名的DI容器快。

示例

如果您想了解Zen是如何工作的,请查看示例文件夹,在那里您可以找到一个示例配置(CompilerConfig)。如果您的系统上提供了docker-composemake,则只需运行以下命令来构建容器

make composer-install  # Install the Composer dependencies
make build             # Build the container into the examples/Container.php

版本控制

此库遵循SemVer v2.0.0

变更日志

有关最近更改的更多信息,请参阅CHANGELOG

测试

Woohoo Labs. Zen有一个PHPUnit测试套件。要从项目文件夹中运行测试,请运行以下命令

$ phpunit

此外,您还可以运行docker-compose upmake test来执行测试。

贡献

有关详细信息,请参阅CONTRIBUTING

支持

有关详细信息,请参阅SUPPORT

致谢

许可协议

MIT许可证(MIT)。有关更多信息,请参阅许可证文件