tflori/dependency-injector

简单轻量级的依赖注入器,实现PSR-11规范

v2.3.0 2022-03-18 13:56 UTC

README

.github/workflows/push.yml Test Coverage Maintainability Latest Stable Version Total Downloads License

这是一个简单轻量级的依赖注入器,符合PHP标准推荐的依赖注入容器规范(PSR-11)。

什么是依赖

应用程序正常工作所需的东西。例如 CalculatorConfig 的实例,或者实现了 CacheInterface 的对象。

基本用法

软件开发中最大的问题之一是依赖的紧密耦合。想象一个使用 new DatabaseConnection() 创建 DatabaseConnection 实例的类。这被称为紧密耦合 - 它非常紧密,以至于您可能需要重写自动加载器(这并不总是可能的)来模拟数据库连接。

有两种方法可以在不需要数据库连接的情况下测试此类。

传递依赖

创建此类对象时,您可以将 DatabaseConnection 传递给构造函数。例如 new MyService(new DatabaseConnection)。在测试中,您可以通过 new MyService(m::mock(DatabaseConnection::class)) 传递一个模拟。

这意味着您必须在应用程序中创建此类对象的任何地方都知道 DatabaseConnection 对象存储在哪里或创建一个新的对象。更糟糕的是:当类的接口发生变化时(例如添加了额外的依赖),您必须在整个代码中更改这些内容。

这时就轮到依赖注入发挥作用了。以下是最直接、最易于理解的方式(回调)

<?php
$container->share('databaseConnection', function () {
    return new DatabaseConnection();
});
$container->add('myService', function () use ($container) {
    return new MyService($container->get('databaseConnection'));
});

使容器可用

容器也可以在类内部使用 *1。此库提供了一个只有静态方法的类 DI,以便从任何地方提供依赖。它使用相同的接口,但使用静态调用。上面的示例可以如下所示

<?php
DI::share('databaseConnection', function () {
    return new DatabaseConnection();
});
DI::add('myService', function () {
  return new MyService(); // we can access DI::get('databaseConnection') within this class now
});

测试

现在,当我们要测试这个类时,我们可以简单地替换数据库连接的依赖

<?php
DI::share('databaseConnection', function () {
    return m::mock(DatabaseConnection::class);
});

DI::get('databaseConnection')->shouldReceive('query'); 
// ...

这可以在两种版本 *2 中工作,并且可以安全地用于测试。

在此文档的其余部分,我们将使用 DI 的静态方法。

高级用法

我们不仅可以存储在需要新实例时执行的回调,还有一些其他实用方法,可以帮助您定义依赖项的解析方式。

创建对象

版本 2.1 引入了新的方法 DI::make(string $class, ...$args),它允许您直接使用 $args 作为构造函数参数获取 $class 的实例,而无需为其定义依赖。

<?php
$feed = DI::make(SomeFeed::class, $_GET['id']);
// equals to  new SomeFeed($_GET['id']);

即使上述示例相同,此方法也有一个很大的优点:您可以为类提供模拟。

<?php
DI::instance(SomeFeed::class, m::mock(SomeFeed::class));
$feedMock = DI::make(SomeFeed::class, $_GET['id']);

定义实例

可以定义在请求依赖项时要返回的实例。请注意,在使用之前必须实例化一个类,这可能会对性能产生影响。无论如何,这为您提供了定义多个值的机会,例如一个非常简单的配置

<?php
DI::instance('config', (object)[
    'database' => (object)[
        'dsn' => 'mysql://whatever',
        'user' => 'john',
        'password' => 'does_secret',
    ]
]);

不要将其用作全局存储。您将遇到命名冲突,我们不会为此提供解决方案。

定义别名

别名允许您为依赖项拥有多个名称。首先定义依赖项,然后对其进行别名化

<?php
DI::share(Config::class, Config::class);
DI::alias(Config::class, 'config');
DI::alias(Config::class, 'cfg'); 

定义依赖项

当请求时构建的依赖项可以使用Container::add(string $name, $getter)添加。getter可以是可调用的(例如闭包 - 如上所述),工厂类的名称,工厂实例或任何其他类名。

在这里,工厂意味着实现FactoryInterfaceSharableFactoryInterface的类。

Container:add()将返回添加的工厂,添加哪个工厂定义如下:

  • 工厂实例:给定的工厂
  • 工厂的类名:给定类的新对象
  • 使用单例模式的类名:一个SingletonFactory
  • 任何其他类名:一个ClassFactory
  • 可调用:一个CallableFactory

使用实现SharableFactoryInterface的工厂的依赖项可以通过调用$factory->share()或使用快捷方式Container::share(string $name, $getter)来共享。

类工厂

ClassFactory默认情况下只创建一个没有参数的实例。它还允许向构造函数传递不同的参数,这是PSR-11建议的常用方式。

<?php
// pass some statics
DI::share('session', Session::class)
    ->addArguments('app-name', 3600, true);
new Session(DI::has('app-name') ? DI::get('app-name') : 'app-name', 3600, true);

// pass dependencies
DI::share('database', Connection::class)
    ->addArguments('config');
new Connection(DI::has('config') ? DI::get('config') : 'config');

// pass a string that is defined as dependency
DI::add('view', View::class)
    ->addArguments(new StringArgument('default-layout'));
new View('default-layout');

还可以在新的实例上调用方法

<?php
DI::share('cache', Redis::class)
    ->addMethodCall('connect', 'localhost', 4321, 1);

// you can also bypass resolving the dependency
DI::add('view', View::class)
    ->addMethodCall('setView', new StringArgument('default-view'));

非共享类允许向构造函数传递额外的参数

<?php
DI::add('view', View::class);
$view = DI::get('view', 'login');
new View('login');

模式工厂

PatternFactory是一个可以用于不同名称的工厂。请注意,每次请求依赖项且没有为该依赖项定义其他工厂时,都会请求所有模式工厂以匹配请求的名称。

命名空间工厂

PatternFactory的一个例子是NamespaceFactory。命名空间工厂可以用来使用这个工厂加载之前定义的命名空间中的所有类。类似于类工厂,它支持在请求后传递参数和方法调用。

此示例显示了控制器类的工厂定义

<?php
use App\Http\Controller;
use DependencyInjector\Factory\NamespaceFactory;
use DependencyInjector\DI;

$request = (object)$_SERVER;
DI::add(Controller::class, (new NamespaceFactory(DI::getContainer(), Controller::class))
    ->addArguments(DI::getContainer()));
DI::get(Controller\UserController::class, $request);

// equals to
new Controller\UserController(DI::getContainer(), $request);

共享的命名空间工厂按类存储实例

<?php
DI::share('ViewHelper', (new NamespaceFactory(DI::getContainer(), App\View\Helper::class))
    ->addArguments(DI::getContainer()));
DI::get(App\View\Helper\Url::class);

单例工厂

SingletonFactory是一个特殊的工厂,它只是包装对::getInstance()的调用。这里的优点是你不需要创建实例(如果你不需要)或为测试创建一个模拟对象。如果没有这个工厂,你可以传递类的实例,或者坚持在你的代码中使用对::getInstance()的调用。

此工厂还允许向存储特定参数的不同实例的类传递参数给::getInstance()方法。

<?php
DI::add('calculator', Calculator::class);

DI::get('calculator', 'rad');
Calculator::getInstance('rad');

DI::get('calculator', 'deg');
Calculator::getInstance('deg');

可调用工厂

这个工厂只是调用传递的回调。回调只需要是可调用的,这通过is_callable($getter)来检查 - 因此你也可以传递一个包含类或实例和方法名的数组。

<?php
DI::share('database', function() {
    $config = DI::get('config');
    return new PDO($config->database->dsn, $config->database->username, $config->database->password);
});

因为回调也可以是一个类的静态方法(例如[Calculator::class, 'getInstance'])。这也适用于单例类。区别在于这可以共享,但SingletonFactory总是调用::getInstance(),这是我们观点中的首选方法。

自定义工厂

当你编写自己的工厂时,你必须实现FactoryInterfaceSharableFactoryInterfaceAbstractFactory实现了SharableFactoryInterface,并且可以非常简单地进行扩展以满足你的需求。

<?php
class DatabaseFactory extends \DependencyInjector\Factory\AbstractFactory
{
    protected $shared = true; // false is default - so simple omit it for non shared factories or use share to define
    
    protected function build()
    {
        $dbConfig = $this->container->get('config')->database;
        return new PDO($dbConfig->dsn, $dbConfig->user, $dbConfig->password);
    }
}

可以像上面描述的那样,使用Container::add()Container::share()为依赖项定义工厂。但你也可以注册你的工厂定义的命名空间,容器将尝试为请求的依赖项查找工厂。当你请求一个依赖项且它尚未定义时,它将检查每个已注册的命名空间是否存在一个名为$namespace . '\\' . ucfirst($dependency) . $suffix的类。

示例

以下是一些如何使用这个库的小示例。

配置

<?php
class Config {
    private static $_instance;
    
    public $database = [
        'host' => 'localhost',
        'user' => 'john',
        'password' => 'does.secret',
        'database' => 'john_doe'
    ];
    
    public $redis = ['host' => 'localhost'];
    
    private function __construct() {
        // maybe some logic to change the config or initialize variables
    }
    
    public static function getInstance() {
        if (!self::$_instance) {
            self::$_instance = new Config();
        }
        return self::$_instance;
    }
}

DI::add('config', Config::class); // adds a SingletonFactory

function someStaticFunction() {
    // before
    if (empty(Config::getInstance()->database['host'])) {
        throw new Exception('No database host configured');
    }
    
    // now
    if (empty(DI::get('config')->database['host'])) {
        throw new Exception('No database host configured');
    }
}

数据库连接

<?php
DI::set('database', function() {
    $dbConfig = DI::get('config')->database;
    
    $mysql = new mysqli($dbConfig['host'], $dbConfig['user'], $dbConfig['password'], $dbConfig['database']);
    
    if (!empty($mysql->connect_error)) {
        throw new Exception('could not connect to database (' . $mysql->connect_error . ')');
    }
    
    return $mysql;
});

function someStaticFunction() {
    // before it maybe looked like this
    $mysql = MyApp::getDatabaseConnection();
    
    // now
    $mysql = DI::get('database');
    
    $mysql->query('SELECT * FROM table');
}

问题之前:你不能模拟静态函数 MyApp::getDatabaseConnection()。你也不能模拟静态函数 DI::get('database')。但你可以设置依赖以返回一个模拟对象

<?php
class ApplicationTest extends TestCase {
    public function testSomeStaticFunction() {
        // prepare the mock
        $mock = $this->getMock(mysqli::class);
        $mock->expects($this->once())->method('query')
            ->with('SELECT * FROM table');
        
        // overwrite the dependency
        DI::instance('database', $mock);
            
        someStaticFunction();
    }
}

提示

扩展DI类

当你使用DI类时,扩展这个类并为__callStatic() getter添加注解是有意义的,这样你的IDE就会知道你的DI返回什么

<?php
/**
 * @method static Config config()
 * @method static mysqli database() 
 */
class DI extends \DependencyInjector\DI {}

扩展容器类

对于Container也有类似的功能。魔法方法__isset()Container::has()的别名,__get()Container::get($name)的别名,__call()Container::get($name, ...$args)的别名。所以你可以这样注解你的容器

<?php
/** 
 * @property Config config
 * @method Config config()
 */
class Container extends \DependencyInjector\Container {}

注释

  • *1有些人说这是隐藏依赖,是一种叫做服务定位器的反模式。不要相信他们。依赖仍然很清楚(你只需要搜索它们),这可能会更容易编写。但最重要的区别是,否则实例在没有要求的情况下被创建。假设你可能只需要一个DatabaseConnection,如果缓存还没有存储结果 - 当我们谈论大量用户时,这种事情可以产生巨大影响。

  • *2PSR-11的元文档中,他们提到,当你将容器传递给对象时,测试会更困难。但是——正如我们所看到的——事实并非如此。