Laravel Container (容器) 深入理解 (下)

Ίκαρος發表於2017-09-20

本文大部分翻譯自 DAVE JAMES MILLER 的 《Laravel’s Dependency Injection Container in Depth》

上文介紹了 Dependency Injection Containers (容器) 的基本概念,現在接著深入講解 LaravelContainer

Laravel 中實現的 Inversion of Control (IoC) / Dependency Injection (DI) Container 非常強悍,但文件中很低調的沒有細講它。

本文中示例基於 Laravel 5.5 ,其它版本差不多。

準備工作

Dependency Injection

關於 DI 請看這篇 《Laravel Dependency Injection (依賴注入) 概念詳解》

初識 Container

Laravel 中有一大堆訪問 Container 例項的姿勢,比如最簡單的:

$container = app();

但我們還是先關注下 Container 類本身。

Laravel 官方文件中一般使用 $this->app 代替 $container。它是 Application 類的例項,而 Application 類繼承自 Container 類。

在 Laravel 之外使用 Illuminate\Container

mkdir container && cd container
composer require illuminate/container
// 新建一個 container.php,檔名隨便取
<?php
include './vendor/autoload.php';

use Illuminate\Container\Container;
$container = Container::getInstance();

Container 的技能們

Q. 基本用法,用type hint (型別提示) 注入 依賴:

只需要在自己類的建構函式中使用 type hint 就實現 DI

class MyClass
{
    private $dependency;

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

接下來用 Containermake 方法來代替 new MyClass:

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

Container 會自動例項化依賴的物件,所以它等同於:

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

如果 AnotherClass 也有 依賴,那麼 Container 會遞迴注入它所需的依賴。

Container 使用 Reflection (反射) 來找到並例項化建構函式引數中的那些類,實現起來並不複雜,以後的文章裡再介紹。

實戰

下面是 PHP-DI 文件 中的一個例子,它分離了「使用者註冊」和「發郵件」的過程:

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)
    {
        // 建立使用者賬戶
        // ...

        // 給使用者的郵箱發個 “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!');

W. Binding Interfaces to Implementations (繫結介面到實現)

Container 可以輕鬆地寫一個介面,然後在執行時例項化一個具體的例項。 首先定義介面:

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

然後宣告實現這些介面的具體類。下面這個類不但實現了一個介面,還依賴了實現另一個介面的類例項:

class MyClass implements MyInterface
{
    private $dependency;

    // 依賴了一個實現 AnotherInterface 介面的類的例項
    public function __construct(AnotherInterface $dependency)
    {
        $this->dependency = $dependency;
    }
}

現在用 Containerbind() 方法來讓每個 介面 和實現它的類一一對應起來:

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

最後,用 介面名 而不是 類名 來傳給 make():

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

注意:如果你忘記繫結它們,會導致一個 Fatal Error:"Uncaught ReflectionException: Class MyInterface does not exist"。

實戰

下面是可封裝的 Cache 層:

interface 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()
    {
        // 去快取裡查詢
        $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();

這裡用 Redis 做快取,如果改用其他快取,只要把 RedisCache 換成別的就行了,easy!

E:Binding Abstract & Concret Classes (繫結抽象類和具體類):

繫結還可以用在抽象類:

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

或者繼承的類中:

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

R:自定義繫結

如果類需要一些附加的配置項,可以把 bind() 方法中的第二個引數換成 Closure (閉包函式)

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

閉包也可用於定製 具體類 的例項化方式:

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

T:Resolving Callbacks (回撥)

可用 resolveing() 方法來註冊一個 callback (回撥函式),而不是直接覆蓋掉之前的 繫結。 這個函式會在繫結的類解析完成之後呼叫。

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

如果有一大堆 callbacks,他們全部都會被呼叫。對於 介面抽象類 也可以這麼用:

$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);

diao 的是,還可以註冊成「什麼類解析完之後都呼叫」:

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

但這個估計只有 loggingdebugging 才會用到。

Y:Extending a Class (擴充套件一個類)

使用 extend() 方法,可以封裝一個類然後返回一個不同的物件 (裝飾模式):

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

注意:這兩個類要實現相同的 介面,不然用型別提示的時候會出錯:

interface Getable
{
    public function get();
}
class APIClient implements Getable
{
    public function get()
    {
        return 'yes!';
    }
}
class APIClientDecorator implements Getable
{
    private $client;

    public function __construct(APIClient $client)
    {
        $this->client = $client;
    }

    public function get()
    {
        return 'no!';
    }
}
class User
{
    private $client;

    public function __construct(Getable $client)
    {
        $this->client = $client;
    }
}
$container->extend(APIClient::class, function ($client, Container $container) {
    return new APIClientDecorator($client);
});
//
$container->bind(Getable::class, APIClient::class);

// 此時 $instance 的 $client 屬性已經是 APIClentDecorator 型別了
$instance = $container->make(User::class);

U:單例

使用 bind() 方法繫結後,每次解析時都會新例項化一個物件(或重新呼叫閉包),如果想獲取 單例 ,則用 singleton() 方法代替 bind()

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

繫結單例 閉包

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

繫結 具體類 的時候,不需要第二個引數:

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

在每種情況下,單例 物件將在第一次需要時建立,然後在後續重複使用。
如果你已經有一個 例項 並且想重複使用,可以用 instance() 方法。 Laravel 就是用這種方法來確保每次獲取到的都是同一個 Container 例項:

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

I:Arbitrary Binding Names (任意繫結名稱)

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);

O:儲存任何值

Container 還可以用來儲存任何值,例如 configuration 資料:

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

它支援陣列訪問語法,這樣用起來更自然:

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

這是因為 Container 實現了 PHP 的 ArrayAccess 介面。

當處理 Closure 繫結的時候,你會發現這個方式非常好用:

$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 用了。

陣列訪問語法還可以代替 make() 來例項化物件:

$db = $container['database'];

P:Dependency Injection for Functions & Methods (給函式或方法注入依賴)

除了給建構函式注入依賴,Laravel 還可以往任意函式中注入:

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]);

都可以注入。

A: 呼叫例項方法的快捷方式

使用 ClassName@methodName 語法可以快捷呼叫例項中的方法:

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

因為Container 被用來例項化類。意味著:

  1. 依賴 被注入進建構函式(或者方法);
  2. 如果需要複用例項,可以定義為單例;
  3. 可以用介面或任何名稱來代替具體類。

所以這樣呼叫也可以生效:

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

最後,還可以傳一個「預設方法」作為第三個引數。如果第一個引數是沒有指定方法的類名稱,則將呼叫預設方法。 Laravel 用這種方式來處理 event handlers :

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

// 相當於:
$container->call('MyEventHandler@handle', $parameters);

S:Method Call Bindings (方法呼叫繫結)

bindMethod() 方法可用來覆蓋方法,例如用來傳遞其他引數:

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

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

下面的方式都有效,呼叫閉包來代替呼叫原始的方法:

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

但是,call() 的任何其他引數都不會傳遞到閉包中,因此不能使用它們。

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

注意:這種方式不是 Container 介面 的一部分,只有在它的實現類 Container 才有。在這個 PR 裡可以看到它加了什麼以及為什麼引數被忽略。

D:Contextual Bindings (上下文繫結)

有時候你想在不同的地方給介面不同的實現。這裡有 Laravel 文件 裡的一個例子:

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

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

現在 PhotoControllerVideoController 都依賴了 Filesystem 介面,但是收到了不同的例項。

可以像 bind() 那樣,給 give() 傳閉包:

    ->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');

F:Binding Parameters to Primitives (繫結初始資料)

當有一個類不僅需要接受一個注入類,還需要注入一個基本值(比如整數)。
還可以通過將變數名稱 (而不是介面) 傳遞給 needs() 並將值傳遞給 give() 來注入需要的任何值 (字串、整數等) :

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

還可以使用閉包實現延時載入,只在需要的時候取回這個

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

這種情況下,不能傳遞類或命名的依賴關係(例如,give('database.user')),因為它將作為字面值返回。所以需要使用閉包:

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

G: Tagging (標記)

Container 可以用來「標記」有關係的繫結:

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

這樣會以陣列的形式取回所有「標記」的例項:

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

tag() 方法的兩個引數都可以接受陣列:

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

H:Rebinding (重新繫結)

這個功能很少用到,可以跳過,僅供參考。

在繫結或例項被使用之後又發生了變化,將呼叫一個 rebinding 方法。 下例中, Auth 使用 Session 類後,Session 類將被替換,此時需要通知 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

Rebinding 的更多資訊可以看這兩個連結:
https://stackoverflow.com/questions/389745...
https://code.tutsplus.com/tutorials/diggin...

還有一個 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;
});

它還返回現有的例項或繫結(如果有的話),所以可以這樣做:

// 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;
});

注意:這種方式不是 Container 介面 的一部分,只有在它的實現類 Container 才有。

J:Overriding Constructor Parameters (重寫建構函式引數)

makeWith 方法允許將附加引數傳遞給建構函式。它忽略任何現有的例項或單例,可以用於建立具有不同引數的類的多個例項,同時仍然注入依賴關係:

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

注意:Laravel 5.3 及以下使用 make($class, $parameters)Laravel 5.4 中移除了此方法,但是在 5.4.16 以後又重新加回來了 makeWith() 。詳見PR:https://github.com/laravel/framework/pull/...

K:其它

這涵蓋了我認為有用的所有方法,但僅僅是簡介,不然這篇文章就寫不完了。。。

bound()

如果一個類/名稱已經被 bind() , singleton()instance()alias() 繫結,那麼 bound() 方法返回 true

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

還可以使用陣列訪問語法和 isset()

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

可以使用 unset() 來重置它,這會刪除指定的繫結/例項/別名。

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

bindIf()

bindIf()bind() 功能類似,差別在於只有在現有繫結不存在的情況下才註冊繫結。 它一般被用在 package 中註冊一個可被使用者重寫的預設繫結。

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

不過並沒有 singletonIf() 方法,只能用 bindIf($abstract, $concrete, true) 來實現。

$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() 之後它會被重置:

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

factory()

factory() 方法返回一個不需要引數並呼叫 make() 的閉包。

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

這個東西我也不知道有什麼用。。。

wrap()

wrap 方法包裝一個閉包,以便在執行時依賴關係被注入。 它接受一個陣列引數; 返回的閉包不帶引數:

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

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

$username = $usernameGetter();

我也不知道它有啥用,因為返回的閉包沒帶回引數。。。

注意:這個方法不是 Container 介面 的一部分,只有在它的實現類 Container 才有。

afterResolving()

afterResolving() 方法作用與 resolving() 完全相同,不同之處是 呼叫 「resolving」回撥之後再呼叫 「afterResolving」回撥。
不知道什麼時候會用到它。。。

最後再附幾個

isShared() – 確定一個給定的型別是一個 singleton/instance
isAlias() – 確定給定的字串是否是已註冊的 別名
hasMethodBinding() - 確定容器是否具有給定的 method binding
getBindings() - 取回所有已註冊繫結的原始陣列
getAlias($abstract) - 獲取基礎類/繫結名稱的別名
forgetInstance($abstract) - 清除單個例項物件
forgetInstances() - 清除所有例項物件
flush() - 清除所有繫結和例項,有效地重置容器
setInstance() - 替換 getInstance() 使用的例項 (提示:使用 setInstance(null)來清除它,這樣下一次它將生成一個新的例項)

注意:這些方法不是 Container 介面 的一部分,只有在它的實現類 Container 才有。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

原創。 所有 Laravel 文章均已收錄至 Github laravel-tips 專案。

相關文章