2013年6月27日 星期四

[轉載] IoC 容器和 Dependency Injection 模式

原作者: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 的某個具體子類。在這裡,我把「涉及具體子類」的代碼放在 MovieFinder 類的建構子中。

class MovieLister...
   private MovieFinder finder;
   public MovieLister () {
      finder = new ColonDelimitedMovieFinder ("movies1.txt");
   }


這個實作類別的名字就說明:我將要從一個逗號分隔的文字檔中獲得影片列表。你不必操心具體的實作細節,只要設想這樣一個實作類別就可以了。

如果這個類只由我自己使用,一切都沒問題。但是,如果我的朋友嘆服于這個精彩的功能,也想使用我的程式,那又會怎麼樣呢?如果他們也把影片清單保存在一個逗號分隔的文字檔中,並且也把這個檔命名為「 movie1.txt 」,那麼一切還是沒問題。如果他們只是給這個檔改改名,我也可以從一個設定檔獲得檔案名,這也很容易。但是,如果他們用完全不同的方式——例如 SQL 資料庫、XML 檔、web service,或者另一種格式的文字檔——來存儲影片清單呢?在這種情況下,我們需要用另一個類來獲取資料。由於已經定義了 MovieFinder 介面,我可以不用修改 moviesDirectedBy 方法。但是,我仍然需要通過某種途徑獲得合適的 MovieFinder 實作類別的實例。



圖 1:「在 MovieFinder 類中直接創建 MovieFinder 實例」時的依賴關係

圖 1 展現了這種情況下的依賴關係: MovieFinder 類既依賴於 MovieFinder 介面,也依賴於具體的實作類別。我們當然希望 MovieFinder 類只依賴於介面,但我們要如何獲得一個 MovieFinder 子類的實例呢?

在「Patterns of Enterprise Application Architecture」一書中,我們把這種情況稱為「Plugin」(外掛程式): MovieFinder 的實作類別不是在編譯期連入程式之中的,因為我並不知道我的朋友會使用哪個實作類別。我們希望 MovieFinder 類能夠與 MovieFinder 的任何實作類別配合工作,並且允許在運行期插入具體的實作類別,插入動作完全脫離我(原作者)的控制。這裡的問題就是:如何設計這個連接過程,使 MovieFinder 類在不知道實作類別細節的前提下與其實例協同工作。

將這個例子推而廣之,在一個真實的系統中,我們可能有數十個服務和元件。在任何時候,我們總可以對使用元件的情形加以抽象,通過介面與具體的元件交流(如果元件並沒有設計一個介面,也可以通過適配器 Adapter 與之交流)。但是,如果我們希望以不同的方式部署這個系統,就需要用外掛程式機制來處理服務之間的交互過程,這樣我們才可能在不同的部署方案中使用不同的實作。

所以,現在的核心問題就是:如何將這些外掛程式組合成一個應用程式?這正是新生的羽量級容器所面臨的主要問題,而它們解決這個問題的手段無一例外地是控制反轉(Inversion of Control)模式。

控制反轉

幾位羽量級容器的作者曾驕傲地對我說:這些容器非常有用,因為它們實作了「控制反轉」。這樣的說辭讓我深感迷惑:控制反轉是框架所共有的特徵,如果僅僅因為使用了控制反轉就認為這些羽量級容器與眾不同,就好象在說「我的轎車是與眾不同的,因為它有四個輪子」。

問題的關鍵在於:它們反轉了哪方面的控制?我第一次接觸到的控制反轉針對的是使用者介面的主控權。早期的使用者介面是完全由應用程式來控制的,你預先設計一系列命令,例如「輸入姓名」、「輸入位址」等,應用程式逐條輸出提示資訊,並取回使用者的回應。而在圖形化使用者介面環境下,UI 框架將負責執行一個主迴圈,你的應用程式只需為螢幕的各個區域提供事件處理函數即可。在這裡,程式的主控權發生了反轉:從應用程式移到了框架。

對於這些新生的容器,它們反轉的是「如何查找外掛程式的具體實作」。在前面那個簡單的例子中, MovieLister 類負責查找 MovieFinder 的具體實作 —— 它直接實例化後者的一個子類。這樣一來, MovieFinder 也就不是一個外掛程式了,因為它並不是在運行期插入應用程式中的。而這些羽量級容器則使用了更為靈活的辦法,只要外掛程式遵循一定的規則,一個獨立的組裝模組就能夠將外掛程式的具體實作「注射」到應用程式中。

因此,我想我們需要給這個模式起一個更能說明其特點的名字——「控制反轉」這個名字太泛了,常常讓人有些迷惑。與多位 IoC 愛好者討論之後,我們決定將這個模式叫做「依賴注入」(Dependency Injection)。

下面,我將開始介紹 Dependency Injection 模式的幾種不同形式。不過,在此之前,我要首先指出:要消除應用程式對外掛程式實作的依賴,依賴注入並不是唯一的選擇,你也可以用 ServiceLocator 模式獲得同樣的效果。介紹完 Dependency Injection 模式之後,我也會談到 Service Locator 模式。

依賴注入的幾種形式

Dependency Injection 模式的基本思想是:用一個單獨的物件(裝配器)來獲得 MovieFinder 的一個合適的實作,並將其實例賦給 MovieFinder 類的一個欄位。這樣一來,我們就得到了圖 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 InjectFinderFilename {
      void injectFilename (String filename);
   }

   class ColonMovieFinder implements MovieFinder, InjectFinderFilename......
      public void injectFilename(String filename) {
         this.filename = filename;
   }


現在,還需要用一些配置代碼將所有的元件實作裝配起來。簡單起見,我直接在代碼中完成配置:

   class Tester...
      private Container container;

      private void configureContainer() {
         container = new Container();

         registerComponents();
         registerInjectors();
         container.start();
      }

配置分成了兩個階段,通過查找關鍵字來註冊元件,這和其它的例子一樣:

   class Tester...
      private void registerComponents() {
      
   container.registerComponent("MovieLister", MovieLister.class);
         container.registerComponent("MovieFinder", ColonMovieFinder.class);
      }

下一步就是註冊要依賴元件的注入器,每一個注入介面都需要一些代碼來注入到依賴的物件。這裡我使用容器來完成注入器的註冊。每一個注入器物件都實作了注入介面。

   class Tester...
   
   private void registerInjectors() {
         container.registerInjector(InjectFinder.class, container.lookup("MovieFinder"));
         container.registerInjector(InjectFinderFilename.class, new FinderFilenameInjector ());
      }

   public interface Injector {
      public void inject(Object target);
   }

當依賴設計成是一個為容器而寫的類那麼它對元件自身實作介面注入是有意義的,就像這裡我對 MoiveFinder 做的修改一樣。對於普通類別,比如 String,我使用內部類別(inner class)來完成配置代碼。

   class ColonMovieFinder implements Injector......
      public void inject(Object target) {
         ((InjectFinder) target).injectFinder(this);        
   }

   class Tester...
   
   public static class FinderFilenameInjector implements Injector {
         public void inject(Object target) {
         ((InjectFinderFilename)target).injectFilename("movies1.txt");      
      }
   }


測試代碼則可以直接使用這個容器:

class IfaceTester...
    public void testIface() {
      configureContainer();
      MovieLister lister = (MovieLister)container.lookup("MovieLister");
      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 的註冊表(Registry),於是 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 介面,只需聲明它想要使用的那部分介面。

此時, MovieFinder 類的提供者也應該一併提供一個定位器介面,使用者可以通過這個介面獲得 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 來保存服務資訊,而不再是將這些資訊保存在容器中。此外, ServiceLocator 還提供了一個通用的方法,用於獲取和載入服務物件。

   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 對象。 ServiceManager 則是一個服務定位器。在這個例子中, MyMovieLister 並不把 ServiceManager 對象保存在欄位中,而是馬上借助它找到 MovieFinder 實例,並將後者保存起來。

作出選擇

到現在為止,我一直在闡述自己對這兩個模式(Dependency Injection 模式和 Service Locator 模式)以及它們的變化形式的看法。現在,我要開始討論他們的優點和缺點,以便指出它們各自適用的場景。

Service Locator vs. Dependency Injection

首先,我們面臨 Service Locator 和 Dependency Injection 之間的選擇。應該注意,儘管我們前面那個簡單的例子不足以表現出來,實際上這兩個模式都提供了基本的解耦合能力——無論使用哪個模式,應用程式碼都不依賴於服務介面的具體實作。兩者之間最重要的區別在於:這個「具體實作」以什麼方式提供給應用程式碼。使用 Service Locator 模式時,應用程式代碼直接向服務定位器發送一個消息,明確要求服務的實作;使用 Dependency Injection 模式時,應用程式碼不發出顯式的請求,服務的實作自然會出現在應用程式碼中,這也就是所謂「控制反轉」。

控制反轉是框架的共同特徵,但它也要求你付出一定的代價:它會增加理解的難度,並且給調試帶來一定的困難。所以,整體來說,除非必要,否則我會儘量避免使用它。這並不意味著控制反轉不好,只是我認為在很多時候使用一個更為直觀的方案(例如 Service Locator 模式)會比較合適。

一個關鍵的區別在於:使用 Service Locator 模式時,服務的使用者必須依賴於服務定位器。定位器可以隱藏使用者對服務具體實作的依賴,但你必須首先看到定位器本身。所以,問題的答案就很明朗了:選擇Service Locator 還是 Dependency Injection,取決於「對定位器的依賴」是否會給你帶來麻煩。

Dependency Injection 模式可以幫助你看清元件之間的依賴關係:你只需觀察依賴注入的機制(例如建構子),就可以掌握整個依賴關係。而使用 Service Locator 模式時,你就必須在源代碼中到處搜索對服務定位器的調用。具備全文檢索能力的 IDE 可以略微簡化這一工作,但還是不如直接觀察建構子或者設值方法來得輕鬆。

這個選擇主要取決於服務使用者的性質。如果你的應用程式中有很多不同的類要使用一個服務,那麼應用程式碼對服務定位器的依賴就不是什麼大問題。在前面的例子中, 我要把 MovieLister 類交給朋友去用,這種情況下使用服務定位器就很好:我的朋友們只需要對定位器做一點配置(通過設定檔或者某些配置性的代碼),使其提供合適的服務實作就可以了。

在這種情況下,我看不出 Dependency Injection 模式提供的控制反轉有什麼吸引人的地方。
但是,如果把 MovieLister 看作一個元件,要將它提供給別人寫的應用程式去使用,情況就不同了。在這種時候,我無法預測使用者會使用什麼樣的服務定位器 API,每個使用者都可能有自己的服務定位器,而且彼此之間無法相容。一種解決辦法是為每項服務提供單獨的介面,使用者可以編寫一個適配器,讓我的介面與他們的服務定位器相配合。但即便如此,我仍然需要到第一個服務定位器中尋找我規定的介面。而且一旦用上了適配器,服務定位器所提供的簡單性就被大大削弱了。

另一方面,如果使用 Dependency Injection 模式,元件與注入器之間不會有依賴關係,因此元件無法從注入器那裡獲得更多的服務,只能獲得配置資訊中所提供的那些。這也是 Dependency Injection 模式的局限性之一。

人們傾向於使用 Dependency Injection 模式的一個常見理由是:它簡化了測試工作。這裡的關鍵是:出於測試的需要,你必須能夠輕鬆地在「真實的服務實作」與「供測試用的『偽』組件」之間切換。但是,如果單從這個角度來考慮,Dependency Injection 模式和 Service Locator 模式其實並沒有太大區別:兩者都能夠很好地支援「偽」組件的插入。之所以很多人有「Dependency Injection 模式更利於測試」的印象,我猜是因為他們並沒有努力保證服務定位器的可替換性。這正是持續測試起作用的地方:如果你不能輕鬆地用一些「偽」元件將一個服務架起來以便測試,這就意味著你的設計出現了嚴重的問題。

當然,如果元件環境具有非常強的侵略性(就像 EJB 框架那樣),測試的問題會更加嚴重。我的觀點是:應該儘量減少這類框架對應用程式碼的影響,特別是不要做任何可能使「編輯-執行」的迴圈變慢的事情。用外掛程式(plugin)機制取代重量級組件會對測試過程有很大幫助,這正是測試驅動開發(Test Driven Development,TDD)之類實踐的關鍵所在。
所以,主要的問題在於:代碼的作者是否希望自己編寫的元件能夠脫離自己的控制、被使用在另一個應用程式中。如果答案是肯定的,那麼他就不能對服務定位器做任何假設——哪怕最小的假設也會給使用者帶來麻煩。

建構子注入 vs. 設值方法注入

在組合服務時,你總得遵循一定的約定,才可能將所有東西拼裝起來。依賴注入的優點主要在於:它只需要非常簡單的約定 —— 至少對於建構子注入和設值方法注入來說是這樣。
相比於這兩者,介面注入的侵略性要強得多,比起 Service Locator 模式的優勢也不那麼明顯。所以,如果你想要提供一個元件給多個使用者,建構子注入和設值方法注入看起來很有吸引力:你不必在元件中加入什麼希奇古怪的東西,注入器可以相當輕鬆地把所有東西配置起來。

設值函數注入和建構子注入之間的選擇相當有趣,因為它反映出物件導向程式設計的一些更普遍的問題:應該在哪裡填充物件的欄位,建構子還是設值方法?

一直以來,我首選的做法是儘量在構造階段就創建完整、合法的物件——也就是說,在建構子中填充對象欄位。這樣做的好處可以追溯到 Kent Beck 在「Smalltalk Best Practice Patterns」一書中介紹的兩個模式:Constructor Method 和 Constructor Parameter Method 。帶有參數的建構子可以明確地告訴你如何創建一個合法的物件。如果創建合法物件的方式不止一種,你還可以提供多個建構子,以說明不同的組合方式。

建構子初始化的另一個好處是:你可以隱藏任何不可變的欄位——只要不為它提供設值方法就行了。我認為這很重要:如果某個欄位是不應該被改變的,「沒有針對該欄位的設值方法」就很清楚地說明了這一點。如果你通過設值方法完成初始化,暴露出來的設值方法很可能成為你心頭永遠的痛。(實際上,在這種時候我更願意回避通常的設值方法約定,而使用諸如 initFoo 之類的方法名,以表明該方法只應該在物件創建之初調用。)

不過,世事總有例外。如果參數太多,建構子會顯得淩亂不堪,特別是對於不支持關鍵字參數的語言更是如此。的確,如果建構子參數列表太長,通常標誌著物件太過繁忙,理應將其拆分成幾個物件,但有些時候也確實需要那麼多的參數。

如果有不止一種的方式可以構造一個合法的物件,也很難通過建構子描述這一資訊,因為建構子之間只能通過參數的個數和類型加以區分。這就是 Factory Method 模式適用的場合了,工廠方法可以借助多個私有建構子和設值方法的組合來完成自己的任務。經典 Factory Method 模式的問題在於:它們往往以靜態方法的形式出現,你無法在介面中聲明它們。你可以創建一個工廠類,但那又變成另一個服務實體了。「工廠服務」是一種不錯的技巧,但你仍然需要以某種方式產生實體這個工廠物件,問題仍然沒有解決。

如果要傳入的參數是像字串這樣的簡單類型,建構子注入也會帶來一些麻煩。使用設值方法注入時,你可以在每個設值方法的名字中說明參數的用途;而使用建構子注入時,你只能靠參數的位置來決定每個參數的作用,而記住參數的正確位置顯然要困難得多。

如果物件有多個建構子,物件之間又存在繼承關係,事情就會變得特別討厭。為了讓所有東西都正確地初始化,你必須將對子類建構子的調用轉發給超類的建構子,然後處理自己的參數。這可能造成建構子規模的進一步膨脹。

儘管有這些缺陷,但我仍然建議你首先考慮建構子注入。不過,一旦前面提到的問題真的成了問題,你就應該準備轉為使用設值方法注入。

在將 Dependecy Injection 模式作為框架的核心部分的幾支團隊之間,「建構子注入還是設值方法注入」引發了很多的爭論。不過,現在看來,開發這些框架的大多數人都已經意識到:不管更喜歡哪種注入機制,同時為兩者提供支援都是有必要的。

代碼配置 vs. 設定檔

另一個問題相對獨立,但也經常與其他問題牽涉在一起:如何配置服務的組裝,通過設定檔還是直接編碼組裝?對於大多數需要在多處部署的應用程式來說,一個單獨的設定檔會更合適。設定檔幾乎都是 XML 檔,XML 也的確很適合這一用途。不過,有些時候直接在程式碼中實作裝配會更簡單。譬如一個簡單的應用程式,也沒有很多部署上的變化,這時用幾句代碼來配置就比 XML 文件要清晰得多。

與之相對的,有時應用程式的組裝非常複雜,涉及大量的條件步驟。一旦程式設計語言中的配置邏輯開始變得複雜,你就應該用一種合適的語言來描述配置資訊,使程式邏輯變得更清晰。然後,你可以編寫一個構造器(builder)類來完成裝配工作。如果使用構造器的情景不止一種,你可以提供多個構造器類,然後通過一個簡單的設定檔在它們之間選擇。

我常常發現,人們太急於定義設定檔。程式設計語言通常會提供簡捷而強大的配置管理機制,現代程式設計語言也可以將程式編譯成小的模組,並將其插入大型系統中。如果編譯過程會很費力,腳本語言也可以在這方面提供幫助。

通常認為,設定檔不應該用程式設計語言來編寫,因為它們需要能夠被不懂程式設計的系統管理人員編輯。但是,這種情況出現的幾率有多大呢?我們真的希望不懂程式設計的系統管理人員來改變一個複雜的伺服器端應用程式的事務隔離等級嗎?只有在非常簡單的時候,非程式設計語言的設定檔才有最好的效果。如果配置資訊開始變得複雜,就應該考慮選擇一種合適的程式設計語言來編寫設定檔。

在 Java 世界裡,我們聽到了來自設定檔的不和諧音——每個元件都有它自己的設定檔,而且格式還各各不同。如果你要使用一打這樣的元件,你就得維護一打的設定檔,那會很快讓你煩死。

在這裡,我的建議是:始終提供一種標準的配置方式,使程式師能夠通過同一個程式設計介面輕鬆地完成配置工作。至於其他的設定檔,僅僅把它們當作一種可選的功能。借助這個程式設計介面,開發者可以輕鬆地管理設定檔。如果你編寫了一個元件,則可以由元件的使用者來選擇如何管理配置資訊:使用你的程式設計介面、直接操作設定檔格式,或者定義他們自己的設定檔格式,並將其與你的程式設計介面相結合。

分離配置與使用

所有這一切的關鍵在於:服務的配置應該與使用分開。實際上,這是一個基本的設計原則——分離介面與實作。在物件導向程式裡,我們在一個地方用條件邏輯來決定具體產生實體哪一個類,以後的條件分支都由多型 (polymorphism)來實作,而不是繼續重複前面的條件邏輯,這就是「分離介面與實作」的原則。

如果對於一段代碼而言,介面與實作的分離還只是「有用」的話,那麼當你需要使用外部元素(例如元件和服務)時,它就是生死攸關的大事。這裡的第一個問題是:你是否希望將「選擇具體實作類別」的決策推遲到部署階段。如果是,那麼你需要使用插入技術。使用了插入技術之後,外掛程式的裝配原則上是與應用程式的其餘部分分開的,這樣你就可以輕鬆地針對不同的部署替換不同的配置。這種配置機制可以通過服務定位器來實作(Service Locator 模式),也可以借助依賴注入直接完成(Dependency Injection 模式)。

更多的問題

在本文中,我關注的焦點是使用 Dependency Injection 模式和 Service Locator 模式進行服務配置的基本問題。還有一些與之相關的話題值得關注,但我已經沒有時間繼續申發下去了。特別值得注意的是生命週期行為的問題:某些元件具有特定的生命週期事件,例如「停止」、「開始」等等。另一個值得注意的問題是:越來越多的人對「如何在這些容器中運用面向方面(aspect oriented)的思想」產生了興趣。儘管目前還沒有認真準備過這方面的材料,但我也很希望以後能在這個話題上寫一些東西。

關於這些問題,你在專注於羽量級容器的網站上可以找到很多資料。流覽 PicoContainer 或者 Spring 的網站,你可以找到大量相關的討論,並由此引申出更多的話題。

結論和思考

在時下流行的羽量級容器都使用了一個共同的模式來組裝應用程式所需的服務,我把這個模式稱為 Dependency Injection,它可以有效地替代 Service Locator 模式。在開發應用程式時,兩者不相上下,但我認為Service Locator 模式略有優勢,因為它的行為方式更為直觀。但是,如果你開發的元件要交給多個應用程式去使用,那麼 Dependency Injection 模式會是更好的選擇。

如果你決定使用 Dependency Injection 模式,這裡還有幾種不同的風格可供選擇。我建議你首先考慮建構子注入;如果遇到了某些特定的問題,再改用設值方法注入。如果你要選擇一個容器,在其之上進行開發,我建議你選擇同時支援這兩種注入方式的容器。

Service Locator 模式和 Dependency Injection 模式之間的選擇並不是最重要的,更重要的是:應該將服務的配置和應用程式內部對服務的使用分離開。

致謝

在此,我要向幫助我理解本文中所提到的問題、並對本文提出寶貴意見的幾個人表示感謝,他們是 Rod Johnson、Paul Hammant、Joe Walnes、Aslak Hellesoy、Jon Tirsen 和 Bill Caputo。另外,Berin Loritsch 和 Hamilton Verissimo de Oliveira 在 Avalon 方面給了我非常有用的建議,一併向他們表示感謝。

參考資料