深入淺出依賴注入

liuqing_hu發表於2018-05-10

本文首發於 深入淺出依賴注入,轉載請註明出處。

本文試圖以一種易於理解的行文講解什麼是「依賴注入」這種設計模式。

或許您已經在專案中已經使用過「依賴注入」,只不過由於某些原因,致使您對它的印象不是特別深刻。

「依賴注入」可能是最簡單的設計模式之一,但即便如此我發現要想真正的以一種老少咸宜的方式把它講解透徹也絕非易事。

本文在寫作過程中參考了諸多優秀的與「依賴注入」相關文章,我會從以下幾個方面給大家講解「依賴注入」究竟是一種怎樣的設計模式:

目錄結構

  • 什麼是「元件」和「服務」
    • 「元件」的定義
    • 「服務」的定義
    • 「元件」與「服務」的異同
  • 什麼是控制反轉和依賴注入
    • 一個簡單的示例
    • 控制反轉
    • 依賴注入
  • 如何實現依賴注入
    • 通過建構函式注入依賴
    • 通過 setter 設值方法注入依賴
  • 什麼是依賴注入容器
  • 依賴注入的優缺點
    • 優點
    • 不足
  • 如何選擇依賴注入的方式
    • 選擇通過建構函式注入:
    • 選擇通過 setter 設值方法注入
  • 參考資料

提示:本文內容較多,會耗費較多的閱讀實現,建議抽取空閒時間進行閱讀;建議不要錯過參考資料部分的學習;另外,由於本人技術水平所限表述不到的地方歡迎指正。

如果您覺得本文對您有幫助,在收藏的同時請隨手點個「贊」,謝謝!

什麼是「元件」和「服務」

在講解什麼是依賴注入之前,我們需要對什麼是依賴這個問題進行說明。

所謂的「依賴」就是指在實現某個功能模組時需要使用另外一個(或多個)「元件」或「服務」,那麼這個所需的「元件」或「服務」將被稱為「依賴」。

後續文中統一使用「元件」表示某個模組的「依賴」,「依賴注入」就是指向使用者注入某個「元件」以供其使用。

「元件」的定義

「元件」:它是可能被作者無法控制的其它應用使用,但使用者不能對其原始碼進行修改的一個功能模組。

「服務」的定義

「服務」指:使用者以同步(或非同步)請求遠端介面來遠端使用的一個功能介面。

「元件」與「服務」的異同

「元件」和「服務」的 共同之處 就是它們都將被其他應用程式或功能模組使用。

它們的不同之處在於:

  • 「元件」是在本地使用(如 jar 檔案、dll 或者原始碼匯入)
  • 「服務」是在遠端使用(如 WebService、訊息系統、RPC 或者 Socket)

什麼是控制反轉和依賴注入

「控制反轉」和「依賴注入」本質上就是一個從 問題發現實現 的過程。

即在專案中我們通過使用「依賴注入」這種技術手段實現功能模組對其依賴元件的「控制反轉」。

我們在開發的過程中時長會遇到這樣一個問題:如何才能將不同的「元件」進行組裝才能讓它們配合默契的完成某個模組的功能?

「依賴注入」就是為了完成這樣的 目標:將 依賴元件 的配置和使用分離開,以降低使用者與依賴之間的耦合度。

在闡述「依賴注入」這個模式具體含義前,還是先看一個常見的示例,或許對於理解更有幫助。

一個簡單的示例

這個示例的靈感來自 What is Dependency Injection? 這篇文章(譯文 什麼是依賴注入?)。

從事服務端研發工作的同學,應該有這樣的體驗。

由於 HTTP 協議是一種無狀態的協議,所以我們就需要使用「Session(會話)」機制對有狀態的資訊進行儲存。一個典型的應用場景就是儲存登入使用者的狀態到會話中。

<?php
$user = ['uid' => 1, 'uname' => '柳公子'];
$_SESSION['user'] = $user;

上面這段程式碼將登入使用者 $user 儲存「會話」的 user 變數內。之後,同一個使用者發起請求就可以直接從「會話」中獲取這個登入使用者資料:

<?php
$user = $_SESSION['user'];

接著,我們將這段程式導向的程式碼,以物件導向的方法進行封裝:

<?php
class SessionStorage
{
    public function __construct($cookieName = 'PHP_SESS_ID')
    {
        session_name($cookieName);
        session_start();
    }

    public function set($key, $value)
    {
        $_SESSION[$key] = $value;
    }

    public function get($key)
    {
        return $_SESSION[$key];
    }

    public function exists($key)
    {
        return isset($this->get($key));
    }
}

並且需要提供一個介面服務類 user:

<?php
class User
{
    protected $storage;

    public function __construct()
    {
        $this->storage = new SessionStorage();
    }

    public function login($user)
    {
        if (!$this->storage->exists('user')) {
            $this->storage->set('user', $user);
        }

        return 'success';
    }

    public function getUser()
    {
        return $this->storage->get('user');
    }
}

以上就是登入所需的大致功能,使用起來也非常容易:

<?php
$user = new User();
$user->login(['uid' => 1, 'uname' => '柳公子']);
$loginUser = $user->getUser();

這個功能實現非常簡單:使用者登入 login() 方法依賴於 $this->storage 儲存物件,這個物件完成將登入使用者的資訊儲存到「會話」的處理。

那麼對於這個功能的實現,究竟還有什麼值得我們去擔心呢?

一切似乎幾近完美,直到我們的業務做大了,會發現通過「會話」機制儲存使用者的登入資訊已近無法滿足需求了,我們需要使用「共享快取」來儲存使用者的登入資訊。這個時候就會發現:

User 物件的 login() 方法依賴於 $this->storage 這個具體實現,即耦合到一起了。這個就是我們需要面對的 核心問題

既然我們已經發現了問題的癥結所在,也就很容易得到 解決方案:讓我們的 User 物件不依賴於具體的儲存方式,但無論哪種儲存方式,都需要提供 set 方法執行儲存使用者資料。

具體實現可以分為以下幾個階段:

  1. 定義 Storage 介面

定義 Storage 介面的作用是: 使 UserSessionStorage 實現類進行解耦,這樣我們的 User 類便不再依賴於具體的實現了。

編寫一個 Storage 介面似乎不會太複雜:

<?php

interface Storage
{
    public function set($key, $value);

    public function get($key);

    public function exists($key);
}

然後讓 SessionStorage 類實現 Storage 介面:

<?php
class SessionStorage implements Storage
{
    public function __construct($cookieName = 'PHP_SESS_ID')
    {
        session_name($cookieName);
        session_start();
    }

    public function set($key, $value)
    {
        $_SESSION[$key] = $value;
    }

    public function get($key)
    {
        return $_SESSION[$key];
    }

    public function exists($key)
    {
        return isset($this->get($key));
    }
}
  1. 定義一個 Storage 介面讓 User 類僅依賴 Storage 介面

現在我們的 User 類看起來既依賴於 Storage 介面又依賴於 SessionStorage 這個具體實現:

<?php

class User
{
    protected $storage;

    public function __construct()
    {
        $this->storage = new SessionStorage();
    }
}

當然這已經是一個完美的登入功能了,直到我將這個功能開放出來給別人使用。然而,如果這個應用同樣是通過「會話」機制來儲存使用者資訊,現有的實現不會出現問題。

但如果使用者將「會話」機制更換到下列這些儲存方式呢?

  • 將會話儲存到 MySQL 資料庫
  • 將會話儲存到 Memcached 快取
  • 將會話儲存到 Redis 快取
  • 將會話儲存到 MongoDB 資料庫
  • ...
<?php
// 想象下下面的所有實現類都有實現 get,set 和 exists 方法
class MysqlStorage {}

class MemcachedStorage {}

class RedisStorage {}

class MongoDBStorage {}

...

此時我們似乎無法在不修改 User 類的建構函式的的情況下,完成替換 SessionStorage 類的例項化過程。即我們的模組與依賴的具體實現類耦合到一起了。

有沒有這樣一種解決方案,讓我們的模組僅依賴於介面類,然後在專案執行階段動態的插入具體的實現類,而非在編譯(或編碼)階段將實現類接入到使用場景中呢?

這種動態接入的能力稱為「外掛」。

答案是有的:可以使用「控制反轉」。

控制反轉

「控制反轉」提供了將「外掛」組合進模組的能力。

在實現「控制反轉」過程中我們「反轉」了哪方面的「控制」呢?其實這裡的「反轉」的意義就是 如何去定位「外掛」的具體實現

採用「控制反轉」模式時,我們通過一個組裝模組,將「外掛」的具體實現「注入」到模組中就可以了。

依賴注入

瞭解完「控制反轉」,我們再來看看什麼是「依賴注入」。「依賴注入」和「控制反轉」之間是怎樣的一種關係呢?

「控制反轉」是目的:它希望我們的模組能夠在執行時動態獲取依賴的「外掛」,然後,我們通過「依賴注入」這種手段去完成「控制反轉」的目的。

這邊我試著給出一個「依賴注入」的具體的定義:

應用程式對需要使用的依賴「外掛」在編譯(編碼)階段僅依賴於介面的定義,到執行階段由一個獨立的組裝模組(容器)完成對實現類的例項化工作,並將其「注射」到應用程式中稱之為「依賴注入」。

如何實現依賴注入

如何實現依賴注入或者說依賴注入有哪些形式?

Inversion of Control Containers and the Dependency Injection pattern 一文中有過相關的闡述:

依賴注入的形式主要有三種,我分別將它們叫做構造注入( Constructor Injection)、設值
方法注入( Setter Injection)和介面注入( Interface Injection)

本文將結合上面的示例稍微講下:

  1. 通過建構函式注入依賴
  2. 通過 setter 設值方法注入依賴

這兩種注入方式。

通過建構函式注入依賴

通過前面的文章我們知道 User 類的建構函式既依賴於 Storage 介面,又依賴於 SessionStorage 這個具體的實現。

現在我們通過重寫 User 類的建構函式,使其僅依賴於 Storage 介面:

<?php

class User
{
    protected $storage;

    public function __construct(Storage $storage)
    {
        $this->storage = $storage;
    }
}

我們知道 User 類中的 login 和 getUser 方法內依賴的是 $this->storage 例項,也就無需修改這部分的程式碼了。

之後我們就可以通過「依賴注入」完成將 SessionStorage 例項注入到 User 類中,實現高內聚低耦合的目標:

<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User($storage);

通過 setter 設值方法注入依賴

設值注入也很簡單:

<?php

class User
{
    protected $storage;

    public function setStorage(Storage $storage)
    {
        $this->storage = $storage;
    }
}

使用也幾乎和構造方法注入一樣:

<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User();
$user->setStorage($storage);

什麼是依賴注入容器

上面實現依賴注入的過程僅僅可以當做一個演示,真實的專案中肯定沒有這樣使用的。那麼我們在專案中該如何去實現依賴注入呢?

嗯,這是個好問題,所以現在我們需要了解另外一個與「依賴注入」相關的內容「依賴注入容器」。

依賴注入容器我們在給「依賴注入」下定義的時候有提到 由一個獨立的組裝模組(容器)完成對實現類的例項化工作,那麼這個組裝模組就是「依賴注入容器」。

「依賴注入容器」是一個知道如何去例項化和配置依賴元件的物件。

儘管,我們已經能夠將 User 類與實現分離,但是還需要進一步,才能稱之為完美。

定義一個簡單的服務容器:

<?php
class Container
{
    public function getStorage()
    {
        return new SessionStorage();
    }

    public function getUser()
    {
        $user = new User($this->getStorage());
        return $user;
    }
}

使用也很簡單:

<?php
$container = new Container();
$user = $container->getUser();

我們看到,如果我們需要使用 User 物件僅需要通過 Container 容器的 getUser 方法即可獲取這個例項,而無需關心它是如何被建立建立出來的。

這樣,我們就瞭解了「依賴注入」幾乎全部的細節了,但是現實總是會比理想更加骨感。因為,我們現有的依賴注入容器還相當的脆弱,因為它同樣依賴於 SessionStorage,一旦我們需要替換這個實現,還是不得不去修改裡面的原始碼,而無法實現在執行時配置。

做了這麼多工作,還是這樣的結果,真是晴天霹靂啊!

為什麼不考慮將實現類相關資料寫入到配置檔案中,在容器中例項化是從配置檔案中讀取呢?

有關使用依賴注入容器的更加詳細的使用可以閱讀我翻譯的 依賴注入 系列文章,文章還部分篇章沒有翻譯,所以你也可以直接閱讀 原文

依賴注入的優缺點

優點

  • 提供系統解耦的能力
  • 可以明確的瞭解到元件之間的依賴關係
  • 簡化測試工作

前兩個比較好理解,稍微說下依賴注入是如何簡化測試的。

如果我們在實現 User 類時,還沒有實現具體的 SessionStorage 類,而僅定義了 Storage 介面。

那麼在測試時,可以編寫一個 NopStorage 先用於測試,之後等實現了 SessionStorage 在進行替換即可。

不足

元件與注入器之間不會有依賴關係,因此元件無法從注入器那裡獲得更多的服務,只能獲得配置資訊中所提供的那些。

如何選擇依賴注入的方式

如何選擇依賴注入方式在 Inversion of Control Containers and the Dependency Injection pattern 一文中有給出相關論述。

選擇通過建構函式注入:

  • 能夠在構造階段就建立完整、合法的物件;
  • 帶有引數的構造子可以明確地告訴你如何建立一個合法的物件;
  • 可以隱藏任何不可變的欄位。

選擇通過 setter 設值方法注入

  • 如果依賴的「外掛」太多時,選擇設值注入更優

說完了什麼是「控制反轉」和「依賴注入」,相信大家已經對這兩個概念有了相對比較清晰的瞭解。我想說的是任何事物的瞭解程度都不是一蹴而就的,所以即便有號稱能一句話講明白什麼是「依賴注入」的文章,其實還是需要我們有了相對深入的瞭解後才能感悟其中的真意,所謂「讀書百遍,其義自見」就是這個道理。

參考資料

相關文章