Dependency Injection 筆記 (1)

.NET 相依性注入》的第一章連載 (1)


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


本章從一個基本的問題開始,點出軟體需求變動的常態,以說明為什麼我們需要學習「相依性注入」(dependency injection;簡稱 DI)來改善設計的品質。接著以一個簡單的入門範例來比較沒有使用 DI 和改寫成 DI 版本之後的差異,並討論使用 DI 的時機。目的是讓讀者先對相關的基礎概念有個概括的理解,包括可維護性(maintainability)、寬鬆耦合(loose coupling)、控制反轉(inversion of control)、動態繫結、單元測試等等。

為什麼需要相依性注入?

或許你也曾在某個網路論壇上看過有人提出類似問題:「如何利用字串來來建立物件?」

欲了解此問題的動機與解法,得先來看一下平常的程式寫法可能產生什麼問題。一般來說,建立物件時通常都是用 new 運算子,例如:

ZipCompressor obj = new ZipCompressor();

上面這行程式碼的作用是建立一個 ZipCompressor 物件,或者說,建立 ZipCompressor 類別的執行個體(instance)。從類別名稱不難看出,ZipCompressor 類別會提供壓縮資料的功能,而且是採用 Zip 壓縮演算法。假如有一天,軟體系統已經部署至用戶端,後來卻因為某種原因無法再使用 ZipCompressor 了(例如發現它有嚴重 bug 或授權問題),必須改用別的類別,比如說 RarCompressor 或 GZip。那麼,專案中所有用到 ZipCompressor 的程式碼全都必須修改一遍,並且重新編譯、測試,導致維護成本太高。

為了降低日後的維護成本,我們可以在設計軟體時,針對「將來很可能需要替換的元件」,在程式中預留適度的彈性。簡單地說,就是一種應付未來變化的設計策略。回到剛才的範例,如果改寫成這樣:

var className = ConfigurationManager.AppSettings["CompressorClassName"];
Type aType = Type.GetType(className);
ICompressor obj = (ICompressor) System.Activator.CreateInstance(aType);

亦即建立物件時,是先從應用程式組態檔中讀取欲使用的類別名稱,然後透過 Activator.CreateInstance 方法來建立該類別的執行個體,並轉型成各壓縮器共同實作的介面 ICompressor。於是,我們就可以將類別名稱寫在組態檔中:

    <appSettings>
        <add key="CompressorClassName" value="MyLib.ZipCompressor, MyLib" />
    <appSettings>

將來若需要換成其他壓縮器,便無須修改和重新編譯程式碼,而只要修改組態檔中的參數值,就能切換程式執行時所使用的類別,進而達到改變應用程式行為的目的。這裡使用了動態繫結的程式技巧。

舉這個例子,重點不在於「以字串來建立物件」的程式技巧,而是想要點出一個問題:當我們在寫程式時,可能因為很習慣使用 new 運算子而不經意地在程式中加入太多緊密的相依關係——即「相依性」(dependency)。進一步說,每當我們在程式中使用 new 來建立第三方(third party)元件的執行個體,我們的程式碼就在編譯時期跟那個類別固定綁(繫結)在一起了;這層相依關係有可能是單向依賴,也可能是彼此相互依賴,形成更緊密的「耦合」(coupling),增加日後維護程式的困難。

可維護性

就對軟體系統而言,「可維護性」(maintainability)指的是將來需要修改程式時需要花費的工夫;如果改起來很費勁,我們就說它是很難維護的、可維護性很低的。

有軟體開發實務經驗的人應該會同意:軟體需求的變動幾乎無可避免。如果你的程式碼在完成第一個版本之後就不會再有任何更動,自然可以不用考慮日後維護的問題。但這種情況非常少見。實務上,即使剛開始只是個小型應用程式,將來亦有可能演變成大型的複雜系統;而最初看似單純的需求,在實作完第一個版本之後,很快就會出現新的需求或變更既有規格,而必須修改原先的設計。這樣的修改,往往不只是改動幾個函式或類別這麼單純,還得算上重新跑過一遍完整測試的成本。這就難怪,修改程式所衍生的整體維護成本總是超出預期;這也難怪,軟體系統交付之後,開發團隊都很怕客戶又要改東改西。

此外,我想大多數人都是比較喜歡寫新程式,享受創新、創造的過程,而鮮少人喜歡接手維護別人的程式,尤其是難以修改的程式碼。然而,程式碼一旦寫成,某種程度上它就已經算是進入維護模式了1。換言之,程式碼大多是處於維護的狀態。既然如此,身為開發人員,我們都有責任寫出容易維護的程式碼,讓自己和別人的日子好過一些。就如 Damian Conway 在《Perl Best Practices》書中建議的:
「寫程式時,請想像最後維護此程式的人,是個有暴力傾向的精神病患,而且他知道你住哪裡。」
寬鬆耦合

在 .NET (或某些物件導向程式語言)的世界裡,任何東西都是「物件」,而應用程式的各項功能便是由各種物件彼此相互合作所達成,例如:物件 A 呼叫物件 B,物件 B 又去呼叫 C。像這樣由類別之間相互呼叫而令彼此有所牽連,便是耦合(coupling)。物件之間的關係越緊密,耦合度即越高,程式碼也就越難維護;因為一旦有任何變動,便容易引發連鎖反應,非得修改多處程式碼不可,導致維護成本提高。為了提高可維護性,一個常見且有效的策略是採用「寬鬆耦合」(loose coupling),亦即讓應用程式的各部元件適度隔離,不讓它們彼此綁得太緊。一般而言,軟體系統越龐大複雜,就越需要考慮採取寬鬆耦合的設計方式。

當你在設計過程中試著落實寬鬆耦合原則,剛開始可能會看不太出來這樣的設計方式有什麼好處,反而會發現要寫更多程式碼,覺得挺麻煩。但是當你開始維護這些既有的程式,你會發現自己修正臭蟲的速度變快了,而且那些類別都比以往緊密耦合的寫法要來得更容易進行獨立測試。此外,修改程式所導致的「牽一髮動全身」的現象也可能獲得改善,因而降低你對客戶需求變更的恐懼感。

基本上,「可維護性」與「寬鬆耦合」便是我們學習「相依性注入」的主要原因。不過,這項技術還有其他附帶好處,例如有助於單元測試與平行開發,這裡也一併討論。

可測試性
I've focused almost entirely on the value of Dependency Injection for unit testing and I've even bitterly referred to using DI as “Mock Driven Design.” That's not the whole story though.2 —— Jeremy Miller
對於相依性注入所帶來的價值,我把重點幾乎全擺在單元測試上面,有人甚至挖苦我是以「模仿驅動設計」的方式來使用 DI。但那只是事實的一部分而已。)

當我們說某軟體系統是「可測試的」(testable),指的是有「單元測試」(unit test),而不是類似用滑鼠在視窗或網頁上東點西點那種測試方式。單元測試有「寫一次,不斷重複使用」的優點,而且能夠利用工具來自動執行測試。不過,撰寫單元測試所需要付出的成本也不低,甚至不亞於撰寫應用程式本身。有些方法論特別強調單元測試,例如「測試驅動開發」(Test-Driven Devcelopment;TDD),它建議開發人員養成先寫測試的習慣,並盡量擴大單元測試所能涵蓋的範圍,以達到改善軟體品質的目的。

有些情況,要實施「先寫測試」的確有些困難。比如說,有些應用程式是屬於分散式多層架構,其中某些元件或服務需要運行於遠端的數台伺服器上。此時,若為了先寫測試而必須先把這些服務部署到遠端機器上,光是部署的成本與時間可能就讓開發人員打退堂鼓。像這種情況,我們可以先用「測試替身」(test doubles)來暫時充當真正的元件;如此一來,便可以針對個別模組進行單元測試了。「相依性注入」與寬鬆耦合原則在這裡也能派上用場,協助我們實現測試替身的機制。

平行開發

分而治之,是對付複雜系統的一個有效方法。實務上,軟體系統也常被劃分成多個部分,交給多名團隊成員同時分頭進行各部元件的開發工作,然後持續進行整合,將它們互相銜接起來,成為一個完整的系統。要能夠做到這點,各部分的元件必須事先訂出明確的介面,就好像插座與插頭,將彼此連接的介面規格先訂出來,等到各部分實作完成時,便能順利接上。「相依性注入」的一項基本原則就是針對介面寫程式(program to an interface),而此特性亦有助於團隊分工合作,平行開發。

了解其優點與目的之後,接著要來談談什麼是「相依性注入」。

什麼是相依性注入?

如果說「容易維護」是設計軟體時的一個主要品質目標,「寬鬆耦合」是達成此目標的戰略原則,那麼,「相依性注入」(dependency injection;DI)就是屬於戰術層次;它包含一組設計模式與原則,能夠協助我們設計出更容易維護的程式。

DI 經常與「控制反轉」(Inversion of Control;簡稱 IoC)相提並論、交替使用,但兩者並不完全相等。比較精確地說,IoC 涵蓋的範圍比較廣,其中包含 DI,但不只是 DI。換個方式說,DI 其實是 IoC 的一種形式。那麼,IoC 所謂的控制反轉,究竟是什麼意思呢?反轉什麼樣的控制呢?如何反轉?對此問題,我想先引用著名的軟體技術問答網站 Stack Overflow 上面的一個妙答,然後再以範例程式碼來說明。

該帖的問題是:「如何向五歲的孩童解釋 DI?」在眾多回答中,有位名叫 John Munch 的仁兄假設提問者就是那五歲的孩童而給了如下答案:
當你自己去開冰箱拿東西時,很可能會闖禍。你可能忘了關冰箱門、可能會拿了爸媽不想讓你碰的東西,甚至冰箱裡根本沒有你想要找的食物,又或者它們早已過了保存期限。
你應該把自己需要的東西說出來就好,例如:「我想要一些可以搭配午餐的飲料。」然後,當你坐下用餐時,我們會準備好這些東西。

如此精準到位的比喻,自然獲得了網友普遍的好評。

接下來,依慣例,我打算用一個 Hello World 等級的 DI 入門範例來說明。常見的 Hello World 範例只有短短幾行程式碼,但 DI 沒辦法那麼單純;即使是最簡單的 DI 範例,也很難只用兩三行程式碼來體現其精神。因此,接下來會有更多程式碼和專有名詞,請讀者稍微耐心一點。我們會先實作一個非 DI 的範例程式,然後再將它改成 DI 版本。

入門範例—非 DI 版本

這裡使用的範例情境是應用程式的登入功能必須提供雙因素驗證(two-factor authentication)機制,其登入流程大致有以下幾個步驟:
  1. 使用者輸入帳號密碼之後,系統檢查帳號密碼是否正確。
  2. 帳號密碼無誤,系統會立刻發送一組隨機驗證碼至使用者的信箱。
  3. 使用者收信,獲得驗證碼之後,回到登入頁面繼續輸入驗證碼。
  4. 驗證碼確認無誤,讓使用者登入系統。

依此描述,我們可以設計一個類別來提供雙因素驗證的服務:AuthenticationService。底下是簡化過的程式碼:

class AuthenticationService
{
    private EmailService msgService;
    public AuthenticationService()
    {
        msgSevice = new EmailService(); // 建立用來發送驗證碼的物件
    }

    public bool TwoFactorLogin(string userId, string pwd)
    {
        // 檢查帳號密碼,若正確,則傳回一個包含使用者資訊的物件。          
        User user = CheckPassword(userId, pwd);
        if (user != null)
        {
            // 接著發送驗證碼給使用者,假設隨機產生的驗證碼為 "123456"。
            this.msgService.Send(user, "您的登入驗證碼為 123456");
            return true;
        }
        return false;
    }
}

AuthenticationService 的建構函式會建立一個 EmailService 物件,用來發送驗證碼。TwoFactorLogin 方法會檢查使用者輸入的帳號密碼,若正確,就呼叫 EmailService 物件的 Send 方法來將驗證碼寄送至使用者的 e-mail 信箱。EmailService 的 Send 方法就是單純發送電子郵件而已,這部分的實作細節並不重要,故未列出程式碼;CheckPassword 方法以及 User 類別的程式碼也是基於同樣的理由省略(User 物件會包含使用者的基本聯絡資訊,如 e-mail 位址、手機號碼等等)。

主程式的部分則是利用 AuthenticationService 來處理使用者登入程序。這裡用一個簡化過的 MainApp 類別來表示,程式碼如下,我想應該不用多做解釋。

class MainApp
{
    public void Login(string userId, string password)
    {
        var authService = new AuthenticationService();
        if (authService.TwoFactorLogin(userId, password))
        {
            if (authService.VerifyToken("使用者輸入的驗證碼"))
            {
                // 登入成功。
            }
        }
        // 登入失敗。
    }
}

此範例目前涉及四個類別:MainApp、AuthenticationService、User、EmailService。它們的相依關係如下圖所示,圖中的箭頭代表相依關係的依賴方向。



透過這張圖可以很容易看出來,代表主程式的 MainApp 類別需要使用 AuthenticationService 提供的驗證服務,而該服務又依賴 User 和 EmailService 類別。就它們之間的角色關係來說,AuthenticationService 對 MainApp 而言是個「服務端」(service)的角色,對於 User 和 EmailService 而言則是「用戶端」(client;有時也說「呼叫端」)的角色。

目前的設計,基本上可以滿足功能需求。但有個問題:萬一將來使用者想要改用手機簡訊來接收驗證碼,怎麼辦?稍後你會看到,此問題凸顯了目前設計上的一個缺陷:它違反了「開放/封閉原則」。


開放/封閉原則

「開放/封閉原則」(Open/Close Principle;OCP)指的是軟體程式的單元(類別、模組、函式等等)應該要夠開放,以便擴充功能,同時要夠封閉,以避免修改既有的程式碼。換言之,此原則的目的是希望能在不修改既有程式碼的前提下增加新的功能。須注意的是,遵循開放/封閉原則通常會引入新的抽象層,使程式碼不易閱讀,並增加程式碼的複雜度。在設計時,應該將此原則運用在將來最有可能變動的地方,而非試圖讓整個系統都符合此原則。

一般公認最早提出 OCP 的是 Bertrand Meyer,後來由 Robert C. Martin(又名鮑伯[Uncle Bob])重新詮釋,成為目前為人熟知的物件導向設計原則之一。鮑伯在他的《Agile Software Development: Principles, Patterns, and Practices》書中詳細介紹了五項設計原則,並且給它們一個好記的縮寫:S.O.L.I.D.。它們分別是:
  • SRP(Single Responsibility Principle):單一責任原則。一個類別應該只有一個責任。
  • OCP(Open/Closed Principle):開放/封閉原則。對開放擴充,對修改封閉。
  • LSP(Liskov Substitution Principle):里氏替換原則。物件應該要可以被它的子類別的物件替換,且完全不影響程式的既有行為。
  • ISP(Interface Segregation Principle):介面隔離原則。多個規格明確的小介面要比一個包山包海的大型介面好。
  • DIP(Dependency Inversion Principle):相依反轉原則。依賴抽象型別,而不是具象型別。

後續章節若再碰到這些原則,將會進一步說明。您也可以參考剛才提到的書籍,繁體中文版書名為《敏捷軟體開發:原則、樣式及實務》 。出版社:碁峰。譯者:林昆穎、吳京子。


針對此需求變動,一個天真而快速的解法,是增加一個提供發送簡訊服務的類別:ShortMessageService,然後修改 AuthenticationService,把原本用到 EmailService 的程式碼換成新的類別,像這樣:

class AuthenticationService
{
    private ShortMessageService msgService;

    public AuthenticationService()
    {
        msgSevice = new ShortMessageService(); // 建立用來發送驗證碼的物件
    }

    public bool TwoFactorLogin(string userId, string pwd)
    {
        // 沒有變動,故省略。
    }
}

其中的 TwoFactorLogin 方法的實作完全沒變,是因為 ShortMessageService 類別也有一個 Send 方法,而且這方法跟 EmailService 的 Send 方法長得一模一樣:接受兩個傳入參數,一個是 User 物件,另一個是訊息內容。底下同時列出兩個類別的原始碼。

class EmailService
{
    public void Send(User user, string msg)
    {
        // 寄送電子郵件給指定的 user (略)
    }
}

class ShortMessageService
{
    public void Send(User user, string msg)
    {
        // 發送簡訊給指定的 user (略)
    }
}

你可以看到,這種解法僅僅改動了 AuthenticationService 類別的兩個地方:
  • 私有成員 msgService 的型別。
  • 建構函式中,原本是建立 EmailService 物件,現在改為 ShortMessageService。

剩下要做的,就只是編譯整個專案,然後部署新版本的應用程式。這種解法的確改得很快,程式碼變動也很少,但是卻沒有解決根本問題。於是,麻煩很快就來了:使用者反映,他們有時想要用 e-mail 接收登入驗證碼,有時想要用手機簡訊。這表示應用程式得在登入畫面中提供一個選項,讓使用者選擇以何種方式接收驗證碼。這也意味著程式內部實作必須要能夠支援執行時期動態切換「提供發送驗證碼服務的類別」。為了達到執行時期動態切換實作類別,相依型別之間的繫結就不能在編譯時期決定,而必須採用動態繫結。

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

未完待續....

1 則留言:

  1. 感謝大大的筆記,又學到了不少東西,感謝~

    回覆刪除

技術提供:Blogger.
回頂端⬆️