如何理解 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 的源碼,因爲這樣能夠更加加深自己對其的理解。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章