agashe / sigmaphp-container

PHP 依赖注入容器

0.1.0 2024-05-21 11:41 UTC

This package is auto-updated.

Last update: 2024-09-22 08:14:16 UTC


README

一个PHP的依赖注入容器。依赖注入是一种强大的设计模式,它可以用来减少耦合、实现控制反转并增强单元测试。

SigmaPHP-Container提供了一组有用的功能,将使您的项目提升到新的水平。通过去除服务之间的耦合,允许您创建更符合SOLID原则的应用程序。

在构造函数中不再需要"new",您只需要在参数名称之前添加类型提示。这样我们就完成了!容器将处理其余的事情。

最后但同样重要的是,服务提供者,这是一种强大的机制,可以为您的应用程序添加可扩展性,使其他开发人员能够为您的应用程序创建插件和扩展,然后轻松地注册它们。

功能

  • 符合 PSR-11
  • 自定义依赖定义
  • 支持构造函数和setter注入
  • 支持工厂进行深度定制
  • 可以使用接口和别名添加依赖
  • 服务提供者以获得更好的代码结构
  • 使用注入调用类中的方法
  • 将依赖注入到闭包中
  • 零配置自动装配!

安装

composer require agashe/sigmaphp-container

文档

目录

基本用法

要开始使用容器,我们首先定义一个新的Container类实例,我们将在这个实例中定义我们的依赖,然后请求实例

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;
use MyApp\MailerService;

$container = new Container();

$container->set(MailerService::class);

if ($container->has(MailerService::class)) {
    $mailerService = $container->get(MailerService::class);
}

在上面的示例中,我们在容器中定义了我们的依赖MyApp\MailerService,然后我们尝试从该依赖定义一个新的实例。

因此,我们与容器交互时使用的三个基本方法是

  • set(string $id, mixed $definition): void
    向容器添加新的定义

  • get(string $id): mixed
    向容器添加新的定义

  • has(string $id): bool
    我们使用此方法来检查容器是否提供了特定的定义。

$id是我们用来请求依赖的关键字,它支持三种主要类型

  • 类路径 MailerService::class
  • 非类路径,如接口、抽象和特质 AuthTrait::classMailerServiceInterface::class
  • 别名,仅是一个普通字符串

对于$definition,可以是任何有效的PHP数据类型或类,甚至是NULL,因此以下所有定义和id都是有效的

$container->set('mailer', MailerService::class);
$container->set(MailerServiceInterface::class, MailerService::class);
$container->set('PI', 3.14);
$container->set('odd_numbers', [1, 3, 5, 7]);
$container->set('my_exception', (new \Exception('whatever...')));
$container->set('_function', fn() => 5+6);

set()方法的例外是,在类路径的情况下,它只能接受$id

// it doesn't make any sense to do this , although it's valid :D
$container->set(MailerService::class, MailerService::class);

// instead we use
$container->set(MailerService::class);

构造函数注入

任何容器中任何依赖注入的主要类型,即通过构造函数进行注入。这意味着在从该类请求新实例时,可以向应用程序中每个类的构造函数注入所需的任何类型的参数。

假设我们在应用程序中有一个UserModel,我们需要从该类创建一个新实例。这个UserModel需要两个依赖项,一个数据库连接和一个邮件服务。

<?php

use MyApp\MailerService;
use MyApp\DbConnection;

class UserModel
{
    private $conn;
    private $mailer;

    public function __construct()
    {
        $this->conn = new DbConnection();
        $this->mailer = new MailerService();
    }
}

这是一个非常直接的类,我们在项目中经常会遇到,我们可以看到这里的耦合度非常高,当然,没有实际的数据库连接和邮件服务,测试是无法进行的 :(

那么让我们看看使用容器如何帮助我们,使这个过程不那么痛苦。

我们需要理解的第一步是依赖注入模式,它严重依赖于类型提示,所以没有类型提示,容器就无法决定应该注入哪个类!

所以请记住,您需要为构造函数参数添加类型提示,以便容器能够注入正确的依赖。

class UserModel
{
    private $conn;
    private $mailer;

    public function __construct(DbConnection $conn, MailerService $mailer) 
    {
        $this->conn = $conn;
        $this->mailer = $mailer;
    }
}

所以,在更新我们的 UserModel 之后,我们可以使用容器来定义实例

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;
use MyApp\MailerService;
use MyApp\DbConnection;
use MyApp\UserModel;

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);
$container->set(UserModel::class);

$user = $container->get(UserModel::class);

正如我们所见,容器为我们的应用程序增加了额外的灵活性,现在我们可以轻松地控制注入的类,如果需要,可以替换它们,并且对于测试,我们可以轻松地创建模拟服务并将它们注入到正在测试的类中。

setter注入

另一种流行的依赖注入类型是setter方法,在这种类型中,类将有一个单独的方法来注入依赖项,而不是在构造函数中定义它。

所以我们可以按照以下方式重写 UserModel

class UserModel
{
    private $conn;
    private $mailer;

    public function __construct()
    {}
    
    public function setDbConnection(DbConnection $conn)
    {
        $this->conn = $conn;
    }
    
    public function setMailerService(MailerService $mailer)
    {
        $this->mailer = $mailer;
    }
}

容器提供了 setMethod 助手。使用此助手定义的任何方法将在容器请求从该类创建新实例时被调用。

setMethod(string $name, array $args = []): void

setMethod 接受两个参数,第一个是 $name,它是setter方法名。第二个是可选参数 $args,一个关联数组,包含setter方法参数的名称和值。

让我们尝试在容器中定义我们的 UserModel

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;
use MyApp\MailerService;
use MyApp\DbConnection;
use MyApp\UserModel;

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);

$container->set(UserModel::class)
    ->setMethod('setDbConnection')
    ->setMethod('setMailerService');

$user = $container->get(UserModel::class);

正如我们所注意到的,由于 setDbConnectionsetMailerService 都定义了它们的参数使用类型提示,因此不需要额外的参数;并且容器将自动解决所需的依赖。

但是假设我们有一些需要原始值的setter。我们可以很容易地使用 $args 参数传递这些参数

// Shape class
class Shape
{
    public function setDimensions($height, $width, $length)
    {/* ... */}
}

// set in the container
$container = new Container();

$container->set(Shape::class)
    ->setMethod('setDimensions', [
        'height' => 10,
        'width'  => 20,
        'length' => 30,
    ]);

而且,作为一个额外的优势点,$args 还可以接受带有类型提示的参数。现在你对setter方法有了更多的控制

$container->set(UserModel::class)
    ->setMethod('setDbConnection', [
        'conn' => function () { 
            return new \PDO(....);
        }
    ])
    ->setMethod('setMailerService', [
        'mailer' => (new MailerService())
    ]);

绑定参数

在构造函数注入和setter方法注入中,我们都看到了容器如何自动使用类型提示解决任何参数。

我们还看到了如何自定义 setMethod 参数,并处理setter方法注入的原始参数。

但是,关于构造函数注入,如果我们有一个需要原始参数(字符串、整数、布尔值等)的类怎么办?

容器提供了 setParam,因此我们可以为类的构造函数传递一个特定的参数

setParam(string $name, mixed $value = null): void

让我们检查 UserModel 的例子

class UserModel
{
    private $conn;
    private $mailer;

    public function __construct(DbConnection $conn, MailerService $mailer) 
    {
        $this->conn = $conn;
        $this->mailer = $mailer;
    }
}

所以,我们可以自己解决这个问题,而不是让容器来解决

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;
use MyApp\MailerService;
use MyApp\DbConnection;
use MyApp\UserModel;

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);

$container->set(UserModel::class)
    ->setParam(MailerService::class)
    ->setParam(DbConnection::class);

$user = $container->get(UserModel::class);

参数的顺序无关紧要,请注意,只有在类参数的情况下,我们可以省略参数名称。

$container->set(UserModel::class)
    ->setParam('conn', DbConnection::class)
    ->setParam('mailer', MailerService::class);

但是,正如我们所看到的,我们亲自传递了参数,但这有点冗余 :)

所以让我们看看一个更合适的例子

// Shape class
class Shape
{
    private $height;
    private $width;
    private $length;

    public function __construct($height, $width, $length = 30)
    {
        $this->height = $height;
        $this->width = $width;
        $this->length = $length;
    }
}

// set in the container
$container = new Container();

$container->set(Shape::class)
    ->setParam('height', 10)
    ->setParam('width', 20);

现在,每次我们请求 Shape 类的实例时,容器都会自动将这些值传递给构造函数。

当然,在默认参数值的情况下,如果未传递该参数的值,容器将使用默认值!

最后,setParam 不仅接受原始数据,还可以接受任何有效的PHP数据类型(闭包、数组、对象等),因此所有以下示例都是有效的

$container->set(DoEverything::class)
    ->setParam('my_function', fn() => true)
    ->setParam('an_array', ['a', 'b', 'c'])
    ->setParam('error', (new \Exception()))
    ->setParam('count', 100);

// UserModel example
$container->set(UserModel::class)
    ->setParam('conn', function() {
        return new DbConnection();
    })
    ->setParam('mailer', function() {
        return new MailerService();
    });

定义

使用set方法定义依赖是默认方法来在容器中注册依赖,但这不是唯一的方式。容器支持使用容器构造函数以数组形式定义依赖。

所以,我们不必编写以下内容:

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);
$container->set(UserModel::class);

我们可以使用数组定义方法

$container = new Container([
    MailerService::class,
    DbConnection::class,
    UserModel::class
]);

结果将相同,而且不使用任何set方法,但参数和setter方法怎么办呢?

数组定义也支持此功能,使用关联数组。关联数组定义有两种选择,简单方法,即将定义绑定到ID

$container = new Container([
    MyServiceInterface::class => MyService::class,
]);

和完整的数组,包括定义、参数和setter方法

$container = new Container([
    MyServiceInterface::class => [
        'definition' => MyService::class,
        'params' => [
            'paramA' => (new ServiceA::class),
            'paramB' => (new ServiceB::class),
        ],
        'methods' => [
            'setDbProvider' => [
                'dbName' => 'test'
            ]
        ]
    ],
]);

因此,我们有必填的definition键,然后我们有两个可选键paramsmethods,它们相当于setParamsetMethod。以下是一个例子

// Shape class
class Shape
{
    private $height;
    private $width;
    private $length;

    public function __construct($height, $width, $length = 30)
    {
        $this->height = $height;
        $this->width = $width;
        $this->length = $length;
    }
    
    public function setDrawHandler(DrawHandler $handler)
    {
        $this->height = $height;
        $this->width = $width;
        $this->length = $length;
    }
}

// bind Shape class to the container
$container = new Container([
    DrawHandler::class => DrawHandler::class
    Shape::class => [
        'definition' => Shape::class,
        'params' => [
            'height' => 10,
            'width'  => 20
            'length' => 30
        ],
        'methods' => [
            'setDrawHandler' => []
        ]
    ])
]);

由于setDrawHandler只接受DrawHandler类的实例,我们不需要传递参数,容器将自动解析依赖。

当然,数组定义支持所有PHP类型,我们可以传递数组、闭包、对象,我们可以混合所有这些酷东西来创建复杂的定义

$container = new Container([
    MailerService::class => function() {
        return (new MailerService());
    },
    DbConnection::class => (new DbConnection())),
    UserModel::class => [
        'definition' => UserModel::class,
        'params' => [
            'conn' => DbConnection::class,
            'mailer => MailerService::class
        ],
        'methods' => [
            'setMailer' => [
                'mailer' => MailerExample::class,
            ],
            'setDefaultUserInfo' => [
                'name' => 'test',
                'email' => '[email protected]'
            ],
        ]
    ]
]);

最后,假设您有一个复杂的定义数组,并且您在应用程序中使用多个容器,您可以将其保存到单独的文件中。然后,每次您想要创建容器的新实例时,只需引入该文件即可

// /path/to/definitions.php
<?php

return [
    MyServiceInterface::class => [
        'definition' => MyService::class,
        'params' => [
            'paramA' => (new ServiceA::class),
            'paramB' => (new ServiceB::class),
        ],
        'methods' => [
            'setDbProvider' => [
                'dbName' => 'test'
            ]
        ]
    ],
];

// and then somewhere in your application 

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;

$definitions = require_once(__DIR__ . '/path/to/definitions.php'); 

$container = new Container($definitions);

共享实例

默认情况下,容器中定义的所有实例都是共享的,这意味着它们被缓存在容器中,因此,我们不必遍历所有定义并为每个依赖项创建新实例。这种机制为性能提供了巨大的提升,尤其是在嵌套复杂的依赖项中。

所以每次调用get方法时,都会返回相同的实例!

$container = new Container();

$container->set(MailerService::class);

$mailer1 = $container->get(MailerService::class);
$mailer2 = $container->get(MailerService::class);

var_dump($mailer1 === $mailer2) // true

假设我们想要从容器中获取新实例,我们可以使用make方法,与get方法不同,它每次都会返回一个新实例。

make($id): object

所以让我们用make方法来重写之前的例子

$container = new Container();

$container->set(MailerService::class);

$mailer1 = $container->make(MailerService::class);
$mailer2 = $container->make(MailerService::class);

var_dump($mailer1 === $mailer2) // false

但请注意,与get方法不同,make方法仅与类一起使用,以便创建新实例,所以原始数据类型、数组、闭包等都不与make一起使用!

工厂

在某些情况下,我们可能有一个复杂的类,该类不能简单地由容器解析,这就是容器引入工厂的原因。工厂只是一个闭包,可以被容器执行。

让我们看看以下例子

$container = new Container();

$container->set('create_cube', function () {
    $height = 5;
    $width  = 5;
    $length = 5;

    return (new Shape($height, $width, $length));
});

var_dump($container->get('create_cube')); // Shape

现在每次我们调用create_cube定义时,容器都会解析闭包并返回Shape类的新实例。

工厂有一些很酷的功能,包括

1- 可以访问当前容器的实例

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);

$container->set('create_user', function (Container $container) {
    $conn = $container->get(DbConnection::class);
    $mailer = $container->get(MailerService::class);

    return (new UserModel($conn, $mailer));
});

因此,每次我们向工厂传递类型为Container的参数时,容器都会自动将当前容器的实例注入到工厂中。

2- 解析参数依赖项

所以之前的例子可以被重写为

$container = new Container();

$container->set(MailerService::class);
$container->set(DbConnection::class);

$container->set('create_user', function (DbConnection $conn, MailerService $mailer) {
    return (new UserModel($conn, $mailer));
});

3- 与setParam一起工作

因此,我们可以轻松地将原始参数绑定到我们的工厂中

$container = new Container();

$container->set('db_connection', function ($host, $db, $user, $pass) {
    return (new \PDO("mysql:host=$host;dbname=$db", $user, $pass));
})
    ->setParam('host', 'localhost')
    ->setParam('db', 'test')
    ->setParam('user', 'root')
    ->setParam('pass', 'root');

var_dump($container->get('db_connection')); // PDO

在类中调用方法

在某些情况下,我们可能需要在类中调用一个方法,而不需要从该类中实例化。这就是call方法,它是容器提供的一个函数,因此我们可以直接在类中调用方法。

call(string $id, string $method, array $args = []): mixed

首先,我们传递$id,它是容器中注册的类名,然后是$method,我们想要调用的方法的名称,最后是一个可选参数$args,用于将任何参数绑定到该方法。

class Calculator
{
    public function add($a, $b)
    {
        return $a + $b;
    }
}

// somewhere in the app
$container = new Container();

$container->set(Calculator::class);

$result = $container->call(Calculator::class, 'add', [
    'a' => 5,
    'b' => 6,
]);

var_dump($result); // 11

和往常一样,默认情况下call方法支持依赖注入,无需声明任何参数。

class Notification
{
    public function sendAlert(MailerService $mailer)
    {
        /* the code to send the email... */
    }
}

// somewhere in the app
$container = new Container();

$container->set(Notification::class);
$container->set(MailerService::class);

$container->call(Notification::class, 'sendAlert');

sendAlert方法将自动接收来自MailerService类的实例。

调用闭包

与类中的call方法类似,容器也可以在飞行中调用闭包,无需在容器中注册。并且所有依赖都将注入到闭包中。

callFunction(\Closure $closure, array $args = []): mixed

callFunction方法接受两个参数,第一个是闭包,第二个是可选的参数数组。

$container = new Container();

$result = $container->callFunction(function ($a, $b) {
        return $a + $b;
    }, 
    [
        'a' => 5,
        'b' => 6,
    ]
);

var_dump($result); // 11

下面是另一个带有依赖注入的例子。

$container = new Container();

$container->set(DbConnection::class);
$container->set(MailerService::class);

$container->callFunction(function (DbConnection $conn, MailerService $mailer) {
    /* fetch data and send emails for example */
});

服务提供者

容器提供的一个最佳特性是服务提供者。使用提供者,您的应用程序将获得巨大的提升,尤其是在可扩展性方面。

到目前为止,在本文档中所有的前例中,我们都是使用set方法或定义数组逐一设置依赖项。然而,使用服务提供者,我们可以将我们的应用程序转换为一组插件(扩展),根据需要可以轻松添加或删除。

关于服务提供者的另一个酷特性是,它将允许其他开发者通过开发包并在容器中使用服务提供者进行注册来为我们的应用程序做出贡献。

要编写自己的服务提供者,我们首先实现ServiceProviderInterface

<?php

namespace SigmaPHP\Container\Interfaces;

use SigmaPHP\Container\Container;

/**
 * Service Provider Interface
 */
interface ServiceProviderInterface
{
    /**
     * The boot method , will be called after all 
     * dependencies were defined in the container.
     * 
     * @param Container $container
     * @return void
     */
    public function boot(Container $container);

    /**
     * Add a definition to the container.
     * 
     * @param Container $container
     * @return void
     */
    public function register(Container $container);
}

ServiceProviderInterface要求在我们的服务提供者中实现两个方法。

  • register(Container $container): voidregister方法可以访问容器的当前实例,我们使用此方法来注册我们的依赖项,这些依赖项不执行任何操作或依赖于其他依赖项的其他功能。

  • boot(Container $container): voidboot方法与register方法相同,但主要区别在于boot将在所有服务注册后执行。

简单来说,如果服务提供者只需使用set注册一些类,那么我们使用register,因为它更适合纯注册。另一方面,如果我们需要在创建类的实例之前从数据库获取一些数据,或者我们需要在文件中写入一些日志,在这种情况下,我们使用boot,因为我们将会确保所有其他服务都已注册,并且可以访问它们。

让我们看看一些例子。

class UserServiceProvider implements ServiceProviderInterface
{
    public function boot(Container $container) {}

    public function register(Container $container)
    {
        $container->set(UserModel::class);
    }
}

class MailServiceProvider implements ServiceProviderInterface
{
    public function boot(Container $container) {}

    public function register(Container $container)
    {
        $container->set(MailerService::class);
    }
}

class DbConnectionServiceProvider implements ServiceProviderInterface
{
    public function boot(Container $container) {}

    public function register(Container $container)
    {
        $container->set(DbConnection::class);
    }
}

因此,我们为每个依赖项创建了3个独立的服务提供者,并且因为这些我们注册这些类时没有使用任何其他服务,所以我们使用了register方法。

class DashboardServiceProvider implements ServiceProviderInterface
{
    public function boot(Container $container) {
        $db = $container->get(DbConnection::class);

        $query = $db->query("SELECT * FROM admins WHERE super_admin = TRUE");

        $admin = $query->fetch()[0];

        $container->set(Dashboard::class)
            ->setParam('admin', $admin);
    }

    public function register(Container $container) {}
}

如上例所示,为了从Dashboard类中实例化一个新的实例,我们需要传递从数据库中刚刚检索到的管理员信息。使用register方法,我们没有保证DbConnection已被注册,在类似情况下,使用boot方法注册我们的依赖项,以确保所有依赖项都已注册并准备好使用。

那么现在我们有了我们的提供者,我们该如何注册它们呢??

答案是简单地使用registerProvider方法。

registerProvider(string $provider): void

registerProvider方法只需要提供者的路径。

$container = new Container();

$container->registerProvider(MailerServiceProvider::class);
$container->registerProvider(DbConnectionProvider::class);
$container->registerProvider(UserProvider::class);

$user = $container->get(UserModel::class);

自动装配

到目前为止,我们一直使用各种方法手动注册所有依赖项。而不是手动注册依赖项,容器提供了一个优雅的函数:autowire来自动注册您的依赖项。

那么自动注入是什么呢??

自动注入是依赖注入容器用来自动加载请求的依赖项的一种机制。

默认情况下,自动注入是禁用的。要启用自动注入,您只需在创建容器后调用一次autowire方法即可。

<?php

require 'vendor/autoload.php';

use SigmaPHP\Container\Container;

$container = new Container();

$container->autowire();

$user = $container->get(UserModel::class);

SigmaPHP-Container中的自动注入功能非常智能、快速,几乎对性能没有影响,但遗憾的是,它有两个主要的缺点。

1- 自动装配功能仅与构造函数注入一起使用,因此如果您的类中存在一些setter注入,那么这些注入将不会被解析!

2- 自动装配只能解析基于类的依赖关系,所以如果您的类的构造函数接受原始参数,容器将抛出异常!

因此,为了克服这两个障碍,您可以使用set方法、定义数组或服务提供者来注册满足这些要求的类。

许可证

(SigmaPHP-Container) 在MIT许可证下发布。