讀 source code 研究 Laravel IoC Container 實作的一點心得

前陣子在SO回答了一個問題

http://stackoverflow.com/questions/27341595/contracts-in-laravel-5/29812568

這問題不難,官方文件就有寫。

過幾天,下面的comments接著有網友 amosmos 提問 IoC Container 實作細節的問題。

他的問題考倒我了,完全無法回答。

於是花了幾天讀原始碼,最後終於搞懂了。

跟大家分享一下這個心得,也順便分享下我在過程中,讀原始碼的整套思路。

正文開始

簡單來說,網友 amosmos 的問題如下:

在 Illuminate/Foundation/Application.php 檔,會在 function registerCoreContainerAliases 看到這些:

        $aliases = [
            'app'                  => ['Illuminate\Foundation\Application', 'Illuminate\Contracts\Container\Container', 'Illuminate\Contracts\Foundation\Application'],
            'auth'                 => 'Illuminate\Auth\AuthManager',
            'auth.driver'          => ['Illuminate\Auth\Guard', 'Illuminate\Contracts\Auth\Guard'],
            'auth.password.tokens' => 'Illuminate\Auth\Passwords\TokenRepositoryInterface',
            'blade.compiler'       => 'Illuminate\View\Compilers\BladeCompiler',
            'cache'                => ['Illuminate\Cache\CacheManager', 'Illuminate\Contracts\Cache\Factory'],
            'cache.store'          => ['Illuminate\Cache\Repository', 'Illuminate\Contracts\Cache\Repository'],
            // ...
        ];

讓我們先稱呼’app’、’auth’這些字串為 IoC Container 內的 「key」 好了。

乍看之下,大部份的 service 都在這時候被綁定到 IoC Container內,對應到某個 key。

看起來非常合理,也很漂亮,對吧?Laravel會用到的service都在這時候統一登記好、綁進這IoC Container。

問題來了,在 config/app.php 內,有這個陣列:

    'providers' => [

        /*
         * Laravel Framework Service Providers...
         */
        Illuminate\Foundation\Providers\ArtisanServiceProvider::class,
        Illuminate\Auth\AuthServiceProvider::class,
        Illuminate\Broadcasting\BroadcastServiceProvider::class,
        Illuminate\Bus\BusServiceProvider::class,
        Illuminate\Cache\CacheServiceProvider::class,
        Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
        // ...
    ];

你如果熟悉 Service Provider (下文以SP代稱) 的話,就會知道每個 SP 內必定有 function register,負責綁定service到container。

以CacheServiceProvider來說,會出現這幾行:

    public function register()
    {
        $this->app->singleton('cache', function ($app) {
            return new CacheManager($app);
        });

        //...
    }

問題來了,在上面的 Application.php,已經有這個了:

            'cache'                => ['Illuminate\Cache\CacheManager', 'Illuminate\Contracts\Cache\Factory'],

所以這CacheManager,到底是何時被綁定到 ‘cache’ 這個key上的?

是Application在建構式呼叫registerCoreContainerAliases時綁定的嗎?

還是在某處讀取 config/app.php 的providers時綁定的?

這邊出現了我們的問題一:

Q1: 難道重複綁定了兩次嗎?CacheManager被初始化了兩次?所以是 Laravel 原始碼的瑕疵?

讓我們做個實驗吧,把Application.php裡面這行comment掉:

            //'cache'                => ['Illuminate\Cache\CacheManager', 'Illuminate\Contracts\Cache\Factory'],

接著在cmd輸入

php artisan tinker

很好,跑起來了,至少laravel沒有炸掉。

接著試試cache功能是否還活著?

Cache::store('file')->put('foo', 'this is foo in the cache', 10);

Cache::store('file')->get('foo');
// => "this is foo in the cache"

看起來沒問題!難道真的是Laravel的瑕疵?

接著把 Application.php恢復原狀,然後把CacheServiceProvider的綁定給comment掉:

    public function register()
    {
        /*
        $this->app->singleton('cache', function ($app) {
            return new CacheManager($app);
        });
       */

接著在cmd輸入

php artisan tinker

[ReflectionException]
Class cache does not exist

Laravel炸掉了!根本不能跑!

所以不算是重複綁兩次?

那就出現了我們的問題二:

Q2: 為什麼 Application.php 內的key給comment掉,功能還是正常運作?

反覆翻閱一下 Application.php、Illuminate\Container\Container.php,會發現 function registerCoreContainerAliases 似乎將整串陣列存在container的成員變數$aliases上?

接著尋找讀取config\app.php的地方,會找到 Illuminate\Foundation\Bootstrap\LoadConfiguration.php這檔案,然後發現Illuminate\Foundation\Bootstrap\RegisterProviders.php這檔案,翻閱Application.php和 Illuminate\Foundation\ProviderRepository.php之後,會發現config\app.php內的providers似乎會存在container的成員變數$bindings上?

有一個很簡單的方法去驗證這件事情。

將 Illuminate\Container\Container.php 的 $aliases 與 $bindings 成員變數改成 public,接著 php artisan tinker

$app = App::make('app');

$app->aliases;
=> [
     "Illuminate\Foundation\Application" => "app",
     "Illuminate\Contracts\Container\Container" => "app",
     "Illuminate\Contracts\Foundation\Application" => "app",
     "Illuminate\Auth\AuthManager" => "auth",
     "Illuminate\Auth\Guard" => "auth.driver",
     "Illuminate\Contracts\Auth\Guard" => "auth.driver",
     //...

$app->bindings;
=> [
     //...
     "command.migrate.refresh" => [
       "concrete" => Closure {#236
         class: "Illuminate\Database\MigrationServiceProvider",
         this: Illuminate\Database\MigrationServiceProvider {#230 …},
         file: "/home/howtomakeaturn/projects/modmaj.com/vendor/laravel/framework/src/Illuminate/Database/MigrationServiceProvider.php",
         line: "144 to 146",
       },
       "shared" => true,
     ],
     "command.migrate.install" => [
       "concrete" => Closure {#237
         class: "Illuminate\Database\MigrationServiceProvider",
         this: Illuminate\Database\MigrationServiceProvider {#230 …},
         parameters: [
           "$app" => [],
         ],
         file: "/home/howtomakeaturn/projects/modmaj.com/vendor/laravel/framework/src/Illuminate/Database/MigrationServiceProvider.php",
         line: "168 to 170",
       },
       "shared" => true,
     ],
     // ...

會發現的確是這樣沒錯!

事到如今,我們會發現Q1的問題解決了:Application.php是存在aliases內,而config\app.php是存在bindings內。並沒有重複綁定兩次的問題!而是container實作細節沒搞清楚的問題!

所以真正的問題應該是這個:

Q3: IoC Container內的$aliases跟$bindings分別是什麼?兩者如何互動?

要解決這個問題,必須去讀container最核心的function make部份程式碼。


/**
 * Resolve the given type from the container.
 *
 * @param  string  $abstract
 * @param  array   $parameters
 * @return mixed
 */

// 首先注意到的是,之前我們稱呼’app’、’auth’這些字串為 IoC Container 內的 「key」
// 其實正確名稱是「abstract」 
public function make($abstract, array $parameters = [])
{

    // 這個getAlias的功能如下
    // >>> $app->getAlias('app');
    // => "app"
    // >>> $app->getAlias('Illuminate\Foundation\Application');
    // => "app"    
    $abstract = $this->getAlias($abstract);

    // If an instance of the type is currently being managed as a singleton we'll
    // just return an existing instance instead of instantiating new instances
    // so the developer can keep using the same objects instance every time.

    // 如果是用singleton或是instance方法,應該是會把物件存在instances陣列
    //這地方會檢查abstract是否真的跟物件綁定,是的話就回傳,收工
    if (isset($this->instances[$abstract])) {
        return $this->instances[$abstract];
    }

    // getConcrete內會去查 $this->bindings[$abstract]['concrete']
    // 尋找這abstract對應到的是哪個類別、或是Closure
    $concrete = $this->getConcrete($abstract);

    // 後面一大段省略
}

看到這邊,我們幾乎可以確定$aliases跟$bindings的關係為何了:

我們在使用 App::make($abstract) 時,輸入的abstract,實際上主要是去bindings找對應並叫出來。

而aliases只是紀錄abstract的其他別名而已,舉例如下:

>>> App::make('cache');
=> Illuminate\Cache\CacheManager {#431}

>>> App::make('Illuminate\Cache\CacheManager');
=> Illuminate\Cache\CacheManager {#431}

>>> App::make('Illuminate\Contracts\Cache\Factory');
=> Illuminate\Cache\CacheManager {#431}

還可以再做進一步的測試,將Container的function alias改成public,然後做以下測試:

$app = App::make('app');

$app->bind('greet', function(){ return 'Hello World!'; });

$app->make('greet');
// => "Hello World!"

$app->make('GreetClass');
// ReflectionException with message 'Class GreetClass does not exist'

$app->alias('greet', 'GreetClass');

$app->make('GreetClass');
// => "Hello World!"

所以正式回答Q3的問題:

Service Provider的function register內會去登記到IoC Container的abstract,實際上是存在$bindings或$instances陣列。

而App::make($abstract)實際上也是從這兩個陣列找東西出來用。

$aliases陣列只是存abstract的其他別名,讓 Binding Interfaces To Implementations 成為可能,也允許直接打類別全名進去。

結論

讀Laravel原始碼有幾個小技巧

1. 多用php artisan tinker去跟laravel互動

2. 把一些source code的變數、函數改成public,在tinker硬倒出來觀察

3. 有些東西很難在一天內搞清楚,睡個覺醒來再看通常很有幫助

(完)