Laravel框架 - 容器、绑定与依赖注入

Laravel框架 - 容器、绑定与依赖注入

laravel框架的核心概念就是容器,打开laravel的入口文件public/index.php,一路追寻,会发现laravel框架在初始化时会新建一个Illuminate\Foundation\Application实例对象赋值给$app这个全局变量。这个Application实例就是我们的laravel容器(Application是Container的子类),是整个laravel程序的基础:

屏幕快照 2016-06-21 下午10.44.35

 

一、容器

容器,就是用来装东西的,对吧。现实中的容器可以用来装水、装米,但编程世界中的容器装的则是一个个实例对象。举个例子,在程序运行过程中,我们可能需要一个Mailer实例来发送邮件,通常直观的做法是在需要的时候才创建这个Mailer实例,但我们也可以在程序初始化的时候先行创建一个Mailer实例,并在容器中注册该实例(laravel中叫做binding),随后在程序中任何需要用到Mailer的地方,就可以直接向容器申请这个实例(laravel叫做resolving)或者由容器进行自动依赖注入。

实际上laravel容器的绑定并不是真的绑定一个实例,而是绑定一个闭包函数,该闭包负责生成要绑定的实例对象。同时在绑定的时候定义是否为单例绑定(laravel中把单例绑定叫做shared)。然后直到程序向容器申请该实例的时候,才真正执行这个闭包函数去生成一个实例。对于非单例绑定,每次resolving都会生成一个新的实例;对于单例绑定,只在第一次resolving的时候生成实例,然后将该实例放入容器的instances数组中(Illuminate\Container\Container->instances),后面在申请时直接从该数组中返回实例。

(如果你有java的spring框架基础的话,那么就很好理解了。laravel容器就相当于spring容器,容器中绑定的实例对象相当于spring bean的概念,他们的目的都是为了依赖注入。另外,laravel的中间件也有点类似spring中切面的概念。推荐可以看一下Craig Walls写的《Spring实战》这本书,对于容器与依赖注入的概念描述得很棒。)

(或者,关于容器与依赖注入的概念,还可以参考这篇博文:https://www.insp.top/article/learn-laravel-container

1. 打印Laravel容器

https://www.hawu.me/coding/1132

2. binding

绑定操作通常在ServiceProvider的register()方法中定义,然后通过写入/config/app.php文件中的providers数组让laravel框架在初始化时自动加载。(当然,你也可以在程序其他地方直接调用bind()与singleton()方法进行绑定)

绑定的作用其实是告诉容器,在解析绑定时候如何生成需要的实例对象。如果一个类不依赖于任何其他类(即构造方法无参)或者它的构造器依赖可以被laravel解析,那么这个类就无需绑定到容器。容器不需要被告知如何生成这样的类对象,因为容器可以通过php的反射机制自动解析出具体对象。

However, there is no need to bind classes into the container if they do not depend on any interfaces. The container does not need to be instructed on how to build these objects, since it can automatically resolve such "concrete" objects using PHP's reflection services.

—— laravel官方文档

Container::bind()与Container::singleton()方法定义如下:

/**
 * Register a binding with the container.
 *
 * @param  string|array  $abstract          绑定名,即该绑定关系在容器中的id。
 * @param  \Closure|string|null  $concrete  要绑定的闭包函数,该闭包负责返回一个实例对象。
 * @param  bool  $shared                    是否为单例绑定。
 * @return void
 */
public function bind($abstract, $concrete = null, $shared = false) 
{
   // ...
}
public function singleton($abstract, $concrete = null)
{
    $this->bind($abstract, $concrete, true);
}

举个例子,假设我们在\app\Providers\AppServiceProvider.php的register()方法中绑定自定义的App\Common\Utility类:

    use App\Common\Utility as Utility;
    
    public function register()
    {
        $this->app->bind('utility', function ($app) {
            return new Utility();
        });
    }

首先在ServiceProvider派生类中我们总是可以通过$this->app 来访问容器。

然后在绑定的闭包函数function ($app) 中(laravel管这个闭包函数叫做resolver),这里的第一个参数是laravel“规定”的。实际上这个resolver应该是function($app, array $parameters) 这样的形式。laravel在解析绑定,执行resolver闭包函数时默认会传递两个实参给闭包函数。第一个实参就是容器本身,第二个实参是一个数组(可以查阅Container类的make()与makeWith()方法的源码)。这样我们就可以在resolver中通过$app实参引用容器了。另外如果在resolver中需要用到某些额外参数,就可以通过第二个数组实参传递进来。比如我们将Utility类改为需要一个参数才能构造,如下所示。

    use App\Common\Utility as Utility;
    
    public function register()
    {
        $this->app->bind('utility', function ($app, $paramaters) {
            // 假设Utililty类的构造函数需要一个参数
            return new Utility($paramaters['key1']);
        });
    }

那么在解析这个utility绑定时候,就得使用app()->make('utility', ['key1'=>'value1']); 或app()->makeWith('utility', ['key1'=>'value1'])

最后,由于php语法中函数实参的个数只要不小于形参个数即可。所以如果不需要用到额外参数与容器实例的话,我们甚至可以将resolver闭包的形参都去掉,只写作function () { return new Utility(); } 。

3. resolving

获取绑定对象,可以通过如下几种方法手工获取绑定对象:

// 1. 使用Container的make方法
$obj = $this->app->make('绑定名');   // 可以通过$this->app属性访问容器时
$obj = app()->make('绑定名');        // 当前类没有app属性时,可以使用app()全局辅助函数访问容器

// 2. 使用数组形式,Conatiner类实现了ArrayAccess接口。
$obj = $this->app['绑定名'];
$obj = app()['绑定名'];

// 3. 使用全局辅助函数app() 或者 resolve()。在Illuminate/Fundation/helpers.php中定义的
$obj = app('绑定名');                // app()函数在无参时返回整个容器的实例,有参数时则返回绑定的实例。
$obj = resolve('绑定名');

// 对于需要额外参数来解析绑定的时候
$obj = app('utility', ['key1'=>'xxx']);
$obj = resolve('utility', ['key1'=>'xxx']);
$obj = app()->make('utility', ['key1'=>'xxx']);    // 好像早期某些版本的make()不支持第二个参数,laravel 6是可以的。
$obj = app()->makeWith('utility', ['key1'=>'xxx']);

可以稍微看一下Container@make()方法的定义:

public function make($abstract, array $parameters = [])
{
    // 从aliases[]数组中取$abstract对应的绑定名(如果存在的话,不存在别名则直接使用$abstract)
    $abstract = $this->getAlias($this->normalize($abstract));

    // 如果要解析的实例已经存在于instances[]数组中(属于单例绑定),则返回对应实例。
    if (isset($this->instances[$abstract])) {
        return $this->instances[$abstract];
    }

    // 取得绑定的闭包函数
    $concrete = $this->getConcrete($abstract);

    // We're ready to instantiate an instance of the concrete type registered for
    // the binding. This will instantiate the types, as well as resolve any of
    // its "nested" dependencies recursively until all have gotten resolved.
    if ($this->isBuildable($concrete, $abstract)) {
        $object = $this->build($concrete, $parameters);
    } else {
        $object = $this->make($concrete, $parameters);
    }

    // ...省略

    // 如果该绑定是单例绑定,将新生成的实例放入instances[]数组
    if ($this->isShared($abstract)) {
        $this->instances[$abstract] = $object;
    }

    // 在resolved[]数组中标记该绑定为已解析过的
    $this->resolved[$abstract] = true;

    // 返回实例
    return $object;
}

4. 依赖注入

上面的resolving其实是程序员手动向容器申请解析绑定然后获得实例对象。更为常用的做法是通过在类的构造方法中对参数进行类型约束(type-hint,即指定参数类型)来使得laravel自动注入所依赖的参数对象,这就是laravel的依赖注入

不幸的是laravel官方文档实在是太渣,对依赖注入的原理只字未提,甚至连用法都没讲清楚=。=#

(1)laravel不仅支持对构造方法进行依赖注入,也支持对某些方法进行依赖注入(比如路由映射到的控制器方法),但laravel依赖注入有个基本前提就是这些方法必须是由laravel容器按照执行逻辑自动调用的。只有这样,laravel才能在调用方法时判断参数,并根据type-hint进行依赖注入。如果你在程序中手动调用这些方法,就别指望laravel帮你依赖注入了,你必须自己在调用时候手动传参。

(3)如果一个类不依赖于任何其他类(即构造方法无参),或者它的构造器依赖可以被laravel解析,那么这个类就无需绑定到容器,laravel可以在需要的时候通过php反射机制自动生成该类实例。

(2)type-hint必须是真实存在的类型。容器在进行依赖注入的时候,会先判断这个type-hint是否存在;如果该type-hint类型不存在,则直接返回ReflectionException;如果存在,再判断是否可以直接生成该类型实例;如果无法直接生成,再查找容器的bindings[]与aliases[]数组,查看是否有该类型的绑定关系;如果有,则从绑定关系中解析出该类型实例。

5. 绑定接口/抽象类

容器的bind() 方法除了可以将一个普通字符串作为绑定名外,还可以用接口名、抽象类的类名作为绑定名。甚至可以将一个实体类型绑定到其派生类。

// 将接口类型绑定到实体类
$container->bind(MyInterface::class, MyClass::class);

    // 后续要获取该“接口的实例“时候是需要make即可,实际返回的是实体类的实例
    $instance = $container->make(MyInterface::class);

// 将抽象类绑定到实体类
$container->bind(MyAbstract::class, MyConcreteClass::class);

// 将实体父类绑定到派生类
$container->bind(MySQLDatabase::class, CustomMySQLDatabase::class);

// 当然上面这些bind()的第二个参数也可以换成一个闭包函数,在闭包中返回实际绑定的类实例即可
$container->bind(DatabaseInterface::class, function () {
    return new MySQLDatabase(MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASS);
});

6. 解析回调(容器事件)

laravel官方文档把解析回调叫做container events。所谓解析回调就是指我们可以通过容器的resolving()方法注册一个回调函数,该函数将会在容器解析绑定,获得绑定实例后自动调用,并将得到的实例作为第一个参数传递给该函数。这样我们就可以将绑定实例构造好之后要执行初始化语句从resolver闭包函数挪到解析回调函数中:

$container->resolving(function ($object, $app) {
    // 容器解析任何一个绑定实例后都会自动调用
});

$container->resolving(\HelpSpot\API::class, function ($api, $app) {
    // 容器解析'\HelpSpot\API'的绑定后自动调用 ($container->make(\HelpSpot\API::class))
});

$container->resolving('utility', function ($utility, $app) {
    // 容器解析'utility'绑定后自动调用 ($container->make('utility'))
});

7. 更多用法

更多关于Laravel容器的用法,请查看我翻译的《Laravel's Dependency Injection Container in Depth》

二、举个例子🙌🌰

1. 定义一个测试类

在app\Common目录下定义一个App\Common\Utility类:

<?php

namespace App\Common;

class Utility
{
    public $createdTime;

    public function __construct()
    {
        $this->createdTime = microtime();
    }

    public function sayHi()
    {
        return 'hi, i have been created @ ' . $this->createdTime;
    }
}

2. 尝试绑定与解析

在app\Providers\AppServiceProvider.php的register()方法中绑定Utility类:

    use App\Common\Utility as Utility;
    
    public function register()
    {
        // 绑定到'utility'
        $this->app->bind('utility', function ($app) {
            return new Utility();
        });

        // 单例绑定到'sharedUtility'
        $this->app->singleton('sharedUtility', function ($app) {
            return new Utility();
        });
    }

然后app\Http\routes.php中添加一个路由方法:

Route::get('/bind', function () {
    echo '<h3>first utility binding:</h3>' . app('utility')->sayHi() . '<br>';
    sleep(1);
    echo '<h3>second utility binding:</h3>' . app('utility')->sayHi() . '<br>';
    sleep(1);
    echo '<h3>first shared utility binding:</h3>' . app('sharedUtility')->sayHi() . '<br>';
    sleep(1);
    echo '<h3>second shared utility binding:</h3>' . app('sharedUtility')->sayHi() . '<br>';
    echo '<hr>';
    dd(app());
});

这时再访问http://localhost:8000/bind页面,返回结果如下:

屏幕快照 2016-06-26 上午10.02.11

从上图结果中,我们可以看出,两次请求解析‘utility’绑定得到实例各不相同,而两次请求‘sharedUtility’绑定得到是同一个实例。在Application->bindings[]数组中可以发现这两个绑定;在Application->instances[]数组中,可以看到‘sharedUtility’实例。

3. 尝试依赖注入

3.1 在app\Http\routes.php添加一个路由方法:

Route::get('di', function (\App\Common\Utility $utl) {
    echo '<h3>Dependency Injected:</h3>' . $utl->sayHi();

    echo '<hr>';
    dd(app());
});

这个路由方法有个参数$utl,并且给定了该参数的type-hint为App\Common\Utility类。这样,当laravel根据执行逻辑执行到这个路由方法时,首先尝试解析这个App\Common\Utility类,因为该类型不依赖于其他类(构造器无参),所以laravel通过反射机制直接生成该类型的实例对象,并赋予$utl参数。这样就完成了一次依赖注入!并且是一次无需绑定的依赖注入(你可以事先把第2步中在AppServiceProvider定义的‘utility’与‘sharedUtility’绑定删除掉)。

访问http://localhost:8000/di,结果如下:

屏幕快照 2016-06-26 上午10.30.22

PS:此时,可以用app('App\Common\Utility')来make一个Utility实例,等效于new App\Common\Utility,当然这样make是有些多此一举了,我只是为了说明容器有这个能力。

 

3.2 然后我们修改Utility类的构造器定义,给他加上一个依赖关系,即参数$app:

    public function __construct($name)
    {
        echo 'my name is '.$name;
        $this->createdTime = microtime();
    }

并且由于我们没有给$name参数一个type-hint,所以laravel容器无法解析出Utility构造方法的依赖来进行自动注入,那么此时再访问我们的di路由就会报错。

3.3 为了让laravel能够解析出Utility类,我们必须为其添加一个绑定,修改AppServiceProvider的register()方法:

    use App\Common\Utility as Utility;

    public function register()
    {
        // 直接绑定到'App\Common\Utility'名上
        $this->app->bind('App\Common\Utility', function($app) {
            return new Utility('xxx');
        });
    }

或者:

    use App\Common\Utility as Utility;
    public function register()
    {
        // 先绑定到‘utility’名上
        $this->app->bind('utility', function ($app) {
            return new Utility('yyy');
        });
        
        // 然后为‘utility’设置一个别名‘App\Coomon\Utility’ 
        // 其实这个“别名”并不是指php的类别名(class_alias),跟/config/app.php文件中的aliases数组没关系。
        // 是指在Laravel容器的aliases数组中注册 “类名App\Common\Utility => 绑定名utility” 的对应关系
        $this->app->alias('utility', 'App\Common\Utility');
    }

这样,当laravel发现无法直接解析出App\Common\Utility类型后,就会去容器的bindings[]与aliases[]数组中查找有没绑定过该类型,有的话则通过绑定关系解析出该类型的实例,赋值给$utl参数。这就完成了一次通过绑定解析的依赖注入。此时再访问di路由就能正常打印结果了。

屏幕快照 2016-06-26 上午11.05.24

 

三、Service Providers

ServiceProvider的核心是register()方法,在register()方法中只允许绑定对象到容器中,不允许在其中注册事件监听、路由等。

注意:ServiceProvider本身并不提供服务,实际提供“服务”的其实是绑定的对象。

 

四、Facades

Facade是指用看起来像调用类的静态方法的语法(类名::静态方法)调用容器中对应实例的同名公有方法的一种用法。

1. 用例

举个例子,假如容器中已绑定Illuminate\Cache\CacheManage对象到'cache'名字上。现在我们要调用他的get方法,可以这样写:

// 使用make方法
$value = $this->$app->make('cache')->get('key');

// 或者使用数组形式
$value = $this->$app['cache']->get('key');

// 或者使用全局辅助函数app()
$value = app('cache')->get('key');

这时候Facades跳出来说,我可以这么写:

use Cache;
/**
 * ...
 */
$value = Cache::get('key');

2. 运行机制

那么Cache::get('key') 是如何调用到容器中的CacheManage实例的呢?

首先,在/config/app.php的alias数组中我们可以找到Cache别名'Cache' => Illuminate\Support\Facades\Cache::class 。所以Cache::get()实际上就是Illuminate\Support\Facades\Cache::get()

那么我们再看...\Facades\Cache.php源码,所有Facade类的派生类都必须重写getFacadeAccessor()方法,该方法必须返回一个字符串,且该字符串指向容器中的一个绑定名。这样就可以通过这个facade来找到容器中绑定的对应实例了。

class Cache extends Facade
{
    protected static function getFacadeAccessor()
    {
        return 'cache';
    }
}

然后,我们可以通过dd(app()); 查到在容器中绑定名'cache'关联的provider是CacheServiceProvider,在CacheServiceProvider的register()方法中将'cache'绑定到一个Illuminate\Cache\CacheManager类的共享实例上。

最后,facede基类...\Facades\Facade重写了php的__callStatic($method, $args) 魔术方法。系统在遇到Illuminate\Support\Facades\Cache::get()时候,由于找不到get() 这个静态方法,就会自动调用Facade::__callStatic() 。在__callStatic($method, $args) 中就会自动调用其getFacadeAccessor() 关联实例的同名公有方法(不是静态方法,更不能是protected,private方法)。

所以,Facades\Cache::get() 实际上调用的是Cache\CacheManager->get() 。

更详细的可以参考该文章:http://blog.csdn.net/hel12he/article/details/46620519

 

PS:其实我有一个疑问,java中用到容器是为了让那些常用的对象可以常驻内存,不用在每次请求的时候初始化,对吧?但php是脚本语言,执行完请求就释放内存了呀?每次请求不都照样得初始化容器,绑定“bean”,然后执行请求,执行完后又释放掉。既然无法常驻内存,那还要容器用来干嘛?=。=#

2016.06.22 好吧,其实spring容器的根本目的也是为了实现依赖注入,并不是为了让spring bean常驻内存,只是默认情况下spring bean对象是以单例模式常驻在spring容器的内存中。但是spring bean可选有几种生命周期,比如request模式的spring bean就相当于laravel容器中绑定的单例对象了。

2020.04,后来重新看laravel 6的时候,顺便还翻译了一篇老外的关于laravel容器的文章:《Laravel's Dependency Injection Container in Depth》

3 thoughts on “Laravel框架 - 容器、绑定与依赖注入

  1. 风起

    解析的很到位,深成次的设计思路都讲出来了。
    另外对于:既然无法常驻内存,那还要容器用来干嘛?=。=#,这里其实可以考虑对于一个执行链路很长的请求,已经被容器解析且是单例的可以复用,这其实已经达到目的了,对于不能单例复用的,注入让代码优雅了,其实还是有必要性的。
    我这有一个极端的案例,最近在优化一个系统的时候,使用容器单例优化,大体是:以前使用redis调用各个模块的数据,后来这些模块拆出去成一个个服务了,就变成了网络调用,但是仅仅是把redis的地方改成了curl,然而执行链路又比较长,又封装了各自的方法,就会有重复获取数据的情况,以前redis感知不明显,现在不一样,如果改造的话,需要动很多地方,所以我干脆直接对这些数据获取进行单例容器复用,10次重复调用其实只发起一个网络请求,比如xhprof分析散落在各处有10次获取一个人的用户信息,改造后大部分接口都受益。。。

Leave a Reply

Your email address will not be published. Required fields are marked *

TOC