原文地址:Inversion of Control Containers and the Dependency Injection pattern中文翻譯版本是網上的PDF文檔,發佈在這裡僅為方便查看。原文作者:Martin Fowler,翻譯:透明。Java 社群近來掀起了一陣輕量級容器的熱潮,這...
原文地址:Inversion of Control Containers and the Dependency Injection pattern
中文翻譯版本是網上的PDF文檔,發佈在這裡僅為方便查看。原文作者:Martin Fowler,翻譯:透明。
Java 社群近來掀起了一陣輕量級容器的熱潮,這些容器能夠幫助開發者將來自不同項目的組件組裝成為一個內聚的應用程式。在它們的背後有著同一個模式,這個模式決定了這些容器進行組件裝配的方式。人們用一個大而化之的名字來稱呼這個模式:“控制反轉”(Inversion of Control,IoC)。在本文中,我將深入探索這個模式的工作原理,給它一個更能描述其特點的名字——“依賴註入”(Dependency Injection),並將其與“服務定位器”(Service Locator)模式作一個比較。不過,這兩者之間的差異並不太重要,更重要的是:應該將組件的配置與使用分離開——兩個模式的目標都是這個。
在企業級Java的世界里存在一個有趣的現象:有很多人投入很多精力來研究主流J2EE技術的替代品——自然,這大多發生在open source社群。在很大程度上,這可以看作是開發者對主流J2EE技術的笨重和複雜作出的回應,但其中的確有很多極富創意的想法,的確提供了一些可供選擇的方案。J2EE開發者常遇到的一個問題就是如何組裝不同的程式元素:如果web控制器體繫結構和資料庫介面是由不同的團隊所開發的,彼此幾乎一無所知,你應該如何讓它們配合工作?很多框架嘗試過解決這個問題,有幾個框架索性朝這個方向發展,提供了更通用的“組裝各層組件”的方案。這樣的框架通常被稱為“輕量級容器”,PicoContainer和Spring都在此列中。
在這些容器背後,一些有趣的設計原則發揮著作用。這些原則已經超越了特定容器的範疇,甚至已經超越了Java平臺的範疇。在本文中,我就要初步揭示這些原則。我使用的範例是Java代碼,但正如我的大多數文章一樣,這些原則也同樣適用於別的OO環境,特別是.NET。
組件和服務
“裝配程式元素”,這樣的話題立即將我拖進了一個棘手的術語問題:如何區分“服務”(service)和“組件”(component)?你可以毫不費力地找出關於這兩個詞定義的長篇大論,各種彼此矛盾的定義會讓你感受到我所處的窘境。有鑒於此,對於這兩個遭到了嚴重濫用的辭彙,我將首先說明它們在本文中的用法。
所謂“組件”是指這樣一個軟體單元:它將被作者無法控制的其他應用程式使用,但後者不能對組件進行修改。也就是說,使用一個組件的應用程式不能修改組件的源代碼,但可以通過作者預留的某種途徑對其進行擴展,以改變組件的行為。
服務和組件有某種相似之處:它們都將被外部的應用程式使用。在我看來,兩者之間最大的差異在於:組件是在本地使用的(例如JAR文件、程式集、DLL、或者源碼導入);而服務是要通過——同步或非同步的——遠程介面來遠程使用的(例如web service、消息系統、RPC,或者socket)。
在本文中,我將主要使用“服務”這個詞,但文中的大多數邏輯也同樣適用於本地組件。實際上,為了方便地訪問遠程服務,你往往需要某種本地組件框架。不過,“組件或者服務”這樣一個片語實在太麻煩了,而且“服務”這個詞當下也很流行,所以本文將用“服務”指代這兩者。
一個簡單的例子
為了更好地說明問題,我要引入一個例子。和我以前用的所有例子一樣,這是一個超級簡單的例子:它非常小,小得有點不夠真實,但足以幫助你看清其中的道理,而不至於陷入真實例子的泥潭中無法自拔。
在這個例子中,我編寫了一個組件,用於提供一份電影清單,清單上列出的影片都是由一位特定的導演執導的。實現這個偉大的功能只需要一個方法:
class MovieLister... public Movie[] moviesDirectedBy(String arg) { List allMovies = finder.findAll(); for (Iterator it = allMovies.iterator(); it.hasNext();) { Movie movie = (Movie) it.next(); if (!movie.getDirector().equals(arg)) it.remove(); } return (Movie[]) allMovies.toArray(new Movie[allMovies.size()]); }
你可以看到,這個功能的實現極其簡單:moviesDirectedBy方法首先請求finder(影片搜尋者)對象(我們稍後會談到這個對象)返回後者所知道的所有影片,然後遍歷finder對象返回的清單,並返回其中由特定的某個導演執導的影片。非常簡單,不過不必擔心,這隻是整個例子的腳手架罷了。
我們真正想要考察的是finder對象,或者說,如何將MovieLister對象與特定的finder對象連接起來。為什麼我們對這個問題特別感興趣?因為我希望上面這個漂亮的moviesDirectedBy方法完全不依賴於影片的實際存儲方式。所以,這個方法只能引用一個finder對象,而finder對象則必須知道如何對findAll方法作出回應。為了幫助讀者更清楚地理解,我給finder定義了一個介面:
public interface MovieFinder { List findAll(); }
現在,兩個對象之間沒有什麼耦合關係。但是,當我要實際尋找影片時,就必須涉及到MovieFinder的某個具體子類。在這裡,我把“涉及具體子類”的代碼放在MovieLister類的構造子中。
class MovieLister... private MovieFinder finder; public MovieLister() { finder = new ColonDelimitedMovieFinder("movies1.txt"); }
這個實現類的名字就說明:我將要從一個逗號分隔的文本文件中獲得影片列表。你不必操心具體的實現細節,只要設想這樣一個實現類就可以了。
如果這個類只由我自己使用,一切都沒問題。但是,如果我的朋友嘆服於這個精彩的功能,也想使用我的程式,那又會怎麼樣呢?如果他們也把影片清單保存在一個逗號分隔的文本文件中,並且也把這個文件命名為“movie1.txt”,那麼一切還是沒問題。如果他們只是給這個文件改改名,我也可以從一個配置文件獲得文件名,這也很容易。但是,如果他們用完全不同的方式——例如SQL資料庫、XML文件、web service,或者另一種格式的文本文件——來存儲影片清單呢?在這種情況下,我們需要用另一個類來獲取數據。由於已經定義了MovieFinder介面,我可以不用修改moviesDirectedBy方法。但是,我仍然需要通過某種途徑獲得合適的MovieFinder實現類的實例。
圖1:“在MovieLister類中直接創建MovieFinder實例時的依賴關係
圖1展現了這種情況下的依賴關係:MovieLister類既依賴於MovieFinder介面,也依賴於具體的實現類。我們當然希望MovieLister類只依賴於介面,但我們要如何獲得一個MovieFinder子類的實例呢?
在Patterns of Enterprise Application Architecture一書中,我們把這種情況稱為“插件”(plugin):MovieFinder的實現類不是在編譯期連入程式之中的,因為我並不知道我的朋友會使用哪個實現類。我們希望MovieLister類能夠與MovieFinder的任何實現類配合工作,並且允許在運行期插入具體的實現類,插入動作完全脫離我(原作者)的控制。這裡的問題就是:如何設計這個連接過程,使MovieLister類在不知道實現類細節的前提下與其實例協同工作。
將這個例子推而廣之,在一個真實的系統中,我們可能有數十個服務和組件。在任何時候,我們總可以對使用組件的情形加以抽象,通過介面與具體的組件交流(如果組件並沒有設計一個介面,也可以通過適配器與之交流)。但是,如果我們希望以不同的方式部署這個系統,就需要用插件機制來處理服務之間的交互過程,這樣我們才可能在不同的部署方案中使用不同的實現。
所以,現在的核心問題就是:如何將這些插件組合成一個應用程式?這正是新生的輕量級容器所面臨的主要問題,而它們解決這個問題的手段無一例外地是控制反轉(Inversion of Control)模式。
控制反轉
幾位輕量級容器的作者曾驕傲地對我說:這些容器非常有用,因為它們實現了“控制反轉”。這樣的說辭讓我深感迷惑:控制反轉是框架所共有的特征,如果僅僅因為使用了控制反轉就認為這些輕量級容器與眾不同,就好象在說“我的轎車是與眾不同的,因為它有四個輪子”。
問題的關鍵在於:它們反轉了哪方面的控制?我第一次接觸到的控制反轉針對的是用戶界面的主控權。早期的用戶界面是完全由應用程式來控制的,你預先設計一系列命令,例如“輸入姓名”、“輸入地址”等,應用程式逐條輸出提示信息,並取回用戶的響應。而在圖形用戶界面環境下,UI框架將負責執行一個主迴圈,你的應用程式只需為屏幕的各個區域提供事件處理函數即可。在這裡,程式的主控權發生了反轉:從應用程式移到了框架。
對於這些新生的容器,它們反轉的是“如何定位插件的具體實現”。在前面那個簡單的例子中,MovieLister類負責定位MovieFinder的具體實現——它直接實例化後者的一個子類。這樣一來,MovieFinder也就不成其為一個插件了,因為它並不是在運行期插入應用程式中的。而這些輕量級容器則使用了更為靈活的辦法,只要插件遵循一定的規則,一個獨立的組裝模塊就能夠將插件的具體實現“註射”到應用程式中。
因此,我想我們需要給這個模式起一個更能說明其特點的名字——“控制反轉”這個名字太泛了,常常讓人有些迷惑。與多位IoC愛好者討論之後,我們決定將這個模式叫做“依賴註入”(Dependency Injection)。
下麵,我將開始介紹Dependency Injection模式的幾種不同形式。不過,在此之前,我要首先指出:要消除應用程式對插件實現的依賴,依賴註入並不是唯一的選擇,你也可以用Service Locator模式獲得同樣的效果。介紹完Dependency Injection模式之後,我也會談到Service Locator模式。
依賴註入的幾種形式
Dependency Injection模式的基本思想是:用一個單獨的對象(裝配器)來獲得MovieFinder的一個合適的實現,並將其實例賦給MovieLister類的一個欄位。這樣一來,我們就得到了圖2所示的依賴圖:
圖2:引入依賴註入器之後的依賴關係
依賴註入的形式主要有三種,我分別將它們叫做構造子註入(Constructor Injection)、設值方法註入(Setter Injection)和介面註入(Interface Injection)。如果讀過最近關於IoC的一些討論材料,你不難看出:這三種註入形式分別就是 type 1 IoC(介面註入)、type 2 IoC(設值方法註入) 和 type 3 IoC(構造子註入)。我發現數字編號往往比較難記,所以我使用了這裡的命名方式。
使用PicoContainer進行構造子註入
首先,我要向讀者展示如何用一個名為PicoContainer的輕量級容器完成依賴註入。之所以從這裡開始,主要是因為我在ThoughtWorks公司的幾個同事在PicoContainer的開發社群中非常活躍——沒錯,也可以說是某種偏袒吧。
PicoContainer通過構造子來判斷“如何將MovieFinder實例註入MovieLister類”。因此,MovieLister類必須聲明一個構造子,併在其中包含所有需要註入的元素:
class MovieLister... public MovieLister(MovieFinder finder) { this.finder = finder; }
MovieFinder實例本身也將由PicoContainer來管理,因此文本文件的名字也可以由容器註入:
class ColonMovieFinder... public ColonMovieFinder(String filename) { this.filename = filename; }
隨後,需要告訴PicoContainer:各個介面分別與哪個實現類關聯、將哪個字元串註入MovieFinder組件。
private MutablePicoContainer configureContainer() { MutablePicoContainer pico = new DefaultPicoContainer(); Parameter[] finderParams = {new ConstantParameter("movies1.txt")}; pico.registerComponentImplementation(MovieFinder.class, ColonMovieFinder.class, finderParams); pico.registerComponentImplementation(MovieLister.class); return pico; }
這段配置代碼通常位於另一個類。對於我們這個例子,使用我的MovieLister類的朋友需要在自己的設置類中編寫合適的配置代碼。當然,還可以將這些配置信息放在一個單獨的配置文件中,這也是一種常見的做法。你可以編寫一個類來讀取配置文件,然後對容器進行合適的設置。儘管PicoContainer本身並不包含這項功能,但另一個與它關係緊密的項目NanoContainer提供了一些包裝,允許開發者使用XML配置文件保存配置信息。NanoContainer能夠解析XML文件,並對底下的PicoContainer進行配置。這個項目的哲學觀念就是:將配置文件的格式與底下的配置機制分離開。
使用這個容器,你寫出的代碼大概會是這樣:
public void testWithPico() { MutablePicoContainer pico = configureContainer(); MovieLister lister = (MovieLister) pico.getComponentInstance(MovieLister.class); Movie[] movies = lister.moviesDirectedBy("Sergio Leone"); assertEquals("Once Upon a Time in the West", movies[0].getTitle()); }
儘管在這裡我使用了構造子註入,實際上PicoContainer也支持設值方法註入,不過該項目的開發者更推薦使用構造子註入。
使用Spring進行設值方法註入
Spring框架是一個用途廣泛的企業級Java開發框架,其中包括了針對事務、持久化框架、web應用開發和JDBC等常用功能的抽象。和 PicoContainer 一樣,它也同時支持構造子註入和設值方法註入,但該項目的開發者更推薦使用設值方法註入——恰好適合這個例子。
為了讓MovieLister類接受註入,我需要為它定義一個設值方法,該方法接受類型為MovieFinder的參數:
class MovieLister... private MovieFinder finder; public void setFinder(MovieFinder finder) { this.finder = finder; }
類似地,在MovieFinder的實現類中,我也定義了一個設值方法,接受類型為String的參數:
class ColonMovieFinder... public void setFilename(String filename) { this.filename = filename; }
第三步是設定配置文件。Spring支持多種配置方式,你可以通過XML文件進行配置,也可以直接在代碼中配置。不過,XML文件是比較理想的配置方式。
<beans> <bean id="MovieLister" class="spring.MovieLister"> <property name="finder"> <ref local="MovieFinder"/> </property> </bean> <bean id="MovieFinder" class="spring.ColonMovieFinder"> <property name="filename"> <value>movies1.txt</value> </property> </bean> </beans>
於是,測試代碼大概就像下麵這樣:
public void testWithSpring() throws Exception { ApplicationContext ctx = new FileSystemXmlApplicationContext("spring.xml"); MovieLister lister = (MovieLister) ctx.getBean("MovieLister"); Movie[] movies = lister.moviesDirectedBy("Sergio Leone"); assertEquals("Once Upon a Time in the West", movies[0].getTitle()); }
介面註入
除了前面兩種註入技術,還可以在介面中定義需要註入的信息,並通過介面完成註入。Avalon框架就使用了類似的技術。在這裡,我首先用簡單的範例代碼說明它的用法,後面還會有更深入的討論。
首先,我需要定義一個介面,組件的註入將通過這個介面進行。在本例中,這個介面的用途是將一個MovieFinder實例註入繼承了該介面的對象。
public interface InjectFinder { void injectFinder(MovieFinder finder); }
這個介面應該由提供MovieFinder介面的人一併提供。任何想要使用MovieFinder實例的類(例如MovieLister類)都必須實現這個介面。
class MovieLister implements InjectFinder... public void injectFinder(MovieFinder finder) { this.finder = finder; }
然後,我使用類似的方法將文件名註入MovieFinder的實現類:
public interface InjectFilename { void injectFilename (String filename); } class ColonMovieFinder implements MovieFinder, InjectFilename...... public void injectFilename(String filename) { this.filename = filename; }
現在,還需要用一些配置代碼將所有的組件實現裝配起來。簡單起見,我直接在代碼中完成配置,並將配置好的MovieLister對象保存在名為lister的欄位中:
class IfaceTester... private MovieLister lister; private void configureLister() { ColonMovieFinder finder = new ColonMovieFinder(); finder.injectFilename("movies1.txt"); lister = new MovieLister(); lister.injectFinder(finder); }
測試代碼則可以直接使用這個欄位:
class IfaceTester... public void testIface() { configureLister(); Movie[] movies = lister.moviesDirectedBy("Sergio Leone"); assertEquals("Once Upon a Time in the West",movies[0].getTitle()); }
使用Service Locator
依賴註入的最大好處在於:它消除了MovieLister類對具體MovieFinder實現類的依賴。這樣一來,我就可以把MovieLister類交給朋友,讓他們根據自己的環境插入一個合適的MovieFinder實現即可。不過,Dependency Injection模式並不是打破這層依賴關係的唯一手段,另一種方法是使用 Service Locator 模式。
Service Locator 模式背後的基本思想是:有一個對象(即服務定位器)知道如何獲得一個應用程式所需的所有服務。也就是說,在我們的例子中,服務定位器應該有一個方法,用於獲得一個MovieFinder實例。當然,這不過是把麻煩換了一個樣子,我們仍然必須在 MovieLister 中獲得服務定位器,最終得到的依賴關係如圖3所示:
圖3:使用Service Locator模式之後的依賴關係
在這裡,我把ServiceLocator類實現為一個Singleton的註冊表,於是MovieLister就可以在實例化時通過ServiceLocator獲得一個MovieFinder實例。
class MovieLister... MovieFinder finder = ServiceLocator.movieFinder();
class ServiceLocator... public static MovieFinder movieFinder() { return soleInstance.movieFinder; }
private static ServiceLocator soleInstance; private MovieFinder movieFinder;
和註入的方式一樣,我們也必須對服務定位器加以配置。在這裡,我直接在代碼中進行配置,但設計一種通過配置文件獲得數據的機制也並非難事。
class Tester... private void configure() { ServiceLocator.load(new ServiceLocator(new ColonMovieFinder("movies1.txt"))); }
class ServiceLocator... public static void load(ServiceLocator arg) { soleInstance = arg; } public ServiceLocator(MovieFinder movieFinder) { this.movieFinder = movieFinder; }
下麵是測試代碼:
class Tester... public void testSimple() { configure(); MovieLister lister = new MovieLister(); Movie[] movies = lister.moviesDirectedBy("Sergio Leone"); assertEquals("Once Upon a Time in the West", movies[0].getTitle()); }
我時常聽到這樣的論調:這樣的服務定位器不是什麼好東西,因為你無法替換它返回的服務實現,從而導致無法對它們進行測試。當然,如果你的設計很糟糕,你的確會遇到這樣的麻煩;但你也可以選擇良好的設計。在這個例子中,ServiceLocator實例僅僅是一個簡單的數據容器,只需要對它做一些簡單的修改,就可以讓它返回用於測試的服務實現。
對於更複雜的情況,我可以從ServiceLocator派生出多個子類,並將子類型的實例傳遞給註冊表的類變數。另外,我可以修改ServiceLocator的靜態方法,使其調用ServiceLocator實例的方法,而不是直接訪問實例變數。我還可以使用特定於線程的存儲機制,從而提供特定於線程的服務定位器。所有這一切改進都無須修改ServiceLocator的使用者。
一種改進的思路是:服務定位器仍然是一個註冊表,但不是Singleton。Singleton的確是實現註冊表的一種簡單途徑,但這隻是一個實現時的決定,可以很輕鬆地改變它。
為定位器提供分離的介面
上面這種簡單的實現方式有一個問題:MovieLister類將依賴於整個ServiceLocator類,但它需要使用的卻只是後者所提供的一項服務。我們可以針對這項服務提供一個單獨的介面,減少MovieLister對ServiceLocator的依賴程度。這樣一來,MovieLister就不必使用整個的ServiceLocator介面,只需聲明它想要使用的那部分介面。
此時,MovieLister類的提供者也應該一併提供一個定位器介面,使用者可以通過這個介面獲得MovieFinder實例。
public interface MovieFinderLocator { public MovieFinder movieFinder();
真實的服務定位器需要實現上述介面,提供訪問MovieFinder實例的能力:
MovieFinderLocator locator = ServiceLocator.locator(); MovieFinder finder = locator.movieFinder(); public static ServiceLocator locator() { return soleInstance; } public MovieFinder movieFinder() { return movieFinder; } private static ServiceLocator soleInstance; private MovieFinder movieFinder;
你應該已經註意到了:由於想要使用介面,我們不能再通過靜態方法直接訪問服務——我們必須首先通過ServiceLocator類獲得定位器實例,然後使用定位器實例得到我們想要的服務。
動態服務定位器
上面是一個靜態定位器的例子——對於你所需要的每項服務,ServiceLocator類都有對應的方法。這並不是實現服務定位器的唯一方式,你也可以創建一個動態服務定位器,你可以在其中註冊需要的任何服務,併在運行期決定獲得哪一項服務。在本例中,ServiceLocator使用一個map來保存服務信息,而不再是將這些信息保存在欄位中。此外,Service Locator還提供了一個通用的方法,用於獲取和載入服務對象。
class ServiceLocator... private static ServiceLocator soleInstance; public static void load(ServiceLocator arg) { soleInstance = arg; } private Map services = new HashMap(); public static Object getService(String key){ return soleInstance.services.get(key); } public void loadService (String key, Object service) { services.put(key, service); }
同樣需要對服務定位器進行配置,將服務對象與適當的關鍵字載入到定位器中:
class Tester... private void configure() { ServiceLocator locator = new ServiceLocator(); locator.loadService("MovieFinder", new ColonMovieFinder("movies1.txt")); ServiceLocator.load(locator); }
我使用與服務對象類名稱相同的字元串作為服務對象的關鍵字:
class MovieLister... MovieFinder finder = (MovieFinder)ServiceLocator.getService("MovieFinder");
總體而言,我不喜歡這種方式。無疑,這樣實現的服務定位器具有更強的靈活性,但它的使用方式不夠直觀明朗。我只有通過文本形式的關鍵字才能找到一個服務對象。相比之下,我更欣賞“通過一個方法明確獲得服務對象”的方式,因為這讓使用者能夠從介面定義中清楚地知道如何獲得某項服務。
用Avalon兼顧服務定位器和依賴註入
Dependency Injection和Service Locator兩個模式並不是互斥的,你可以同時使用它們,Avalon框架就是這樣的一個例子。Avalon使用了服務定位器,但“如何獲得定位器”的信息則是通過註入的方式告知組件的。對於前面一直使用的例子,Berin Loritsch發送給了我一個簡單的Avalon實現版本:
public class MyMovieLister implements MovieLister, Serviceable { private MovieFinder finder; public void service(ServiceManager manager) throws ServiceException { finder = (MovieFinder)manager.lookup("finder"); }
service方法就是介面註入的例子,它使容器可以將一個ServiceManager對象註入MyMovieLister對象。ServiceM