轉載自:
http://www.contextfree.net/wangyw/source/dip_ioc.html
依賴倒置、控制反轉和依賴注入辨析
在《道法自然——面向?qū)ο髮嵺`指南》一書中,我們采用了一個對立統(tǒng)一的辯證關系來說明“模板方法”模式—— “正向依賴 vs. 依賴倒置”(參見:《道法自然》第15章[王詠武, 王詠剛 2004])。這種把“好萊塢”原則和 “依賴倒置”原則等量齊觀的看法其實來自于輕量級容器PicoContainer主頁上的一段話:
“控制反轉(Inversion of Control)的一個著名的同義原則是由Robert C. Martin提出的依賴倒置原則(Dependency Inversion Principle),它的另一個昵稱是好萊塢原則(Hollywood Principle:不要調(diào)用我,讓我來調(diào)用你)”[PicoContainer 2004]。
和網(wǎng)友們在CSDN Blog上進行了深入的討論后,我又把這些概念重新梳理了一下。我發(fā)現(xiàn),這幾個概念雖然在思路和動機等宏觀層面上是統(tǒng)一的,但在具體的應用層面還是存在著許多很微妙的差別。本文通過幾個簡單的例子對依賴倒置(Dependency Inversion Principle)、控制反轉(Inversion of Control)、依賴注入(Dependency Injection)等概念進行了更為深入的辨析,也算是對于《道法自然》正文內(nèi)容的一個補充吧。
依賴和耦合(Dependency and Coupling)
Rational Rose的幫助文檔上是這樣定義“依賴”關系的:“依賴描述了兩個模型元素之間的關系,如果被依賴的模型元素發(fā)生變化就會影響到另一個模型元素。典型的,在類圖上,依賴關系表明客戶類的操作會調(diào)用服務器類的操作。”
Martin Fowler在《Reducing Coupling》一文中這樣描述耦合:“如果改變程序的一個模塊要求另一個模塊同時發(fā)生變化,就認為這兩個模塊發(fā)生了耦合。” [Fowler 2001]
從上面的定義可以看出:如果模塊A調(diào)用模塊B提供的方法,或訪問模塊B中的某些數(shù)據(jù)成員(當然,在面向?qū)ο箝_發(fā)中一般不提倡這樣做),我們就認為模塊A依賴于模塊B,模塊A和模塊B之間發(fā)生了耦合。
那么,依賴對于我們來說究竟是好事還是壞事呢?
由于人類的理解力有限,大多數(shù)人難以理解和把握過于復雜的系統(tǒng)。把軟件系統(tǒng)劃分成多個模塊,可以有效控制模塊的復雜度,使每個模塊都易于理解和維護。但在這種情況下,模塊之間就必須以某種方式交換信息,也就是必然要發(fā)生某種耦合關系。如果某個模塊和其它模塊沒有任何關聯(lián)(哪怕只是潛在的或隱含的依賴關系),我們就幾乎可以斷定,該模塊不屬于此軟件系統(tǒng),應該從系統(tǒng)中剔除。如果所有模塊之間都沒有任何耦合關系,其結果必然是:整個軟件不過是多個互不相干的系統(tǒng)的簡單堆積,對每個系統(tǒng)而言,所有功能還是要在一個模塊中實現(xiàn),這等于沒有做任何模塊的分解。
因此,模塊之間必定會有這樣或那樣的依賴關系,永遠不要幻想消除所有依賴。但是,過強的耦合關系(如一個模塊的變化會造成一個或多個其他模塊也同時發(fā)生變化的依賴關系)會對軟件系統(tǒng)的質(zhì)量造成很大的危害。特別是當需求發(fā)生變化時,代碼的維護成本將非常高。所以,我們必須想盡辦法來控制和消解不必要的耦合,特別是那種會導致其它模塊發(fā)生不可控變化的依賴關系。依賴倒置、控制反轉、依賴注入等原則就是人們在和依賴關系進行艱苦卓絕的斗爭過程中不斷產(chǎn)生和發(fā)展起來的。
接口和實現(xiàn)分離
把接口和實現(xiàn)分開是人們試圖控制依賴關系的第一個嘗試,圖 1是Robert C. Martin在《依賴倒置》[Martin 1996]一文中所舉的第一個例子。其中,ReadKeyboard()和WritePrinter()為函數(shù)庫中的兩個函數(shù),應用程序循環(huán)調(diào)用這兩個函數(shù),以便把用戶鍵入的字符拷貝到打印機輸出。
為了使應用程序不依賴于函數(shù)庫的具體實現(xiàn),C語言把函數(shù)的定義寫在了一個分離的頭文件(函數(shù)庫.h)中。這種做法的好處是:雖然應用程序要調(diào)用函數(shù)庫、依賴于函數(shù)庫,但是,當我們要改變函數(shù)庫的實現(xiàn)時,只要重寫函數(shù)的實現(xiàn)代碼,應用程序無需發(fā)生變化。例如,改變函數(shù)庫.c文件,把WritePrinter()函數(shù)重新實現(xiàn)成向磁盤中輸出,這時只要將應用程序和函數(shù)庫重新鏈接,程序的功能就會發(fā)生相應的變化。
上面的函數(shù)庫也可以采用C++語言來實現(xiàn)。我們通常把這種用面向?qū)ο蠹夹g實現(xiàn)的,為應用程序提供多個支持類的模塊稱為 “類庫”,如圖 2所示。這種通過分離接口和實現(xiàn)來消解應用程序和類庫之間依賴關系的做法具有以下特點:
1. 應用程序調(diào)用類庫,依賴于類庫。
2. 接口和實現(xiàn)的分離從一定的程度上消解了這個依賴關系,具體實現(xiàn)可以在編譯期間發(fā)生變化。但是,這種消解方法的作用非常有限。比如說,一個系統(tǒng)中無法容納多個實現(xiàn),不同的實現(xiàn)不能動態(tài)發(fā)生變化,用WritePrinter函數(shù)名來實現(xiàn)向磁盤中輸出的功能也顯得非常古怪,等等。
3. 類庫可以單獨重用。但是應用程序不能脫離類庫而重用,除非提供一個實現(xiàn)了相同接口的類庫。
依賴倒置(Dependency Inversion Principle)
可以看出,上面討論的簡單分離接口的方法對于依賴關系的消解作用非常有限。Java語言提供了純粹的接口類,這種接口類不包括任何實現(xiàn)代碼,可以更好地隔離兩個模塊。C++語言中雖然沒有定義這種純粹的接口類,但所有成員函數(shù)都是純虛函數(shù)的抽象類也不包含任何實現(xiàn)代碼,可以起到類似于Java接口類的作用。為了和上一節(jié)中提到的簡單接口相區(qū)別,本文后面將把基于Java 接口類或C++抽象類定義的接口稱為抽象接口。依賴倒置原則就是建立在抽象接口的基礎上的。Robert Martin這樣描述依賴倒置原則[Martin 1996]:
A. 上層模塊不應該依賴于下層模塊,它們共同依賴于一個抽象。
B. 抽象不能依賴于具象,具象依賴于抽象。
其含義是:為了消解兩個模塊間的依賴關系,應該在兩個模塊之間定義一個抽象接口,上層模塊調(diào)用抽象接口定義的函數(shù),下層模塊實現(xiàn)該接口。如圖 3所示,對于上一節(jié)的例子,我們可以定義兩個抽象類Reader和Writer作為抽象接口,其中的Read()和Write()函數(shù)都是純虛函數(shù),而具體的KeyboardReader和PrinterWriter類實現(xiàn)了這些接口。當應用程序調(diào)用Read()和Write()函數(shù)時,由于多態(tài)性機制的作用,實際調(diào)用的是具體的KeyboardReader和PrinterWriter類中的實現(xiàn)。因此,抽象接口隔離了應用程序和類庫中的具體類,使它們之間沒有直接的耦合關系,可以獨立地擴展或重用。例如,我們可以用類似的方法實現(xiàn)FileReader或DiskWriter類,應用程序既可以根據(jù)需要選擇從鍵盤或文件輸入,也可以選擇向打印機或磁盤輸出,甚至同時完成多種不同的輸入、輸出任務。由此可以總結出,這種通過抽象接口消解應用程序和類庫之間依賴關系的做法具有以下特點:
1. 應用程序調(diào)用類庫的抽象接口,依賴于類庫的抽象接口;具體的實現(xiàn)類派生自類庫的抽象接口,也依賴于類庫的抽象接口。
2. 應用程序和具體的類庫實現(xiàn)完全獨立,相互之間沒有直接的依賴關系,只要保持接口類的穩(wěn)定,應用程序和類庫的具體實現(xiàn)都可以獨立地發(fā)生變化。
3. 類庫完全可以獨立重用,應用程序可以和任何一個實現(xiàn)了相同抽象接口的類庫協(xié)同工作。

一般情況下,由于類庫的設計者并不知道應用程序會如何使用類庫,抽象接口大多由類庫設計者根據(jù)自己設想的典型使用模式總結出來,并保留一定的靈活度,以提供給應用程序的開發(fā)者使用。
但還有另外一種情況。圖 4是Martin Fowler在《Reducing Coupling》一文中使用的一個例子[Fowler 2001]。其中,Domain包要使用數(shù)據(jù)庫包,即Domain包依賴于數(shù)據(jù)庫包。為了隔離Domain包和數(shù)據(jù)庫包,可以引入一個Mapper包。如果在特定的情況下,我們希望Domain包能夠被多次重用,而Mapper包可以隨時變化,那么,我們就必須防止Domain包過分地依賴于Mapper包。這時,可以由 Domain包的設計者總結出自己需要的抽象接口(如Store),而由Mapper包的設計者來實現(xiàn)該抽象接口。這樣一來,無論是在接口層面,還是在實現(xiàn)層面,依賴關系都完全顛倒過來了。
控制反轉(Inversion of Control)
前面描述的是應用程序和類庫之間的依賴關系。如果我們開發(fā)的不是類庫,而是框架系統(tǒng),依賴關系就會更強烈一點。那么,該如何消解框架和應用程序之間的依賴關系呢?
《道法自然》第5章描述了框架和類庫之間的區(qū)別:
“框架和類庫最重要的區(qū)別是:框架是一個‘半成品’的應用程序,而類庫只包含一系列可被應用程序調(diào)用的類。
“類庫給用戶提供了一系列可復用的類,這些類的設計都符合面向?qū)ο笤瓌t和模式。用戶使用時,可以創(chuàng)建這些類的實例,或從這些類中繼承出新的派生類,然后調(diào)用類中相應的功能。在這一過程中,類庫總是被動地響應用戶的調(diào)用請求。
“框架則會為某一特定目的實現(xiàn)一個基本的、可執(zhí)行的架構??蚣苤幸呀?jīng)包含了應用程序從啟動到運行的主要流程,流程中那些無法預先確定的步驟留給用戶來實現(xiàn)。程序運行時,框架系統(tǒng)自動調(diào)用用戶實現(xiàn)的功能組件。這時,框架系統(tǒng)的行為是主動的。
“我們可以說,類庫是死的,而框架是活的。應用程序通過調(diào)用類庫來完成特定的功能,而框架則通過調(diào)用應用程序來實現(xiàn)整個操作流程??蚣苁强刂频怪迷瓌t的完美體現(xiàn)。”
框架系統(tǒng)的一個最好的例子就是圖形用戶界面(GUI)系統(tǒng)。一個簡單的,使用面向過程的設計方法開發(fā)的GUI系統(tǒng)如圖 5所示。

從圖 5中可以看出,應用程序調(diào)用GUI框架中的CreateWindow()函數(shù)來創(chuàng)建窗口,在這里,我們可以說應用程序依賴于GUI框架。但GUI框架并不了解該窗口接收到窗口消息后應該如何處理,這一點只有應用程序最為清楚。因此,當GUI框架需要發(fā)送窗口消息時,又必須調(diào)用應用程序定義的某個特定的窗口函數(shù)(如上圖中的MyWindowProc)。這時,GUI框架又必須依賴于應用程序。這是一個典型的雙向依賴關系。這種雙向依賴關系有一個非常嚴重的缺陷:由于GUI框架調(diào)用了應用程序中的某個特定函數(shù)(MyWindowProc), GUI框架根本無法獨立存在;換一個新的應用程序,GUI框架多半就要做相應的修改。因此,如何消解框架系統(tǒng)對應用程序的依賴關系是實現(xiàn)框架系統(tǒng)的關鍵。
并非只有面向?qū)ο蟮姆椒ú拍芙鉀Q這一問題。WIN32 API早就為我們提供了在面向過程的設計思路下解決類似問題的范例。類WIN32 的架構模型如圖 6所示。
在圖 6中,應用程序調(diào)用CreateWindow()函數(shù)時,要傳遞一個消息處理函數(shù)的指針給GUI框架(對WIN32而言,我們在注冊窗口類時傳遞這一指針),GUI框架把該指針記錄在窗口信息結構中。需要發(fā)送窗口消息時,GUI框架就通過該指針調(diào)用窗口函數(shù)。和圖 5 相比,GUI框架仍然需要調(diào)用應用程序,但這一調(diào)用從一個硬編碼的函數(shù)調(diào)用變成了一個由應用程序事先注冊被調(diào)用對象的動態(tài)調(diào)用。圖 6用一條虛線表示這種動態(tài)調(diào)用??梢钥闯?,這種動態(tài)的調(diào)用關系有一個非常大的好處:當應用程序發(fā)生變化時,它可以自行改變框架系統(tǒng)的調(diào)用目標,GUI框架無需隨之發(fā)生變化?,F(xiàn)在,我們可以說,雖然還存在著從GUI框架到應用程序的調(diào)用關系,但GUI框架已經(jīng)完全不再依賴于應用程序了。這種動態(tài)調(diào)用機制通常也被稱為“回調(diào)函數(shù)”。
在面向?qū)ο箢I域,“回調(diào)函數(shù)”的替代物就是“模板方法模式”,也就是“好萊塢原則(不要調(diào)用我們,讓我們調(diào)用你)”。GUI框架的一個面向?qū)ο蟮膶崿F(xiàn)如圖 7所示。
圖 7中,“GUI框架抽象接口”是GUI框架系統(tǒng)提供給應用程序使用的接口。抽象出該接口的動機是根據(jù)“依賴倒置”的原則,消解從應用程序到GUI框架之間的直接依賴關系,以使得GUI框架實現(xiàn)的變化對應用程序的影響最小化。Window接口類則是“模板方法模式”的核心。應用程序調(diào)用CreateWindow()函數(shù)時,GUI框架會把該窗口的引用保存在窗口鏈表中。需要發(fā)送窗口消息時,GUI框架就調(diào)用窗口對象的SendMessage()函數(shù),該函數(shù)是實現(xiàn)在Window類中的非虛成員函數(shù)。SendMessage()函數(shù)又調(diào)用WindowProc()虛函數(shù),這里實際執(zhí)行的是應用程序MyWindow類中實現(xiàn)的WindowProc()函數(shù)。在圖 7中,我們已經(jīng)看不到從GUI框架到應用程序之間的直接依賴關系了。因此,模板方法模式完全實現(xiàn)了回調(diào)函數(shù)的動態(tài)調(diào)用機制,消解了從框架到應用程序之間的依賴關系。
從上面的分析可以看出,模板方法模式是框架系統(tǒng)的基礎,任何框架系統(tǒng)都離不開模板方法模式。Martin Fowler也說 [Folwer 2004],“幾位輕量級容器的作者曾驕傲地對我說:這些容器非常有用,因為它們實現(xiàn)了‘控制反轉’。這樣的說辭讓我深感迷惑:控制反轉是框架所共有的特征,如果僅僅因為使用了控制反轉就認為這些輕量級容器與眾不同,就好像在說‘我的轎車是與眾不同的,因為它有四個輪子’。問題的關鍵在于:它們反轉了哪方面的控制?我第一次接觸到的控制反轉針對的是用戶界面的主控權。早期的用戶界面是完全由應用程序來控制的,你預先設計一系列命令,例如‘輸入姓名’、‘輸入地址’等,應用程序逐條輸出提示信息,并取回用戶的響應。而在圖形用戶界面環(huán)境下,UI 框架將負責執(zhí)行一個主循環(huán),你的應用程序只需為屏幕的各個區(qū)域提供事件處理函數(shù)即可。在這里,程序的主控權發(fā)生了反轉:從應用程序移到了框架。”
確實:對比圖 3和圖 7可以看出,使用普通類庫時,程序的主循環(huán)位于應用程序中,而使用框架系統(tǒng)的應用程序不再包括一個主循環(huán),只是實現(xiàn)某些框架定義的接口,框架系統(tǒng)負責實現(xiàn)系統(tǒng)運行的主循環(huán),并在必要的時候通過模板方法模式調(diào)用應用程序。
也就是說,雖然“依賴倒置”和“控制反轉”在設計層面上都是消解模塊耦合的有效方法,也都是試圖令具體的、易變的模塊依賴于抽象的、穩(wěn)定的模塊的基本原則,但二者在使用語境和關注點上存在差異:“依賴倒置”強調(diào)的是對于傳統(tǒng)的、源于面向過程設計思想的層次概念的“倒置”,而“控制反轉”強調(diào)的是對程序流程控制權的反轉;“依賴倒置”的使用范圍更為寬泛,既可用于對程序流程的描述(如流程的主從和層次關系),也可用于描述其他擁有概念層次的設計模型(如服務組件與客戶組件、核心模塊與外圍應用等),而“控制反轉”則僅適用于描述流程控制權的場合(如算法流程或業(yè)務流程的控制權)。
從某種意義上說,我們也可以把“控制反轉”看作是“依賴倒置”的一個特例。例如,用模板方法模式實現(xiàn)的“控制反轉”機制其實就是在框架系統(tǒng)和應用程序之間抽象出了一個描述所有算法步驟原型的接口類,框架系統(tǒng)依賴于該接口類定義并實現(xiàn)程序流程,應用程序依賴于該接口類提供具體算法步驟的實現(xiàn),應用程序?qū)蚣芟到y(tǒng)的依賴被“倒置”為二者對抽象接口的依賴。
總地說來,應用程序和框架系統(tǒng)之間的依賴關系有以下特點:
1. 應用程序和框架系統(tǒng)之間實際上是雙向調(diào)用,雙向依賴的關系。
2. 依賴倒置原則可以減弱應用程序到框架之間的依賴關系。
3. “控制反轉”及具體的模板方法模式可以消解框架到應用程序之間的依賴關系,這也是所有框架系統(tǒng)的基礎。
4. 框架系統(tǒng)可以獨立重用。
依賴注入(Dependency Injection)
在前面的例子里,我們通過“依賴倒置”原則,最大限度地減弱了應用程序Copy類和類庫提供的服務Read,Write之間的依賴關系。但是,如果需要把Copy()函數(shù)也實現(xiàn)在類庫中,又會發(fā)生什么情況呢?假設在類庫中實現(xiàn)一個“服務類”,“服務類”提供Copy()方法供應用程序使用。應用程序使用時,首先創(chuàng)建“服務類”的實例,調(diào)用其中的Copy()函數(shù)。“服務類”的實例初始化時會創(chuàng)建KeyboardReader 和PrinterWriter類的實例對象。如圖 8所示。
從圖 8中可以看出,雖然Reader和Writer接口隔離了“服務類”和具體的Reader和Writer類,使它們之間的耦合降到了最小。但當 “服務類”創(chuàng)建具體的Reader和Writer對象時,“服務類”還是和具體的Reader和Writer對象發(fā)生了依賴關系——圖 8中用藍色的虛線描述了這種依賴關系。
在這種情況下,如何實例化具體的Reader和Writer類,同時又盡量減少服務類對它們的依賴,就是一個非常關鍵的問題了。如果服務類位于應用程序中,這一依賴關系對我們造成的影響還不算大。但當“服務類”位于需要獨立發(fā)布的類庫中,它的代碼就不能隨著應用程序的變化而改變了。這也意味著,如果“服務類”過度依賴于具體的Reader和Writer類,用戶就無法自行添加新的Reader和Writer 的實現(xiàn)了。
解決這一問題的方法是“依賴注入”,即切斷“服務類”到具體的Reader和Writer類之間的依賴關系,而由應用程序來注入這一依賴關系。如圖 9所示。
在圖 9中,“服務類”并不負責創(chuàng)建具體的Reader和Writer類的實例對象,而是由應用程序來創(chuàng)建。應用程序創(chuàng)建“服務類”的實例對象時,把具體的Reader和Write對象的引用注入“服務類”內(nèi)部。這樣,“服務類”中的代碼就只和抽象接口相關的了。具體實現(xiàn)代碼發(fā)生變化時,“服務類”不會發(fā)生任何變化。添加新的實現(xiàn)時,也只需要改變應用程序的代碼,就可以定義并使用新的Reader和Writer類,這種依賴注入方式通常也被稱為“構造器注入”。
如果專門為Copy類抽象出一個注入接口,應用程序通過接口注入依賴關系,這種注入方式通常被稱為“接口注入”。如果為Copy類提供一個設值函數(shù),應用程序通過調(diào)用設值函數(shù)來注入依賴關系,這種依賴注入的方法被稱為“設值注入”。具體的“接口注入”和“設值注入”請參考[Martin 2004]。
PicoContainer和Spring輕量級容器框架都提供了相應的機制來幫助用戶實現(xiàn)各種不同的“依賴注入”。并且,通過不同的方式,他們也都支持在XML文件中定義依賴關系,然后由應用程序調(diào)用框架來注入依賴關系,當依賴關系需要發(fā)生變化時,只要修改相應的 XML文件即可。
因此,依賴注入的核心思想是:
1. 抽象接口隔離了使用者和實現(xiàn)之間的依賴關系,但創(chuàng)建具體實現(xiàn)類的實例對象仍會造成對于具體實現(xiàn)的依賴。
2. 采用依賴注入可以消除這種創(chuàng)建依賴性。使用依賴注入后,某些類完全是基于抽象接口編寫而成的,這可以最大限度地適應需求的變化。
結論
分離接口和實現(xiàn)是人們有效地控制依賴關系的最初嘗試,而純粹的抽象接口更好地隔離了相互依賴的兩個模塊,“依賴倒置”和 “控制反轉”原則從不同的角度描述了利用抽象接口消解耦合的動機,GoF的設計模式正是這一動機的完美體現(xiàn)。具體類的創(chuàng)建過程是另一種常見的依賴關系,“依賴注入”模式可以把具體類的創(chuàng)建過程集中到合適的位置,這一動機和GoF的創(chuàng)建型模式有相似之處。
這些原則對我們的實踐有很好的指導作用,但它們不是圣經(jīng),在不同的場合可能會有不同的變化,我們應該在開發(fā)過程中根據(jù)需求變化的可能性靈活運用。
參考文獻