如何理解 Laravel 和 ThinkPHP 5 中的服务容器与注入?

从文档说起

很多人一开始看到官方的文档,无论是 Laravel 还是 ThinkPHP ,看完都是一头雾水,不求甚解。甚至都是直接跳过去,不看,反正我也不一样用得到这么高端的东西,如果在短时间内有这个念头很正常,尤其是习惯了 ThinkPHP 3 的使用者,相对引入的理念比较前沿,如果你在长时间内都不去考虑去理解,那就要看你自己的职业规划了。
接下来就来一起看一下,细细追品。

从 Laravel 开始

从 Laravel 的文档中看到有 bindsingleton 以及 instance ,这三个常用方法,接下来就一一解答。

实际应用

假设我们有这样一个场景,当我们用户在进行注册时,我们需要向用户手机发送一条短信验证码,然后当用户收到验证码后在注册表单提交时还需要验证验证码是否正确。

这个需求看起来非常容易实现,对吧?

当我们拿到短信平台的开发文档后,我们只需要写出两个方法。sendcheck 分别用来发送验证码和校验验证码,下面就在不用容器的情况下来写一下伪代码。

  • MeiSms.php
<?php


namespace App\Tools;


class MeiSms
{
    public function send($phone)
    {
        $code = mt_rand(1e3, 1e4 - 1);
        // TODO ... 通过接口发送
        // 存放验证码到 Redis
        $cacheManager = cache();
        // 设定 5 分钟失效
        $cacheManager->set('sms:' . $phone, $code, 5 * 60);
        return true;
    }

    public function check($phone, $code)
    {
        $cacheManager = cache();
        return $cacheManager->get('sms:' . $phone) === $code;
    }
}

很容易,不是吗?

然后在控制器中 new 一个 MeiSms 的实例,直接调用 sendcheck 就可以分别发送和检查验证码了。

但是,如果运营突然反馈说,之前给的短信平台不可靠,发送短信不稳定,用户经常收不到。

这时候我们就需要换一个接口,常见的方式就是 我们再写一个对象, 然后 又可能这个代码是别人写的让你来接收,你觉得 send 或者 check 这个方法名不够规范,然后你就给改了,然后顺带把原来的注册那边一并改了,然后代码就突突上线,跑起来了。

然后没过多久,运营又觉得这个平台的短信太贵了,另外又找到了一家既便宜又稳定的一家,然后你又重复了上面的事情,这次,方法这些你都觉得很完美,不用改动。

只是需要写一写方法体,然后在调用的地方改一些new 时的类名。

当然,这只是一个小例子,开发过程中我们可能还会遇到比这复杂的多的改动,又或者,运营又想让你换回之前的版本?emm。

在这里,如果有了解过简单工厂模式的朋友,可能会想到我可以使用简单工厂模式来搞定这个啊。

function factory($name)
{
    $modules = [
        'sms' => new MeiSms(),
    ];
    if (!isset($modules[$name])) {
        throw new \Exception('对象不存在。');
    }
    return $modules[$name];
}

在需要的地方直接调用 factory('sms') 这样就能拿到一个 发送短信的对象,当需求改了后,我直接改造一下工厂就好了,不是也简单了很多。

但是,到这里你会发现一些问题,工厂生产出来的对象没有类型提示,而且我们在工厂内没办法限制类必须要实现哪些方法(当然你可以把工厂搞的更复杂,加上接口校验),但是到头来你会发现,这里我们最初要做的事儿越来越远,而且,越来越复杂,不是吗?而且,工厂也不是那么的易用。

服务容器

这里就要看回我们的 服务容器 ,首先我们先看看控制器的文档中关于依赖注入部分的说明,这也是很多人最开始了解到 依赖注入 的地方。

  • 构造函数注入
Laravel 服务容器 解析所有的控制器。因此,你可以在控制器的构造函数中使用类型提示可能需要的依赖项。依赖声明会被自动解析并注入到控制器实例
  • 方法注入
处理构造函数注入,你还可以在控制器方法中输入类型提示依赖项。方法注入最常见的用例是在控制器方法中注入 Illuminate\Http\Request 的实例

当我们每次创建一个控制器方法,都会主动填写第一个参数,即 Request $request 你是否有注意过那个 Request 的参数呢?是不是很神奇呢?

为什么我什么都没有做,我就可以使用它, 而且,着并不限于 Laravel 内置的对象,我们自己写的对象也是可以的,而且在使用 IDE 开发时 我们还可以方便的使用类型提示,这些工作,就是 服务容器 帮我们做的。

当容器解析到这个方法时,当方法存在,就会用反射,来解析这个方法中需要的参数以及参数的类型。
ReflectionFunctionAbstract::getParameters,然后在容器中查找我们是否 bind 有这个类型,如果没有,就继续使用容器去创建这个类(因为这个类的构造方法中可能还会依赖其他的类)直到所依赖的类实例化完成,并且,把实例存到容器。

到这里你是不是觉得服务容器没有卵用?就是帮我们递归实例化类而已。那你就太年轻了,既然上面说到了我们要用 服务容器 来解决 简单工厂 所解决的问题,难倒我还会骗你不成?哈哈

这里就要开始说第一个了 bind

bind 方法

首先 我们来看一下 Laravel 中 bind 方法的实现。

    /**
     * Register a binding with the container.
     *
     * @param  string  $abstract
     * @param  \Closure|string|null  $concrete
     * @param  bool  $shared
     * @return void
     */
    public function bind($abstract, $concrete = null, $shared = false)
    {
        // If no concrete type was given, we will simply set the concrete type to the
        // abstract type. After that, the concrete type to be registered as shared
        // without being forced to state their classes in both of the parameters.
        $this->dropStaleInstances($abstract);

        if (is_null($concrete)) {
            $concrete = $abstract;
        }

        // If the factory is not a Closure, it means it is just a class name which is
        // bound into this container to the abstract type and we will just wrap it
        // up inside its own Closure to give us more convenience when extending.
        if (! $concrete instanceof Closure) {
            $concrete = $this->getClosure($abstract, $concrete);
        }

        $this->bindings[$abstract] = compact('concrete', 'shared');

        // If the abstract type was already resolved in this container we'll fire the
        // rebound listener so that any objects which have already gotten resolved
        // can have their copy of the object updated via the listener callbacks.
        if ($this->resolved($abstract)) {
            $this->rebound($abstract);
        }
    }
    /**
     * Drop all of the stale instances and aliases.
     *
     * @param  string  $abstract
     * @return void
     */
    protected function dropStaleInstances($abstract)
    {
        unset($this->instances[$abstract], $this->aliases[$abstract]);
    }

    /**
     * Get the Closure to be used when building a type.
     *
     * @param  string  $abstract
     * @param  string  $concrete
     * @return \Closure
     */
    protected function getClosure($abstract, $concrete)
    {
        return function ($container, $parameters = []) use ($abstract, $concrete) {
            if ($abstract == $concrete) {
                return $container->build($concrete);
            }

            return $container->make($concrete, $parameters);
        };
    }

在一开始就调用了 $this->dropStaleInstances($abstract); 追踪源码我们看到,他直接删除了 第一个参数对应的已经存在的实例和别名。

然后接着往下,当 $concrete 不是一个 Closure (匿名方法) 时,他会去做一些包装处理成一个匿名方法,最后存入了 bindings 这个属性,键为 $abstract,值是一个数组,其中 concrete 是包装后的方法,然后调用了容器的 make 。。
当运行到这个位置

if ($this->resolved($abstract)) {
    $this->rebound($abstract);
}

会先去判断这个是否已经解析过了,进行更新已经存放在容器中的副本。

这就是 bind 所干的事儿,描述简单点儿,就是给一个类、类实例、匿名方法提供了一个别名绑定到了容器中去。

当我们使用 resolve 传入刚刚的别名时就能解析拿到我们之前绑定的实例。

赶紧来试试?

我们先打开 bootstrap/app.php,可以看到一开始,就创建了一个 Application 的实例,我们就试着在 $app 被 return 前面给绑定一下。

  • bootstrap/app.php:54
$app->bind('hello', \App\Tools\MeiSms::class);
return $app;
  • routes/web.php:23
Route::any('hello', function ()
{
    $resolve = resolve('hello');
    var_dump(get_class($resolve));
});

打开浏览器看一下

clipboard.png

不错吧,但是到了这里,我们只是做到了和简单工厂差不多的事情,接下来我们改造一下我们的短信类。

首先,我们约定一个接口,短信验证必须要发送短信和验证短信验证码两个方法,分别为 send($phone)check($phone,$code) 方法。

  • Sms.php
<?php


namespace App\Contracts\Interfaces;


interface Sms
{
    /**
     * @param string $phone 手机号
     * @return bool 是否发送成功
     */
    public function send(string $phone): bool;

    /**
     * @param string $phone 手机号
     * @param string $code 用户填写的验证码
     * @return bool 是否验证通过
     */
    public function check(string $phone, string $code): bool;
}

然后,我们用把之前的 MeiSms 类实现实现这个接口。

  • MeiSms.php
<?php


namespace App\Tools;


use App\Contracts\Interfaces\Sms;

class MeiSms implements Sms
{
    public function send(string $phone): bool
    {
        $code = mt_rand(1e3, 1e4 - 1);
        // TODO ... 通过接口发送
        // 存放验证码到 Redis
        $cacheManager = cache();
        // 设定 60 分钟失效
        $cacheManager->set('sms:' . $phone, $code, 5 * 60);
        return true;
    }

    public function check(string $phone, string $code): bool
    {
        $cacheManager = cache();
        return $cacheManager->get('sms:' . $phone) === $code;
    }
}

现在,我们再去 bootstrap/app.php 中注册,这次就跟以前的有点儿不一样了。

$app->bind(\App\Contracts\Interfaces\Sms::class, \App\Tools\MeiSms::class);
return $app;

可以看到,我们的第一个参数传递的时 Sms 的接口,要绑定上去的时 MeiSms 类,接着我们改造一下路由。

  • web.php
Route::any('hello', function (\App\Contracts\Interfaces\Sms $sms)
{
    var_dump(get_class($sms));
});
  • 结果

clipboard.png

你是不是拿刚刚的截图骗我?上面明明限定的是 \App\Contracts\Interfaces\Sms 怎么 打印出来的是 \App\Tools\MeiSms ,代码居然没有报错?

别惊讶,首先 \App\Tools\MeiSms 已经实现了 \App\Contracts\Interfaces\Sms 接口,所以在接口限定类型这是合法。

而因为这个方法调用时通过,容器进行调用的,容器会调用内部的 make 方法 进行一系列的依赖注入处理,当获取到方法需要一个 \App\Contracts\Interfaces\Sms 类型的参数时,容器将类名字符串到已经绑定中去查找,因为我们已经再前面注册过,所以就相当于实现了给类一个别名,最终还是由 \App\Tools\MeiSms 来执行结果给我们。

好处

那么回到议题,我们在考虑我们之前遇到问题,看上去已经解决了,那相比之前的方法有什么好处呢,一个个来讲。

  • 更好的规范
因为我们在路由那里限定了接口限定,所以我们不用再担心调用 send 或者 check 不存在了
不用再担心因为 send 或者 check 方法 返回值参数不知道怎么判断结果了(因为我们已经限定了只能返回 bool 值)
  • 不再改动原来的业务代码,更少的 bug
是的,没错。我们不再需要去改动现有的业务代码,只需要把新加入的类实现接口后绑定到容器即可,其他的都没有发生改变。
  • 更好的测试

补缺

当然,说到这里,你可能觉得我少说了什么东西。

singleton 方法

其实从源码很容易看到,singleton 还是调用了 bind 方法,只是 shared 参数 不一样,表示绑定了一个单例对象。

    /**
     * Register a shared binding in the container.
     *
     * @param  string  $abstract
     * @param  \Closure|string|null  $concrete
     * @return void
     */
    public function singleton($abstract, $concrete = null)
    {
        $this->bind($abstract, $concrete, true);
    }

instance 方法。

这个和 bind 几乎一样,只是 bind 可以绑定一个匿名方法或者直接类名(内部会处理)。
而 instance 正如其名字,用来绑定一个实例到容器里面去。

结束

在小范围看来,服务容器工厂模式有很多相似的地方,但是服务容器会让你接触到 PHP 的另一个知识块 反射,这个强大的 API 。

其实我也没想通,我为啥要用 Laravel 来举例写这篇文章。
因为看了一下 ThinkPHP 的实现,相对要好读 容易一些。
其实一开始我是准备把两个框架都说一下,但是感觉都又差不多,就挑了 Laravel ,虽然其中要复杂些,甚至很多点都没有照顾到,但是我还是把这个文章写出来了,不是吗?

也希望,这个文章对你在了解服务容器方面有所帮助。当然,我更加推荐你去 Laravel 或者 ThinkPHP 的源码,因为这样能够更加加深自己对其的理解。

补充

2019年11月9日

推荐扩展阅读

参考资料

阅读 1.2k更新于 11月9日
推荐阅读

记录收集一些开发中遇到的奇技淫巧和坑

30 人关注
10 篇文章
专栏主页
目录