向依賴關係宣戰

向依賴關係宣戰
                                        依賴倒置、控制反轉和依賴注入辨析
在《道法自然——面向對象實踐指南》一書中,我們採用了一個對立統一的辯證關係來說明“模板方法”模式—— “正向依賴 vs. 依賴倒置”(參見:《道法自然》第15章[王詠武, 王詠剛 2004])。這種把“好萊塢”原則和 “依賴倒置”原則等量齊觀的看法其實來自於輕量級容器PicoContainer主頁上的一段話:
  “控制反轉(Inversion of Control)的一個著名的同義原則是由Robert C. Martin提出的依賴倒置原則(Dependency Inversion Principle),它的另一個暱稱是好萊塢原則(Hollywood Principle:不要調用我,讓我來調用你)”[PicoContainer 2004]。
  和網友們在CSDN Blog上進行了深入的討論後,我又把這些概念重新梳理了一下。我發現,這幾個概念雖然在思路和動機等宏觀層面上是統一的,但在具體的應用層面還是存在着許多很微妙的差別。本文通過幾個簡單的例子對依賴倒置(Dependency Inversion Principle)、控制反轉(Inversion of Control)、依賴注入(Dependency Injection)等概念進行了更爲深入的辨析,也算是對於《道法自然》正文內容的一個補充吧。

依賴和耦合(Dependency and Coupling) 

  在《道法自然——面向對象實踐指南》一書中,我們採用了一個對立統一的辯證關係來說明“模板方法”模式—— “正向依賴 vs. 依賴倒置”(參見:《道法自然》第15章[王詠武, 王詠剛 2004])。這種把“好萊塢”原則和 “依賴倒置”原則等量齊觀的看法其實來自於輕量級容器PicoContainer主頁上的一段話:
  首先來看一下依賴和耦合的概念。
  Rational Rose的幫助文檔上是這樣定義“依賴”關係的:“依賴描述了兩個模型元素之間的關係,如果被依賴的模型元素髮生變化就會影響到另一個模型元素。典型的,在類圖上,依賴關係表明客戶類的操作會調用服務器類的操作。”
  Martin Fowler在《Reducing Coupling》一文中這樣描述耦合:“如果改變程序的一個模塊要求另一個模塊同時發生變化,就認爲這兩個模塊發生了耦合。” [Fowler 2001]
  從上面的定義可以看出:如果模塊A調用模塊B提供的方法,或訪問模塊B中的某些數據成員(當然,在面向對象開發中一般不提倡這樣做),我們就認爲模塊A依賴於模塊B,模塊A和模塊B之間發生了耦合。
  那麼,依賴對於我們來說究竟是好事還是壞事呢?
  由於人類的理解力有限,大多數人難以理解和把握過於複雜的系統。把軟件系統劃分成多個模塊,可以有效控制模塊的複雜度,使每個模塊都易於理解和維護。但在這種情況下,模塊之間就必須以某種方式交換信息,也就是必然要發生某種耦合關係。如果某個模塊和其它模塊沒有任何關聯(哪怕只是潛在的或隱含的依賴關係),我們就幾乎可以斷定,該模塊不屬於此軟件系統,應該從系統中剔除。如果所有模塊之間都沒有任何耦合關係,其結果必然是:整個軟件不過是多個互不相干的系統的簡單堆積,對每個系統而言,所有功能還是要在一個模塊中實現,這等於沒有做任何模塊的分解。
  因此,模塊之間必定會有這樣或那樣的依賴關係,永遠不要幻想消除所有依賴。但是,過強的耦合關係(如一個模塊的變化會造成一個或多個其他模塊也同時發生變化的依賴關係)會對軟件系統的質量造成很大的危害。特別是當需求發生變化時,代碼的維護成本將非常高。所以,我們必須想盡辦法來控制和消解不必要的耦合,特別是那種會導致其它模塊發生不可控變化的依賴關係。依賴倒置、控制反轉、依賴注入等原則就是人們在和依賴關係進行艱苦卓絕的鬥爭過程中不斷產生和發展起來的。

接口和實現分離 

  把接口和實現分開是人們試圖控制依賴關係的第一個嘗試,圖 1是Robert C. Martin在《依賴倒置》[Martin 1996]一文中所舉的第一個例子。其中,ReadKeyboard()和WritePrinter()爲函數庫中的兩個函數,應用程序循環調用這兩個函數,以便把用戶鍵入的字符拷貝到打印機輸出。

爲了使應用程序不依賴於函數庫的具體實現,C語言把函數的定義寫在了一個分離的頭文件(函數庫.h)中。這種做法的好處是:雖然應用程序要調用函數庫、依賴於函數庫,但是,當我們要改變函數庫的實現時,只要重寫函數的實現代碼,應用程序無需發生變化。例如,改變函數庫.c文件,把WritePrinter()函數重新實現成向磁盤中輸出,這時只要將應用程序和函數庫重新鏈接,程序的功能就會發生相應的變化。
  上面的函數庫也可以採用C++語言來實現。我們通常把這種用面向對象技術實現的,爲應用程序提供多個支持類的模塊稱爲 “類庫”,如圖 2所示。這種通過分離接口和實現來消解應用程序和類庫之間依賴關係的做法具有以下特點:
  1. 應用程序調用類庫,依賴於類庫。
  2. 接口和實現的分離從一定的程度上消解了這個依賴關係,具體實現可以在編譯期間發生變化。但是,這種消解方法的作用非常有限。比如說,一個系統中無法容納多個實現,不同的實現不能動態發生變化,用WritePrinter函數名來實現向磁盤中輸出的功能也顯得非常古怪,等等。
  3. 類庫可以單獨重用。但是應用程序不能脫離類庫而重用,除非提供一個實現了相同接口的類庫。 

依賴倒置(Dependency Inversion Principle) 

  可以看出,上面討論的簡單分離接口的方法對於依賴關係的消解作用非常有限。Java語言提供了純粹的接口類,這種接口類不包括任何實現代碼,可以更好地隔離兩個模塊。C++語言中雖然沒有定義這種純粹的接口類,但所有成員函數都是純虛函數的抽象類也不包含任何實現代碼,可以起到類似於Java接口類的作用。爲了和上一節中提到的簡單接口相區別,本文後面將把基於Java 接口類或C++抽象類定義的接口稱爲抽象接口。依賴倒置原則就是建立在抽象接口的基礎上的。Robert Martin這樣描述依賴倒置原則[Martin 1996]:
  A. 上層模塊不應該依賴於下層模塊,它們共同依賴於一個抽象。
  B. 抽象不能依賴於具象,具象依賴於抽象。
  其含義是:爲了消解兩個模塊間的依賴關係,應該在兩個模塊之間定義一個抽象接口,上層模塊調用抽象接口定義的函數,下層模塊實現該接口。如圖 3所示,對於上一節的例子,我們可以定義兩個抽象類Reader和Writer作爲抽象接口,其中的Read()和Write()函數都是純虛函數,而具體的KeyboardReader和PrinterWriter類實現了這些接口。當應用程序調用Read()和Write()函數時,由於多態性機制的作用,實際調用的是具體的KeyboardReader和PrinterWriter類中的實現。因此,抽象接口隔離了應用程序和類庫中的具體類,使它們之間沒有直接的耦合關係,可以獨立地擴展或重用。例如,我們可以用類似的方法實現FileReader或DiskWriter類,應用程序既可以根據需要選擇從鍵盤或文件輸入,也可以選擇向打印機或磁盤輸出,甚至同時完成多種不同的輸入、輸出任務。由此可以總結出,這種通過抽象接口消解應用程序和類庫之間依賴關係的做法具有以下特點:
  1. 應用程序調用類庫的抽象接口,依賴於類庫的抽象接口;具體的實現類派生自類庫的抽象接口,也依賴於類庫的抽象接口。
  2. 應用程序和具體的類庫實現完全獨立,相互之間沒有直接的依賴關係,只要保持接口類的穩定,應用程序和類庫的具體實現都可以獨立地發生變化。
  3. 類庫完全可以獨立重用,應用程序可以和任何一個實現了相同抽象接口的類庫協同工作。

一般情況下,由於類庫的設計者並不知道應用程序會如何使用類庫,抽象接口大多由類庫設計者根據自己設想的典型使用模式總結出來,並保留一定的靈活度,以提供給應用程序的開發者使用。
  但還有另外一種情況。圖 4是Martin Fowler在《Reducing Coupling》一文中使用的一個例子[Fowler 2001]。其中,Domain包要使用數據庫包,即Domain包依賴於數據庫包。爲了隔離Domain包和數據庫包,可以引入一個Mapper包。如果在特定的情況下,我們希望Domain包能夠被多次重用,而Mapper包可以隨時變化,那麼,我們就必須防止Domain包過分地依賴於Mapper包。這時,可以由 Domain包的設計者總結出自己需要的抽象接口(如Store),而由Mapper包的設計者來實現該抽象接口。這樣一來,無論是在接口層面,還是在實現層面,依賴關係都完全顛倒過來了。  

控制反轉(Inversion of Control) 

  前面描述的是應用程序和類庫之間的依賴關係。如果我們開發的不是類庫,而是框架系統,依賴關係就會更強烈一點。那麼,該如何消解框架和應用程序之間的依賴關係呢?
  《道法自然》第5章描述了框架和類庫之間的區別:
  “框架和類庫最重要的區別是:框架是一個‘半成品’的應用程序,而類庫只包含一系列可被應用程序調用的類。
  “類庫給用戶提供了一系列可複用的類,這些類的設計都符合面向對象原則和模式。用戶使用時,可以創建這些類的實例,或從這些類中繼承出新的派生類,然後調用類中相應的功能。在這一過程中,類庫總是被動地響應用戶的調用請求。
  “框架則會爲某一特定目的實現一個基本的、可執行的架構。框架中已經包含了應用程序從啓動到運行的主要流程,流程中那些無法預先確定的步驟留給用戶來實現。程序運行時,框架系統自動調用用戶實現的功能組件。這時,框架系統的行爲是主動的。
  “我們可以說,類庫是死的,而框架是活的。應用程序通過調用類庫來完成特定的功能,而框架則通過調用應用程序來實現整個操作流程。框架是控制倒置原則的完美體現。”
  框架系統的一個最好的例子就是圖形用戶界面(GUI)系統。一個簡單的,使用面向過程的設計方法開發的GUI系統如圖 5所示。 

 從圖 5中可以看出,應用程序調用GUI框架中的CreateWindow()函數來創建窗口,在這裏,我們可以說應用程序依賴於GUI框架。但GUI框架並不瞭解該窗口接收到窗口消息後應該如何處理,這一點只有應用程序最爲清楚。因此,當GUI框架需要發送窗口消息時,又必須調用應用程序定義的某個特定的窗口函數(如上圖中的MyWindowProc)。這時,GUI框架又必須依賴於應用程序。這是一個典型的雙向依賴關係。這種雙向依賴關係有一個非常嚴重的缺陷:由於GUI框架調用了應用程序中的某個特定函數(MyWindowProc), GUI框架根本無法獨立存在;換一個新的應用程序,GUI框架多半就要做相應的修改。因此,如何消解框架系統對應用程序的依賴關係是實現框架系統的關鍵。
  並非只有面向對象的方法才能解決這一問題。WIN32 API早就爲我們提供了在面向過程的設計思路下解決類似問題的範例。類WIN32 的架構模型如圖 6所示。 

在圖 6中,應用程序調用CreateWindow()函數時,要傳遞一個消息處理函數的指針給GUI框架(對WIN32而言,我們在註冊窗口類時傳遞這一指針),GUI框架把該指針記錄在窗口信息結構中。需要發送窗口消息時,GUI框架就通過該指針調用窗口函數。和圖 5 相比,GUI框架仍然需要調用應用程序,但這一調用從一個硬編碼的函數調用變成了一個由應用程序事先註冊被調用對象的動態調用。圖 6用一條虛線表示這種動態調用。可以看出,這種動態的調用關係有一個非常大的好處:當應用程序發生變化時,它可以自行改變框架系統的調用目標,GUI框架無需隨之發生變化。現在,我們可以說,雖然還存在着從GUI框架到應用程序的調用關係,但GUI框架已經完全不再依賴於應用程序了。這種動態調用機制通常也被稱爲“回調函數”。
  在面向對象領域,“回調函數”的替代物就是“模板方法模式”,也就是“好萊塢原則(不要調用我們,讓我們調用你)”。GUI框架的一個面向對象的實現如圖 7所示。 

 圖 7中,“GUI框架抽象接口”是GUI框架系統提供給應用程序使用的接口。抽象出該接口的動機是根據“依賴倒置”的原則,消解從應用程序到GUI框架之間的直接依賴關係,以使得GUI框架實現的變化對應用程序的影響最小化。Window接口類則是“模板方法模式”的核心。應用程序調用CreateWindow()函數時,GUI框架會把該窗口的引用保存在窗口鏈表中。需要發送窗口消息時,GUI框架就調用窗口對象的SendMessage()函數,該函數是實現在Window類中的非虛成員函數。SendMessage()函數又調用WindowProc()虛函數,這裏實際執行的是應用程序MyWindow類中實現的WindowProc()函數。在圖 7中,我們已經看不到從GUI框架到應用程序之間的直接依賴關係了。因此,模板方法模式完全實現了回調函數的動態調用機制,消解了從框架到應用程序之間的依賴關係。
  從上面的分析可以看出,模板方法模式是框架系統的基礎,任何框架系統都離不開模板方法模式。Martin Fowler也說 [Folwer 2004],“幾位輕量級容器的作者曾驕傲地對我說:這些容器非常有用,因爲它們實現了‘控制反轉’。這樣的說辭讓我深感迷惑:控制反轉是框架所共有的特徵,如果僅僅因爲使用了控制反轉就認爲這些輕量級容器與衆不同,就好像在說‘我的轎車是與衆不同的,因爲它有四個輪子’。問題的關鍵在於:它們反轉了哪方面的控制?我第一次接觸到的控制反轉針對的是用戶界面的主控權。早期的用戶界面是完全由應用程序來控制的,你預先設計一系列命令,例如‘輸入姓名’、‘輸入地址’等,應用程序逐條輸出提示信息,並取回用戶的響應。而在圖形用戶界面環境下,UI 框架將負責執行一個主循環,你的應用程序只需爲屏幕的各個區域提供事件處理函數即可。在這裏,程序的主控權發生了反轉:從應用程序移到了框架。”
  確實:對比圖 3和圖 7可以看出,使用普通類庫時,程序的主循環位於應用程序中,而使用框架系統的應用程序不再包括一個主循環,只是實現某些框架定義的接口,框架系統負責實現系統運行的主循環,並在必要的時候通過模板方法模式調用應用程序。
  也就是說,雖然“依賴倒置”和“控制反轉”在設計層面上都是消解模塊耦合的有效方法,也都是試圖令具體的、易變的模塊依賴於抽象的、穩定的模塊的基本原則,但二者在使用語境和關注點上存在差異:“依賴倒置”強調的是對於傳統的、源於面向過程設計思想的層次概念的“倒置”,而“控制反轉”強調的是對程序流程控制權的反轉;“依賴倒置”的使用範圍更爲寬泛,既可用於對程序流程的描述(如流程的主從和層次關係),也可用於描述其他擁有概念層次的設計模型(如服務組件與客戶組件、核心模塊與外圍應用等),而“控制反轉”則僅適用於描述流程控制權的場合(如算法流程或業務流程的控制權)。
  從某種意義上說,我們也可以把“控制反轉”看作是“依賴倒置”的一個特例。例如,用模板方法模式實現的“控制反轉”機制其實就是在框架系統和應用程序之間抽象出了一個描述所有算法步驟原型的接口類,框架系統依賴於該接口類定義並實現程序流程,應用程序依賴於該接口類提供具體算法步驟的實現,應用程序對框架系統的依賴被“倒置”爲二者對抽象接口的依賴。
  總地說來,應用程序和框架系統之間的依賴關係有以下特點:
  1. 應用程序和框架系統之間實際上是雙向調用,雙向依賴的關係。
  2. 依賴倒置原則可以減弱應用程序到框架之間的依賴關係。
  3. “控制反轉”及具體的模板方法模式可以消解框架到應用程序之間的依賴關係,這也是所有框架系統的基礎。
  4. 框架系統可以獨立重用。

依賴注入(Dependency Injection) 

  在前面的例子裏,我們通過“依賴倒置”原則,最大限度地減弱了應用程序Copy類和類庫提供的服務Read,Write之間的依賴關係。但是,如果需要把Copy()函數也實現在類庫中,又會發生什麼情況呢?假設在類庫中實現一個“服務類”,“服務類”提供Copy()方法供應用程序使用。應用程序使用時,首先創建“服務類”的實例,調用其中的Copy()函數。“服務類”的實例初始化時會創建KeyboardReader 和PrinterWriter類的實例對象。如圖 8所示。 

從圖 8中可以看出,雖然Reader和Writer接口隔離了“服務類”和具體的Reader和Writer類,使它們之間的耦合降到了最小。但當 “服務類”創建具體的Reader和Writer對象時,“服務類”還是和具體的Reader和Writer對象發生了依賴關係——圖 8中用藍色的虛線描述了這種依賴關係。
  在這種情況下,如何實例化具體的Reader和Writer類,同時又儘量減少服務類對它們的依賴,就是一個非常關鍵的問題了。如果服務類位於應用程序中,這一依賴關係對我們造成的影響還不算大。但當“服務類”位於需要獨立發佈的類庫中,它的代碼就不能隨着應用程序的變化而改變了。這也意味着,如果“服務類”過度依賴於具體的Reader和Writer類,用戶就無法自行添加新的Reader和Writer 的實現了。
  解決這一問題的方法是“依賴注入”,即切斷“服務類”到具體的Reader和Writer類之間的依賴關係,而由應用程序來注入這一依賴關係。如圖 9所示。 

在圖 9中,“服務類”並不負責創建具體的Reader和Writer類的實例對象,而是由應用程序來創建。應用程序創建“服務類”的實例對象時,把具體的Reader和Write對象的引用注入“服務類”內部。這樣,“服務類”中的代碼就只和抽象接口相關的了。具體實現代碼發生變化時,“服務類”不會發生任何變化。添加新的實現時,也只需要改變應用程序的代碼,就可以定義並使用新的Reader和Writer類,這種依賴注入方式通常也被稱爲“構造器注入”。
  如果專門爲Copy類抽象出一個注入接口,應用程序通過接口注入依賴關係,這種注入方式通常被稱爲“接口注入”。如果爲Copy類提供一個設值函數,應用程序通過調用設值函數來注入依賴關係,這種依賴注入的方法被稱爲“設值注入”。具體的“接口注入”和“設值注入”請參考[Martin 2004]。
  PicoContainer和Spring輕量級容器框架都提供了相應的機制來幫助用戶實現各種不同的“依賴注入”。並且,通過不同的方式,他們也都支持在XML文件中定義依賴關係,然後由應用程序調用框架來注入依賴關係,當依賴關係需要發生變化時,只要修改相應的 XML文件即可。
  因此,依賴注入的核心思想是:
  1. 抽象接口隔離了使用者和實現之間的依賴關係,但創建具體實現類的實例對象仍會造成對於具體實現的依賴。
  2. 採用依賴注入可以消除這種創建依賴性。使用依賴注入後,某些類完全是基於抽象接口編寫而成的,這可以最大限度地適應需求的變化。

結論 

  分離接口和實現是人們有效地控制依賴關係的最初嘗試,而純粹的抽象接口更好地隔離了相互依賴的兩個模塊,“依賴倒置”和 “控制反轉”原則從不同的角度描述了利用抽象接口消解耦合的動機,GoF的設計模式正是這一動機的完美體現。具體類的創建過程是另一種常見的依賴關係,“依賴注入”模式可以把具體類的創建過程集中到合適的位置,這一動機和GoF的創建型模式有相似之處。
  這些原則對我們的實踐有很好的指導作用,但它們不是聖經,在不同的場合可能會有不同的變化,我們應該在開發過程中根據需求變化的可能性靈活運用。

發佈了18 篇原創文章 · 獲贊 9 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章