.NET 相依性注入》的試閱章節連載 (2)


本文摘自電子書《.NET 相依性注入》的第一章,您可至書籍首頁下載試閱章節。
書籍首頁網址:https://leanpub.com/dinet

上集,接著要說明如何運用 DI 來讓剛才的範例程式具備執行時期切換實作類別的能力。

入門範例—DI 版本

為了讓 AuthenticationService 類別能夠在執行時期才決定要使用 EmailService 還是 ShortMessageService 來發送驗證碼,我們必須對這些類別動點小手術,把它們之間原本緊密耦合的關係鬆開——或者說「解耦合」。有一個很有效的工具可以用來解耦合:介面(interface)。

說得更明白些,原本 AuthenticationService 是相依於特定實作類別來發送驗證碼(如 EmailService),現在我們要讓它相依於某個介面,而此介面會定義發送驗證碼的工作必須包含那些操作。由於介面只是一份規格,並未包含任何實作,故任何類別只要實作了這份規格,便能夠與 AuthenticationService 銜接,完成發送驗證碼的工作。有了中間這層介面,開發人員便能夠「針對介面、而非針對實作來撰寫程式。」(program to an interface, not an implementation)3,使應用程式中的各部元件保持「有點黏、又不會太黏」的適當距離,從而達成寬鬆耦合的目標。

提煉介面(Extract Interface)

開始動手修改吧!首先要對 EmailService 和 ShortMessageService 進行抽象化(abstraction),亦即將它們的共通特性抽離出來,放在一個介面中,使這些共通特性成為一份規格,然後再分別由具象類別來實作這份規格。以下程式碼是重構之後的結果,包含一個介面,兩個實作類別。我在個別的 Send 方法中使用 Console.WriteLine 方法來輸出不同的訊息字串,方便觀察實驗結果(此範例是個 Console 類型的應用程式專案)。

interface IMessageService
{
    void Send(User user, string msg);
}

class EmailService : IMessageService
{
    public void Send(User user, string msg)
    {          
        // 寄送電子郵件給指定的 user (略)
        Console.WriteLine("寄送電子郵件給使用者,訊息內容:" + msg);
    }
}

class ShortMessageService : IMessageService
{
    public void Send(User user, string msg)
    {
        // 發送簡訊給指定的 user (略)
        Console.WriteLine("發送簡訊給使用者,訊息內容:" + msg);
    }
}

看類別圖可能會更清楚些:
圖 1-2:抽離出共通介面之後的類別圖

介面抽離出來之後,如先前提過的,AuthenticationService 就可以依賴此介面,而不用再依賴特定實作類別。為了方便比對差異,我將修改前後的程式碼都一併列出來:

class AuthenticationService
{
    // 原本是這樣:
    private ShortMessageService msgService;

    public AuthenticationService()

    {
        this.msgSevice = new ShortMessageService();
    }

    // 現在改成這樣:
    private IMessageService msgService;

    public AuthenticationService(IMessageService service)
    {
        this.msgService = service;
    }
}

修改前後的差異如下:
  • 私有成員 msgService 的型別:修改前是特定類別(EmailService 或 ShortMessageService),修改後是 IMessageService 介面。
  • 建構函式:修改前是直接建立特定類別的執行個體,並將物件參考指定給私有成員 msgService; 修改後則需要由外界傳入一個 IMessageService 介面參考,並將此參考指定給私有成員 msgService。

控制反轉(IoC)

現在 AuthenticationService 已經不依賴特定實作了,而只依賴 IMessageService 介面。然而,介面只是規格,沒有實作,亦即我們不能這麼寫(無法通過編譯):

IMessageService msgService = new IMessageService();

那麼物件從何而來呢?答案是由外界透過 AuthenticationService 的建構函式傳進來。請注意這裡有個重要意涵:非 DI 版本的 AuthenticationService 類別使用 new 運算子來建立特定訊息服務的物件,並控制該物件的生命週期;DI 版本的 AuthenticationService 則將此控制權交給外層呼叫端(主程式)來負責——換言之,相依性被移出去了,「控制反轉了」。

最後要修改的是主程式(MainApp):

class MainApp
{
    public void Login(string userId, string pwd, string messageServiceType)
    {
        IMessageService msgService = null;

        // 用字串比對的方式來決定該建立哪一種訊息服務物件。
        switch (messageServiceType)
        {
            case "EmailService":
                msgService = new EmailService();
                break;
            case "ShortMessageService":
                msgService = new ShortMessageService();
                break;
            default:
                throw new ArgumentException("無效的訊息服務型別!");
        }

        var authService = new AuthenticationService(msgService);  // 注入相依物件
        if (authService.TwoFactorLogin(userId, pwd))
        {
            // 此處沒有變動,故省略.
        }
    }
}

現在主程式會負責建立訊息服務物件,然後在建立 AuthenticationService 物件時將訊息服務物件傳入其建構函式。這種由呼叫端將相依物件透過建構函式注入至另一個物件的作法是 DI 的一種常見寫法,而這寫法也有個名稱,叫做「建構式注入」(Constructor Injection)。「建構式注入」是實現 DI 的一種方法,第 2 章會進一步介紹。
現在各型別之間的相依關係如下圖所示。請與上一集的第一張圖比較一下兩者的差異(為了避免圖中箭頭過於複雜交錯,我把無關緊要的配角 User 類別拿掉了) 。

圖 1-3:改成 DI 版本之後的類別相依關係圖

你會發現,上一集的圖中的相依關係,是上層依賴下層的方式;或者說,高階模組依賴低階模組。這只符合了先前提過的 S.O.L.I.D. 五項原則中的「相依反轉原則」(Dependency Inversion Principle;DIP)的其中一小部分的要求。DIP 指的是:
  • 高階模組不應依賴低階模組;他們都應該依賴抽象層(abstractions)。
  • 抽象層不應依賴實作細節;實作細節應該依賴抽象層。
而從圖 1-3 可以發現,DI 版本的範例程式已經符合「相依反轉原則」。其中的 IMessageService 介面即屬於抽象層,而高階模組 AuthenticationService 和低階模組皆依賴中間這個抽象層。


此 DI 版本的範例程式有個好處,即萬一將來使用者又提出新的需求,希望傳送驗證碼的方式除了 e-mail 和簡訊之外,還要增加行動裝置平台的訊息推播服務(push notification),以便將驗證碼推送至行動 app。此時只要加入一個新的類別(可能命名為 PushMessageService),並讓此類別實作 IMessageService,然後稍微改一下 MainApp 便大致完工,AuthenticationService 完全不需要修改。簡單地說,應用程式更容易維護了。

當然,這個範例的程式寫法還是有個缺點:它是用字串比對的方式來決定該建立哪一種訊息服務物件。想像一下,如果欲支援的訊息服務類別有十幾種,那個 switch...case 區塊不顯得太冗長累贅嗎?如果有一個專屬的物件能夠幫我們簡化這些型別對應以及建立物件的工作,那就更理想了。這個部分會在第 3 章〈DI 容器〉中進一步說明。

何時該用 DI?

一旦你開始感受到寬鬆耦合的好處,在設計應用程式時,可能會傾向於讓所有類別之間的耦合都保持寬鬆。換言之,碰到任何需求都想要先定義介面,然後透過相依性注入的方式(例如先前範例中使用的「建構式注入」)來建立物件之間的相依關係。然而,天下沒有白吃的午餐,寬鬆耦合也不例外。每當你將類別之間的相依關係抽離出來,放到另一個抽象層,再於特定時機注入相依物件,這樣的動作其實多少都會產生一些額外成本。不管三七二十一,將所有物件都設計成可任意替換、隨時插拔,並不是個好主意。

以 .NET 基礎類別庫(Base Class Library;簡稱 BCL)為例,此類別庫包含許多組件,各組件又包含許多現成的類別,方便我們直接取用。每當你在程式中使用 BCL 的類別,例如 String、DateTime、Hashtable 等等,就等於在程式中加入了對這些類別的依賴。此時,你會擔心有一天自己的程式可能需要把這些 BCL 類別替換成別的類別嗎?如果是從網路上找到的開放原始碼呢?答案往往取決於你對於特定類別/元件是否會經常變動的信心程度;而所謂的「經常變動」,也會依應用程式的類型、大小而有不同的定義。

相較於其他在網路上找到或購買的第三方元件,我想多數人都會覺得 .NET BCL 裡面的類別應該會相對穩定得多,亦即不會隨便改來改去,導致既有程式無法編譯或正常執行。這樣的認知,有一部分可能來自於我們對該類別的提供者(微軟)有相當程度的信心,另一部分則是來自以往的經驗。無論如何,在為應用程式加入第三方元件時,最好還是審慎評估之後再做決定。

以下是幾個可能需要使用或了解 DI 技術的場合:
  • 如果你正在設計一套框架(framework)或可重複使用的類別庫,DI 會是很好用的技術。
  • 如果你正在開發應用程式,需要在執行時其動態載入、動態切換某些元件,DI 也能派上用場。
  • 希望自已的程式碼在將來需求變動時,能夠更容易替換掉其中一部份不穩定的元件(例如第三方元件,此時可能搭配 Adapter 模式使用)。
  • 你正在接手維護一個應用程式,想要在重構(refactor)程式碼的時候降低對某些元件的依賴,方便測試以及讓程式碼更好維護。

以下是一些可能不適合、或應該更謹慎使用 DI 的場合:
  • 在小型的、需求非常單純的應用程式中使用 DI,恐有殺雞用牛刀之嫌。
  • 在大型且複雜的應用程式中,如果到處都是寬鬆耦合的介面、到處都用 DI 注入相依物件,對於後續接手維護的新進成員來說可能會有點辛苦。在閱讀程式碼的過程中,他可能會因為無法確定某處使用的物件究竟是哪個類別而感到挫折。比如說,看到程式碼中呼叫 IMessageService 介面的 Send 方法,卻沒法追進去該方法的實作來了解接著會發生什麼事,因為介面並沒有提供任何實作。若無人指點、也沒有文件,每次碰到疑問時就只能以單步除錯的方式來了解程式實際運行的邏輯,這確實需要一些耐心。
  • 對老舊程式進行重構(refactoring)時,可能會因為既有設計完全沒考慮寬鬆耦合,使得引入 DI 困難重重。

總之,不加思索地使用任何技術總是不好的;沒有銀彈。

未完待續....