Skip to content

Instantly share code, notes, and snippets.

@funway
Last active April 20, 2020 04:49
Show Gist options
  • Save funway/9e5a1f41199a94e4d8aa3638af54408e to your computer and use it in GitHub Desktop.
Save funway/9e5a1f41199a94e4d8aa3638af54408e to your computer and use it in GitHub Desktop.
Laravel's Dependency Injection Container in Depth 【中文翻译】

深入理解Laravel容器

Laravel 是一个具有很强控制反转(IoC)与依赖注入(DI)能力的容器。不幸的是它的官方文档没有更详细的介绍这些用法,所以我决定自己做点试验并写些更详细的文档。下面所有的都是基于Laravel 5.4.26,不同版本可能有异。

依赖注入

我不会在这里介绍DI/IoC的原理,如果对依赖注入与控制反转不是很熟悉,你可以阅读这篇文章 What is Dependency Injection?

访问容器

在Laravel中有多种方式可以用来访问容器实例,最简单的就是调用全局辅助函数app()

$container = app();

其他方式我就不讲了,因为我只想关注于Laravel的Container类本身。

Note: 如果你读过官方文档, 它是使用 $this->app 来访问容器实例的。

(* Laravel应用程序 Application 实际上是Container类的派生类(所以全局辅助函数才叫做 app() ),不过在这篇文章中我只会讨论 Container 类的方法。)

在其他项目使用Laravel容器

如果你想在其他项目使用Laravel容器,你必须从packagist 安装 它然后调用如下代码:

use Illuminate\Container\Container;

$container = Container::getInstance();

基本用法

实现Laravel依赖注入最简单的一种方式就是通过类型约束(type-hint),在你需要注入实例的参数前指定其类型:

class MyClass
{
    private $dependency;

    public function __construct(AnotherClass $dependency)
    {
        $this->dependency = $dependency;
    }
}

然后在新建MyClass实例的时候,用容器的make()方法代替直接new MyClass:

$instance = $container->make(MyClass::class);

这时候容器就会自动注入MyClass类的依赖(即其构造方法中的$dependency参数),所以上述代码相当于:

$instance = new MyClass(new AnotherClass());

(如果AnotherClass类也有自己的依赖,那么容器就会递归的进行依赖注入)

实际用例

以下是一个更实际的用例,它通过依赖注入将邮件类从用户注册类中解耦。(这个例子来自 PHP-DI docs ):

class Mailer
{
    public function mail($recipient, $content)
    {
        // Send an email to the recipient
        // ...
    }
}
class UserManager
{
    private $mailer;

    public function __construct(Mailer $mailer)
    {
        $this->mailer = $mailer;
    }

    public function register($email, $password)
    {
        // Create the user account
        // ...

        // Send the user an email to say hello!
        $this->mailer->mail($email, 'Hello and welcome!');
    }
}
use Illuminate\Container\Container;

$container = Container::getInstance();

$userManager = $container->make(UserManager::class);
$userManager->register('dave@davejamesmiller.com', 'MySuperSecurePassword!');

将接口绑定到实体类

容器使得我们可以很方便的将接口类与实体类进行绑定,并在需要这个接口时候直接实例化。首先,定义接口:

interface MyInterface { /* ... */ }
interface AnotherInterface { /* ... */ }

然后声明实体类来实现接口。在实体类的构造方法中你可能会依赖其他接口或实体类:

class MyClass implements MyInterface
{
    private $dependency;

    public function __construct(AnotherInterface $dependency)
    {
        $this->dependency = $dependency;
    }
}

然后通过bind()方法将接口绑定到对应的实体类:

$container->bind(MyInterface::class, MyClass::class);
$container->bind(AnotherInterface::class, AnotherClass::class);

最后通过容器的make()方法来获取这个“接口的实例”(实际上是其派生类的的实例):

$instance = $container->make(MyInterface::class);

Note: 如果你之前没有将接口MyInterface绑定到实体类,那么就会得到一个严重错误:

Fatal error: Uncaught ReflectionException: Class MyInterface does not exist

因为容器会直接去实例化接口new MyInterface,而接口类型是不能实例化的。

实际用例

以下是一个实际用例,一个可以替换的cache实现。通过这种方法可以很方便的替换Cache接口的实现:

interface Cache
{
    public function get($key);
    public function put($key, $value);
}
class RedisCache implements Cache
{
    public function get($key) { /* ... */ }
    public function put($key, $value) { /* ... */ }
}
class Worker
{
    private $cache;

    public function __construct(Cache $cache)
    {
        $this->cache = $cache;
    }

    public function result()
    {
        // Use the cache for something...
        $result = $this->cache->get('worker');

        if ($result === null) {
            $result = do_something_slow();

            $this->cache->put('worker', $result);
        }

        return $result;
    }
}
use Illuminate\Container\Container;

$container = Container::getInstance();
$container->bind(Cache::class, RedisCache::class);

$result = $container->make(Worker::class)->result();

绑定抽象类和实体类

容器的绑定同样可以作用在抽象类:

$container->bind(MyAbstract::class, MyConcreteClass::class);

甚至可以将一个实体类型绑定到派生类上:

$container->bind(MySQLDatabase::class, CustomMySQLDatabase::class);

自定义绑定

如果要绑定到的目标类型需要额外的配置,那么你可以传递一个闭包函数(laravel官方文档管这个闭包叫resolver)给bind()方法的第二个参数,而不仅仅是传递一个类名:

$container->bind(Database::class, function (Container $container) {
    return new MySQLDatabase(MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASS);
});

这样,当每次要用到Database接口的时候,一个新的MySQLDatabase实例就会被创建出来。(如果不希望每次都新建一个实例,而是共用同一个实例,请翻阅下面的"单例绑定"部分)这个闭包函数接收容器实例作为第一个参数,通过它就可以在闭包中向容器请求已绑定的实例:

$container->bind(Logger::class, function (Container $container) {
    $filesystem = $container->make(Filesystem::class);

    return new FileLogger($filesystem, 'logs/error.log');
});

在这个闭包中还可以自定义绑定实例的初始化过程:

$container->bind(GitHub\Client::class, function (Container $container) {
    $client = new GitHub\Client;
    $client->setEnterpriseUrl(GITHUB_HOST);
    return $client;
});

解析回调

我们还可以通过容器的resolving()方法注册一个解析回调函数,该函数将会在容器解析绑定,获得绑定实例后自动调用,并将得到的实例作为第一个参数传递给该函数。我们可以将绑定实例的初始化语句从resolver闭包函数挪到解析回调函数中:

$container->resolving(GitHub\Client::class, function ($client, Container $container) {
    $client->setEnterpriseUrl(GITHUB_HOST);
});

绑定回调同样适用于对接口或抽象类的绑定:

$container->resolving(Logger::class, function (Logger $logger) {
    $logger->setLevel('debug');
});

$container->resolving(FileLogger::class, function (FileLogger $logger) {
    $logger->setFilename('logs/debug.log');
});

$container->bind(Logger::class, FileLogger::class);

$logger = $container->make(Logger::class);

你甚至可以定义一个绑定回调关联到容器中的所有绑定,任何一个绑定实例从容器中解析后,都会自动调用这个回调函数——不过我想这大概只有在输出日志或者调试的时候才会用到:

$container->resolving(function ($object, Container $container) {
    // ...
});

扩展绑定

你还可以通过容器的extend()方法来包装一个已有的绑定,这样当解析绑定时候,会先解析bind()中绑定的实例,然后将该实例传递给extend()方法中定义的“包装器”闭包函数,该闭包也必须返回一个实例。这个新的实例就是最后的解析结果。(解析回调在扩展绑定之后调用,所以传给解析回调的就是这个“扩展后“的实例):

$container->extend(APIClient::class, function ($client, Container $container) {
    return new APIClientDecorator($client);
});

需要注意的是,扩展绑定返回的实例最好要包含原实例的方法和属性,否则在后续调用中可能会报错。

单例绑定

容器在解析在bind()方法绑定的实例时,每次都会用其绑定的闭包函数(或是类构造函数)新建一个对象。如果我们想再解析绑定时都能返回同一个实例,就必须用singleton()代替bind():

$container->singleton(Cache::class, RedisCache::class);

或是绑定一个闭包函数:

$container->singleton(Database::class, function (Container $container) {
    return new MySQLDatabase('localhost', 'testdb', 'user', 'pass');
});

如果只是想创造某个类的单例放到容器中,可以直接给singleton()传递类型作为第一个实参,然后不需要传第二个参数:

$container->singleton(MySQLDatabase::class);

在上述情况下,单例对象会在第一次需要时被创建,然后后续每次用到时就直接返回该对象,而不会再重新创建一个。如果你希望绑定一个已经存在的对象,那么可以通过 instance()方法将该对象注册到Laravel容器中。举个例子,Larave容器实例就是通过下面这样的方法将自身作为一个单例绑定来注册的,第一个参数是要绑定的类名,第二个参数就是要绑定到的单例对象,就是它自己:

$container->instance(Container::class, $container);

绑定名

除了可以使用类名/接口名进行绑定,你也可以选择使用任意的字符串作为绑定名。不过这样的话你就无法通过类型约束来注入这个实例了,必须手工调用make()方法:

$container->bind('database', MySQLDatabase::class);

$db = $container->make('database');

如果希望类型名绑定与字符串名绑定都能返回同一个实例,那么就需要用alias()方法:

$container->singleton(Cache::class, RedisCache::class);
$container->alias(Cache::class, 'cache');

$cache1 = $container->make(Cache::class);
$cache2 = $container->make('cache');

assert($cache1 === $cache2);

在容器中存储任意值

你还可以在容器中存储任意值(比如配置信息):

$container->instance('database.name', 'testdb');

$db_name = $container->make('database.name');

容器还支持通过数组形式的访问来解析绑定,这使得从容器中取值看起来更自然:

$container['database.name'] = 'testdb';

$db_name = $container['database.name'];

在绑定的resolver闭包中调用容器解析时你就会发现这种数组形式的访问有多方便:

$container->singleton('database', function (Container $container) {
    return new MySQLDatabase(
        $container['database.host'],
        $container['database.name'],
        $container['database.user'],
        $container['database.pass']
    );
});

(当然Laravel并没有直接使用容器来存储配置信息,而是使用一个独立的Config类型,但PHP-DI就是这么做的。)

Tip: 数组形式的访问也可以代替make()方法进行绑定解析,获取绑定实例:

$db = $container['database'];

对函数的依赖注入

目前为止,我们已经看到了在类构造方法中的依赖注入,实际上Laravel还支持对任意函数进行依赖注入,只需要通过容器的call()方法调用目标函数:

function do_something(Cache $cache) { /* ... */ }

$result = $container->call('do_something');

额外参数会作为有序数组或是关联数组传入:

function show_product(Cache $cache, $id, $tab = 'details') { /* ... */ }

// show_product($cache, 1)
$container->call('show_product', [1]);
$container->call('show_product', ['id' => 1]);

// show_product($cache, 1, 'spec')
$container->call('show_product', [1, 'spec']);
$container->call('show_product', ['id' => 1, 'tab' => 'spec']);

这也可以用在闭包函数上:

闭包

$closure = function (Cache $cache) { /* ... */ };

$container->call($closure);

静态方法

class SomeClass
{
    public static function staticMethod(Cache $cache) { /* ... */ }
}
$container->call(['SomeClass', 'staticMethod']);
// or:
$container->call('SomeClass::staticMethod');

实例方法

class PostController
{
    public function index(Cache $cache) { /* ... */ }
    public function show(Cache $cache, $id) { /* ... */ }
}
$controller = $container->make(PostController::class);

$container->call([$controller, 'index']);
$container->call([$controller, 'show'], ['id' => 1]);

实例方法的快捷方式

还有一种更快捷的方式,通过Laravel自动注入来调用一个实例方法——使用ClassName@methodName语法:

$container->call('PostController@index');
$container->call('PostController@show', ['id' => 4]);

容器可以实例化一个类,这表示:

  1. 依赖可以被自动注入到类构造方法中以及其他方法中。
  2. 你可以将类作为一个单例进行实例化,如果你想重复使用的话。
  3. 你可以用接口名或任意字符串名字来代替类名.

比如下面这样,使用字符串名'post'代替类名绑定到容器,然后调用实例方法时容器会自动为其注入依赖的参数:

class PostController
{
    public function __construct(Request $request) { /* ... */ }
    public function index(Cache $cache) { /* ... */ }
}
$container->singleton('post', PostController::class);
$container->call('post@index');

最后,你还可以给call()方法传递第三个参数,作为“默认方法”。在第一个实参只是一个类名而未指定方法名时候,这个“默认方法”就会被调用。Laravel就是这么实现它的 event handlers:

$container->call(MyEventHandler::class, $parameters, 'handle');

// Equivalent to:
$container->call('MyEventHandler@handle', $parameters);

类方法的绑定

容器的bindMethod()方法可以用来重写对某个类方法的调用,比如用来给原方法指定某些参数,或者增加某些操作:

$container->bindMethod('PostController@index', function ($controller, $container) {
    $posts = get_posts(...);

    return $controller->index($posts);
});

下面这些call()用法都是OK的,容器将会自动调用bindMethod()绑定到的闭包函数,而不是原类方法:

$container->call('PostController@index');
$container->call('PostController', [], 'index');
$container->call([new PostController, 'index']);

但是bindMethod()绑定的闭包函数无法接受第三个参数,只能接受类实例,容器实例作为前两个参数。所以在call()方法调用这个绑定闭包时,就无法通过第二个实参给闭包传递其他参数了。

$container->call('PostController@index', ['Not used :-(']);

Notes: bindMethod()方法并不是在 Container 接口里定义的, 而是在实体类 Container class中定义的. 这篇文章 the PR where it was added 讨论了为什么参数会被忽略掉。(其实只要看Container@callMethodBinding()就知道了)

上下文绑定

有时候你会希望在不同的地方使用某个接口的不同实现(实体类)。以下是改编自官方文档的示例:

$container
    ->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(LocalFilesystem::class);

$container
    ->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(S3Filesystem::class);

现在PhotoController与VideoController都需要用到FileSystem接口,并且会得到不同的实现。同样你也可以在give()方法中使用闭包函数来返回实例,就像bind()方法一样:

$container
    ->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(function () {
        return Storage::disk('s3');
    });

或者使用一个已绑定的名字:

$container->instance('s3', $s3Filesystem);

$container
    ->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give('s3');

给变量绑定不同值

你也可以通过这种方法,在不同地方给同一个变量绑定不同的值。只要通过给needs()方法传递一个变量名而不是接口名,然后给give()方法传递要绑定的变量值:

$container
    ->when(MySQLDatabase::class)
    ->needs('$username')
    ->give(DB_USER);

give() 中依然可以传递一个闭包函数来返回这个变量值:

$container
    ->when(MySQLDatabase::class)
    ->needs('$username')
    ->give(function () {
        return config('database.user');
    });

但是此时不能在give()方法中传递类型名或者绑定名(比如give('database.user')),因为它会被当作字符串直接返回并赋值给变量。如果你要赋予的值确实需要依赖于容器的解析的话,那就得用give(闭包)来返回了:

$container
    ->when(MySQLDatabase::class)
    ->needs('$username')
    ->give(function (Container $container) {
        return $container['database.user'];
    });

标签

你可以通过容器的tag()方法给近似的绑定打上标签:

$container->tag(MyPlugin::class, 'plugin');
$container->tag(AnotherPlugin::class, 'plugin');

然后使用tagged()方法就能解析出所有该标签的绑定实例,通过数组返回:

foreach ($container->tagged('plugin') as $plugin) {
    $plugin->init();
}

tag() 方法还接受标签数组作为参数:

$container->tag([MyPlugin::class, AnotherPlugin::class], 'plugin');
$container->tag(MyPlugin::class, ['plugin', 'plugin.admin']);

重绑定

Note: 这节有点进阶了,而且很少用到,所以你可以跳过这一节。

rebinding()回调函数会在一个绑定实例被解析过后,又被修改了(通常是重新生成了绑定实例)的时候调用。在下面的例子中,容器中的Session绑定在被Auth实例用过之后,又被替换成一个新的Session实例了,这时候就会触发rebinding闭包,告诉Auth这个变化:

$container->singleton(Auth::class, function (Container $container) {
    $auth = new Auth;
    $auth->setSession($container->make(Session::class));

    $container->rebinding(Session::class, function ($container, $session) use ($auth) {
        $auth->setSession($session);
    });

    return $auth;
});

$container->instance(Session::class, new Session(['username' => 'dave']));
$auth = $container->make(Auth::class);
echo $auth->username(); // dave
$container->instance(Session::class, new Session(['username' => 'danny']));

echo $auth->username(); // danny

(更多关于重绑定的信息,请参考 here and here.)

refresh()

还有一个便捷的方法 refresh(),同样是用来处理这件事的:

$container->singleton(Auth::class, function (Container $container) {
    $auth = new Auth;
    $auth->setSession($container->make(Session::class));

    $container->refresh(Session::class, $auth, 'setSession');

    return $auth;
});

refresh()方法会返回这个最新的绑定实例,所以你还可以这么写:

// This only works if you call singleton() or bind() on the class
$container->singleton(Session::class);

$container->singleton(Auth::class, function (Container $container) {
    $auth = new Auth;
    $auth->setSession($container->refresh(Session::class, $auth, 'setSession'));
    return $auth;
});

(不过我觉得第二种方法比第一种更难读懂,所以还是用第一种把,虽然冗长一点)

Note: 这个方法不是定义在 Container 接口中的,而是定义在实体类 Container class中。

解析绑定时传参

容器的makeWith()方法允许你传递额外的参数给绑定实例的构造方法。它还会忽略可以被自动注入的参数:

class Post
{
    public function __construct(Database $db, int $id) { /* ... */ }
}
$post1 = $container->makeWith(Post::class, ['id' => 1]);
$post2 = $container->makeWith(Post::class, ['id' => 2]);

Note: In Laravel 5.3 and below it was simply make($class, $parameters). It was removed in Laravel 5.4, but then re-added as makeWith() in 5.4.16. In Laravel 5.5 it looks like it will be reverted back to the Laravel 5.3 syntax.

其他方法

下面是其他我认为可能会有用的方法,不过做点简单说明。

bound()

bound()判断一个类名、接口名或者字符串名是否已经被 bind(), singleton(), instance()alias()绑定到容器中。

if (! $container->bound('database.user')) {
    // ...
}

同样你也可以用 isset()方法进行判断:

if (! isset($container['database.user'])) {
    // ...
}

unset()方法可以用来解除绑定

unset($container['database.user']);
var_dump($container->bound('database.user')); // false

bindIf()

注册一个绑定,如果该绑定还未被注册过(如果已存在,就不再注册)。这通常用在某个包里,它默认注册一个绑定,如果用户没有注册一个同名绑定的话。

$container->bindIf(Loader::class, FallbackLoader::class);

Laravel没有提供 singletonIf()方法, 不过可以bindIf接受第三个参数表示是否为单例绑定:

$container->bindIf(Loader::class, FallbackLoader::class, true);

或者你可以这么写:

if (! $container->bound(Loader::class)) {
    $container->singleton(Loader::class, FallbackLoader::class);
}

resolved()

resolved() 方法返回true,当绑定已经被解析过时。

var_dump($container->resolved(Database::class)); // false
$container->make(Database::class);
var_dump($container->resolved(Database::class)); // true

另外,unset()绑定会影响resolved()的结果:

unset($container[Database::class]);
var_dump($container->resolved(Database::class)); // false

factory()

factory()方法返回绑定的闭包函数:

$dbFactory = $container->factory(Database::class);

$db = $dbFactory();

但我实在不知道这有啥用。。。=。=

wrap()

wrap()方法用来包裹一个闭包函数并返回一个新的闭包。这样Laravel就可以为其自动注入依赖。wrap()方法可以通过第二个参数传递一个参数数组给原函数,但是wrap()返回的新闭包函数是无参的:

$cacheGetter = function (Cache $cache, $key) {
    return $cache->get($key);
};

$usernameGetter = $container->wrap($cacheGetter, ['username']);

$username = $usernameGetter();

Note: 这个方法不是定义在 Container 接口中的,而是定义在实体类 Container class中。

afterResolving()

afterResolving()方法等同于 resolving(),不过它是在resolving()方法执行完后才被调用。额,我也不知道这有啥子用=。=

And Finally...

  • isShared() - 判断是否为单例绑定
  • isAlias() - 判断是否为绑定别名
  • hasMethodBinding() - 判断容器中是否存在某个绑定方法
  • getBindings() - 以数组形式返回容器中所有绑定关系
  • getAlias($abstract) - 获取绑定别名指向的绑定名
  • forgetInstance($abstract) - 从容器中清除某个实例
  • forgetInstances() - 从容器中清除所有实例
  • flush() - 清除所有绑定关系与实例
  • setInstance() - 设置容器实例本身(Tip: setInstance(null)会删除容器自身实例,下次再引用容器时会新建一个)

Note: None of the methods in this last section are part of the Container interface.


This article was originally posted on DaveJamesMiller.com on 15 June 2017.

@funway
Copy link
Author

funway commented Apr 20, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment