天天看點

控制反轉和依賴注入

“控制反轉(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的建立型模式有相似之處。

  這些原則對我們的實踐有很好的指導作用,但它們不是聖經,在不同的場合可能會有不同的變化,我們應該在開發過程中根據需求變化的可能性靈活運用。

繼續閱讀