6 追蹤者

依賴注入容器

依賴注入 (DI) 容器是一個物件,它知道如何實例化和組態物件及其所有依賴物件。Martin Fowler 的文章 很好地解釋了 DI 容器為何有用。在這裡,我們將主要解釋 Yii 提供的 DI 容器的用法。

依賴注入

Yii 通過類別 yii\di\Container 提供 DI 容器功能。它支援以下幾種依賴注入

  • 建構函式注入;
  • 方法注入;
  • Setter 和屬性注入;
  • PHP 可調用注入;

建構函式注入

DI 容器借助於建構函式參數的類型提示來支援建構函式注入。類型提示告訴容器在用於建立新物件時,哪些類別或介面是依賴的。容器將嘗試取得依賴類別或介面的實例,然後通過建構函式將它們注入到新物件中。例如,

class Foo
{
    public function __construct(Bar $bar)
    {
    }
}

$foo = $container->get('Foo');
// which is equivalent to the following:
$bar = new Bar;
$foo = new Foo($bar);

方法注入

通常,類別的依賴項會傳遞給建構函式,並在整個生命週期內在類別內部可用。使用方法注入,可以提供僅類別的單一方法需要的依賴項,並且將其傳遞給建構函式可能不可行,或者可能在大多數用例中造成過多的開銷。

可以像以下範例中的 doSomething() 方法一樣定義類別方法

class MyClass extends \yii\base\Component
{
    public function __construct(/*Some lightweight dependencies here*/, $config = [])
    {
        // ...
    }

    public function doSomething($param1, \my\heavy\Dependency $something)
    {
        // do something with $something
    }
}

您可以通過自己傳遞 \my\heavy\Dependency 的實例,或使用 yii\di\Container::invoke()(如下所示)來調用該方法

$obj = new MyClass(/*...*/);
Yii::$container->invoke([$obj, 'doSomething'], ['param1' => 42]); // $something will be provided by the DI container

Setter 和屬性注入

通過 組態 支援 Setter 和屬性注入。在註冊依賴項或建立新物件時,您可以提供組態,容器將使用該組態通過相應的 setter 或屬性注入依賴項。例如,

use yii\base\BaseObject;

class Foo extends BaseObject
{
    public $bar;

    private $_qux;

    public function getQux()
    {
        return $this->_qux;
    }

    public function setQux(Qux $qux)
    {
        $this->_qux = $qux;
    }
}

$container->get('Foo', [], [
    'bar' => $container->get('Bar'),
    'qux' => $container->get('Qux'),
]);

資訊:yii\di\Container::get() 方法將其第三個參數作為應套用於正在建立的物件的組態陣列。如果類別實作了 yii\base\Configurable 介面(例如 yii\base\BaseObject),則組態陣列將作為最後一個參數傳遞給類別建構函式;否則,組態將在物件建立之後套用。

PHP 可調用注入

在這種情況下,容器將使用註冊的 PHP 可調用物件來建構類別的新實例。每次調用 yii\di\Container::get() 時,都會調用相應的可調用物件。可調用物件負責解析依賴項並將它們適當地注入到新建立的物件中。例如,

$container->set('Foo', function ($container, $params, $config) {
    $foo = new Foo(new Bar);
    // ... other initializations ...
    return $foo;
});

$foo = $container->get('Foo');

為了隱藏用於建構新物件的複雜邏輯,您可以將靜態類別方法用作可調用物件。例如,

class FooBuilder
{
    public static function build($container, $params, $config)
    {
        $foo = new Foo(new Bar);
        // ... other initializations ...
        return $foo;
    }
}

$container->set('Foo', ['app\helper\FooBuilder', 'build']);

$foo = $container->get('Foo');

這樣做,想要組態 Foo 類別的人不再需要知道它是如何建構的。

註冊依賴

您可以使用 yii\di\Container::set() 來註冊依賴項。註冊需要依賴項名稱以及依賴項定義。依賴項名稱可以是類別名稱、介面名稱或別名;依賴項定義可以是類別名稱、組態陣列或 PHP 可調用物件。

$container = new \yii\di\Container;

// register a class name as is. This can be skipped.
$container->set('yii\db\Connection');

// register an interface
// When a class depends on the interface, the corresponding class
// will be instantiated as the dependent object
$container->set('yii\mail\MailInterface', 'yii\symfonymailer\Mailer');

// register an alias name. You can use $container->get('foo')
// to create an instance of Connection
$container->set('foo', 'yii\db\Connection');

// register an alias with `Instance::of`
$container->set('bar', Instance::of('foo'));

// register a class with configuration. The configuration
// will be applied when the class is instantiated by get()
$container->set('yii\db\Connection', [
    'dsn' => 'mysql:host=127.0.0.1;dbname=demo',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
]);

// register an alias name with class configuration
// In this case, a "class" or "__class" element is required to specify the class
$container->set('db', [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=127.0.0.1;dbname=demo',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
]);

// register callable closure or array
// The callable will be executed each time when $container->get('db') is called
$container->set('db', function ($container, $params, $config) {
    return new \yii\db\Connection($config);
});
$container->set('db', ['app\db\DbFactory', 'create']);

// register a component instance
// $container->get('pageCache') will return the same instance each time it is called
$container->set('pageCache', new FileCache);

提示:如果依賴項名稱與相應的依賴項定義相同,則無需向 DI 容器註冊它。

通過 set() 註冊的依賴項將在每次需要依賴項時產生一個實例。您可以使用 yii\di\Container::setSingleton() 來註冊僅產生單一實例的依賴項

$container->setSingleton('yii\db\Connection', [
    'dsn' => 'mysql:host=127.0.0.1;dbname=demo',
    'username' => 'root',
    'password' => '',
    'charset' => 'utf8',
]);

解析依賴

註冊依賴項後,您可以使用 DI 容器來建立新物件,並且容器將通過實例化依賴項並將它們注入到新建立的物件中來自動解析依賴項。依賴項解析是遞迴的,這表示如果依賴項有其他依賴項,則這些依賴項也將自動解析。

您可以使用 get() 來建立或取得物件實例。該方法採用依賴項名稱,可以是類別名稱、介面名稱或別名。依賴項名稱可以通過 set()setSingleton() 註冊。您可以選擇性地提供類別建構函式參數列表和 組態 來組態新建立的物件。

例如

// "db" is a previously registered alias name
$db = $container->get('db');

// equivalent to: $engine = new \app\components\SearchEngine($apiKey, $apiSecret, ['type' => 1]);
$engine = $container->get('app\components\SearchEngine', [$apiKey, $apiSecret], ['type' => 1]);

// equivalent to: $api = new \app\components\Api($host, $apiKey);
$api = $container->get('app\components\Api', ['host' => $host, 'apiKey' => $apiKey]);

在幕後,DI 容器做的工作遠不止建立一個新物件。容器將首先檢查類別建構函式以找出依賴的類別或介面名稱,然後自動遞迴解析這些依賴項。

以下程式碼顯示了一個更複雜的範例。UserLister 類別依賴於實作 UserFinderInterface 介面的物件;UserFinder 類別實作了此介面,並依賴於 Connection 物件。所有這些依賴項都通過類別建構函式參數的類型提示來宣告。通過正確的依賴項註冊,DI 容器能夠自動解析這些依賴項,並通過簡單調用 get('userLister') 來建立新的 UserLister 實例。

namespace app\models;

use yii\base\BaseObject;
use yii\db\Connection;
use yii\di\Container;

interface UserFinderInterface
{
    function findUser();
}

class UserFinder extends BaseObject implements UserFinderInterface
{
    public $db;

    public function __construct(Connection $db, $config = [])
    {
        $this->db = $db;
        parent::__construct($config);
    }

    public function findUser()
    {
    }
}

class UserLister extends BaseObject
{
    public $finder;

    public function __construct(UserFinderInterface $finder, $config = [])
    {
        $this->finder = $finder;
        parent::__construct($config);
    }
}

$container = new Container;
$container->set('yii\db\Connection', [
    'dsn' => '...',
]);
$container->set('app\models\UserFinderInterface', [
    'class' => 'app\models\UserFinder',
]);
$container->set('userLister', 'app\models\UserLister');

$lister = $container->get('userLister');

// which is equivalent to:

$db = new \yii\db\Connection(['dsn' => '...']);
$finder = new UserFinder($db);
$lister = new UserLister($finder);

實際應用

當您在應用程式的 入口腳本 中包含 Yii.php 檔案時,Yii 會建立一個 DI 容器。DI 容器可通過 Yii::$container 存取。當您調用 Yii::createObject() 時,該方法實際上會調用容器的 get() 方法來建立新物件。如前所述,DI 容器將自動解析依賴項(如果有的話)並將它們注入到取得的物件中。由於 Yii 在其大多數核心程式碼中使用 Yii::createObject() 來建立新物件,這表示您可以通過處理 Yii::$container 來全域自訂物件。

例如,讓我們全域自訂 yii\widgets\LinkPager 的預設分頁按鈕數。

\Yii::$container->set('yii\widgets\LinkPager', ['maxButtonCount' => 5]);

現在,如果您在視圖中使用具有以下程式碼的小部件,則 maxButtonCount 屬性將初始化為 5,而不是類別中定義的預設值 10。

echo \yii\widgets\LinkPager::widget();

但是,您仍然可以覆寫通過 DI 容器設定的值

echo \yii\widgets\LinkPager::widget(['maxButtonCount' => 20]);

注意:在小部件調用中給定的屬性將始終覆寫 DI 容器中的定義。即使您指定一個陣列,例如 'options' => ['id' => 'mypager'],這些也不會與其他選項合併,而是替換它們。

另一個範例是利用 DI 容器的自動建構函式注入。假設您的控制器類別依賴於其他一些物件,例如飯店預訂服務。您可以通過建構函式參數宣告依賴項,並讓 DI 容器為您解析它。

namespace app\controllers;

use yii\web\Controller;
use app\components\BookingInterface;

class HotelController extends Controller
{
    protected $bookingService;

    public function __construct($id, $module, BookingInterface $bookingService, $config = [])
    {
        $this->bookingService = $bookingService;
        parent::__construct($id, $module, $config);
    }
}

如果您從瀏覽器存取此控制器,您將看到一個錯誤,抱怨無法實例化 BookingInterface。這是因為您需要告訴 DI 容器如何處理此依賴項

\Yii::$container->set('app\components\BookingInterface', 'app\components\BookingService');

現在,如果您再次存取控制器,將建立 app\components\BookingService 的實例,並將其作為第三個參數注入到控制器的建構函式中。

自 Yii 2.0.36 起,當使用 PHP 7 時,動作注入可用於 Web 和主控台控制器

namespace app\controllers;

use yii\web\Controller;
use app\components\BookingInterface;

class HotelController extends Controller
{    
    public function actionBook($id, BookingInterface $bookingService)
    {
        $result = $bookingService->book($id);
        // ...    
    }
}

進階實際應用

假設我們在 API 應用程式上工作,並且有

  • app\components\Request 類別,它擴展了 yii\web\Request 並提供額外功能
  • app\components\Response 類別,它擴展了 yii\web\Response,並且應在建立時將 format 屬性設定為 json
  • app\storage\FileStorageapp\storage\DocumentsReader 類別,它們實作了一些關於處理位於某些檔案儲存中的文件的邏輯

    class FileStorage
    {
        public function __construct($root) {
            // whatever
        }
    }
      
    class DocumentsReader
    {
        public function __construct(FileStorage $fs) {
            // whatever
        }
    }
    

可以一次組態多個定義,將組態陣列傳遞給 setDefinitions()setSingletons() 方法。遍歷組態陣列,這些方法將分別為每個項目調用 set()setSingleton()

組態陣列格式為

  • key:類別名稱、介面名稱或別名。金鑰將作為第一個參數 $class 傳遞給 set() 方法。
  • value:與 $class 關聯的定義。可能的值在 set() 文件中針對 $definition 參數進行了描述。將作為第二個參數 $definition 傳遞給 set() 方法。

例如,讓我們組態我們的容器以遵循上述要求

$container->setDefinitions([
    'yii\web\Request' => 'app\components\Request',
    'yii\web\Response' => [
        'class' => 'app\components\Response',
        'format' => 'json'
    ],
    'app\storage\DocumentsReader' => function ($container, $params, $config) {
        $fs = new app\storage\FileStorage('/var/tempfiles');
        return new app\storage\DocumentsReader($fs);
    }
]);

$reader = $container->get('app\storage\DocumentsReader'); 
// Will create DocumentReader object with its dependencies as described in the config 

提示:自 2.0.11 版本以來,可以使用應用程式組態以宣告式樣式組態容器。查看 組態 指南文章的 應用程式組態 子章節。

一切正常,但是如果我們需要建立 DocumentWriter 類別,我們應該複製貼上建立 FileStorage 物件的行,這顯然不是最聰明的方法。

解析依賴 子章節中所述,set()setSingleton() 可以選擇性地將依賴項的建構函式參數作為第三個參數。要設定建構函式參數,您可以使用 __construct() 選項

讓我們修改我們的範例

$container->setDefinitions([
    'tempFileStorage' => [ // we've created an alias for convenience
        'class' => 'app\storage\FileStorage',
        '__construct()' => ['/var/tempfiles'], // could be extracted from some config files
    ],
    'app\storage\DocumentsReader' => [
        'class' => 'app\storage\DocumentsReader',
        '__construct()' => [Instance::of('tempFileStorage')],
    ],
    'app\storage\DocumentsWriter' => [
        'class' => 'app\storage\DocumentsWriter',
        '__construct()' => [Instance::of('tempFileStorage')]
    ]
]);

$reader = $container->get('app\storage\DocumentsReader'); 
// Will behave exactly the same as in the previous example.

您可能會注意到 Instance::of('tempFileStorage') 表示法。這表示 Container 將隱式提供以 tempFileStorage 名稱註冊的依賴項,並將其作為 app\storage\DocumentsWriter 建構函式的第一個參數傳遞。

注意:setDefinitions()setSingletons() 方法自 2.0.11 版本起可用。

組態最佳化的另一個步驟是將某些依賴項註冊為單例。通過 set() 註冊的依賴項將在每次需要時實例化。某些類別在運行時不會更改狀態,因此可以將它們註冊為單例以提高應用程式效能。

一個很好的範例可能是 app\storage\FileStorage 類別,它使用簡單的 API(例如 $fs->read()$fs->write())在檔案系統上執行某些操作。這些操作不會更改內部類別狀態,因此我們可以建立其一個實例並多次使用它。

$container->setSingletons([
    'tempFileStorage' => [
        'class' => 'app\storage\FileStorage',
        '__construct()' => ['/var/tempfiles']
    ],
]);

$container->setDefinitions([
    'app\storage\DocumentsReader' => [
        'class' => 'app\storage\DocumentsReader',
        '__construct()' => [Instance::of('tempFileStorage')],
    ],
    'app\storage\DocumentsWriter' => [
        'class' => 'app\storage\DocumentsWriter',
        '__construct()' => [Instance::of('tempFileStorage')],
    ]
]);

$reader = $container->get('app\storage\DocumentsReader');

何時註冊依賴

由於在建立新物件時需要依賴項,因此應盡可能早地完成其註冊。以下是建議的做法

  • 如果您是應用程式的開發人員,則可以使用應用程式組態來註冊您的依賴項。請閱讀 組態 指南文章的 應用程式組態 子章節。
  • 如果您是可重新發布的 擴展 的開發人員,則可以在擴展的啟動引導類別中註冊依賴項。

總結

依賴注入和 服務定位器 都是流行的設計模式,它們允許以鬆散耦合且更易於測試的方式建構軟體。我們強烈建議您閱讀 Martin 的文章,以更深入地了解依賴注入和服務定位器。

Yii 在依賴注入 (DI) 容器之上實作了其 服務定位器。當服務定位器嘗試建立新物件實例時,它會將調用轉發到 DI 容器。後者將如上所述自動解析依賴項。

發現錯字或您認為此頁面需要改進?
在 github 上編輯 !