你確定懂?徹底搞懂 控制反轉(IoC Inversion of Control )與依賴注入(DI Dependency Inversion Principle )

架構師易筋發表於2020-11-28

說明

Spring框架,核心就是IoC容器。要掌握Spring框架,就必須要理解控制反轉的思想以及依賴注入的實現方式。下面,我們將圍繞下面幾個問題來探討控制反轉與依賴注入的關係以及在Spring中如何應用。

什麼是控制反轉?
什麼是依賴注入?
它們之間有什麼關係?
如何在Spring框架中應用依賴注入?

1. 控制反轉

在討論控制反轉之前,我們先來看看軟體系統中耦合的物件。
在這裡插入圖片描述圖1:軟體系統中耦合的物件

從圖中可以看到,軟體中的物件就像齒輪一樣,協同工作,但是互相耦合,一個零件不能正常工作,整個系統就崩潰了。這是一個強耦合的系統。齒輪組中齒輪之間的齧合關係,與軟體系統中物件之間的耦合關係非常相似。物件之間的耦合關係是無法避免的,也是必要的,這是協同工作的基礎。現在,伴隨著工業級應用的規模越來越龐大,物件之間的依賴關係也越來越複雜,經常會出現物件之間的多重依賴性關係,因此,架構師和設計師對於系統的分析和設計,將面臨更大的挑戰。物件之間耦合度過高的系統,必然會出現牽一髮而動全身的情形。

為了解決物件間耦合度過高的問題,軟體專家Michael Mattson提出了IoC理論,用來實現物件之間的“解耦”。

控制反轉(Inversion of Control)是一種是物件導向程式設計中的一種設計原則,用來減低計算機程式碼之間的耦合度。其基本思想是:藉助於“第三方”實現具有依賴關係的物件之間的解耦。

在這裡插入圖片描述
圖2:IoC解耦過程

由於引進了中間位置的“第三方”,也就是IOC容器,使得A、B、C、D這4個物件沒有了耦合關係,齒輪之間的傳動全部依靠“第三方”了,全部物件的控制權全部上繳給“第三方”IOC容器,所以,IOC容器成了整個系統的關鍵核心,它起到了一種類似“粘合劑”的作用,把系統中的所有物件粘合在一起發揮作用,如果沒有這個“粘合劑”,物件與物件之間會彼此失去聯絡,這就是有人把IOC容器比喻成“粘合劑”的由來。
我們再來看看,控制反轉(IOC)到底為什麼要起這麼個名字?我們來對比一下:

  1. 軟體系統在沒有引入IOC容器之前,如圖1所示,物件A依賴於物件B,那麼物件A在初始化或者執行到某一點的時候,自己必須主動去建立物件B或者使用已經建立的物件B。無論是建立還是使用物件B,控制權都在自己手上。

  2. 軟體系統在引入IOC容器之後,這種情形就完全改變了,如圖2所示,由於IOC容器的加入,物件A與物件B之間失去了直接聯絡,所以,當物件A執行到需要物件B的時候,IOC容器會主動建立一個物件B注入到物件A需要的地方。
    通過前後的對比,我們不難看出來:物件A獲得依賴物件B的過程,由主動行為變為了被動行為,控制權顛倒過來了,這就是“控制反轉”這個名稱的由來。

  3. 控制反轉不只是軟體工程的理論,在生活中我們也有用到這種思想。再舉一個現實生活的

例子:
海爾公司作為一個電器制商需要把自己的商品分銷到全國各地,但是發現,不同的分銷渠道有不同的玩法,於是派出了各種銷售代表玩不同的玩法,隨著渠道越來越多,發現,每增加一個渠道就要新增一批人和一個新的流程,嚴重耦合並依賴各渠道商的玩法。實在受不了了,於是制定業務標準,開發分銷資訊化系統,只有符合這個標準的渠道商才能成為海爾的分銷商。讓各個渠道商反過來依賴自己標準。反轉了控制,倒置了依賴。

我們把海爾和分銷商當作軟體物件,分銷資訊化系統當作IOC容器,可以發現,在沒有IOC容器之前,分銷商就像圖1中的齒輪一樣,增加一個齒輪就要增加多種依賴在其他齒輪上,勢必導致系統越來越複雜。開發分銷系統之後,所有分銷商只依賴分銷系統,就像圖2顯示那樣,可以很方便的增加和刪除齒輪上去。

2. 依賴注入

依賴注入就是將例項變數傳入到一個物件中去(Dependency injection means giving an object its instance variables)。

2.1 什麼是依賴

如果在 Class A 中,有 Class B 的例項,則稱 Class A 對 Class B 有一個依賴。例如下面類 Human 中用到一個 Father 物件,我們就說類 Human 對類 Father 有一個依賴。

public class Human {
    ...
    Father father;
    ...
    public Human() {
        father = new Father();
    }
}

仔細看這段程式碼我們會發現存在一些問題:

  1. 如果現在要改變 father 生成方式,如需要用new Father(String name)初始化 father,需要修改 Human 程式碼;
  2. 如果想測試不同 Father 物件對 Human 的影響很困難,因為 father 的初始化被寫死在了 Human 的建構函式中;
  3. 如果new Father()過程非常緩慢,單測時我們希望用已經初始化好的 father 物件 Mock 掉這個過程也很困難。

2.2 依賴注入

上面將依賴在建構函式中直接初始化是一種 Hard init 方式,弊端在於兩個類不夠獨立,不方便測試。我們還有另外一種 Init 方式,如下:

public class Human {
    ...
    Father father;
    ...
    public Human(Father father) {
        this.father = father;
    }
}

上面程式碼中,我們將 father 物件作為建構函式的一個引數傳入。在呼叫 Human 的構造方法之前外部就已經初始化好了 Father 物件。像這種非自己主動初始化依賴,而通過外部來傳入依賴的方式,我們就稱為依賴注入。
現在我們發現上面 1 中存在的兩個問題都很好解決了,簡單的說依賴注入主要有兩個好處:

  1. 解耦,將依賴之間解耦。
  2. 因為已經解耦,所以方便做單元測試,尤其是 Mock 測試。

2.3 控制反轉和依賴注入的關係

我們已經分別解釋了控制反轉和依賴注入的概念。有些人會把控制反轉和依賴注入等同,但實際上它們有著本質上的不同。

  • 控制反轉是一種思想
  • 依賴注入是一種設計模式

IoC框架使用依賴注入作為實現控制反轉的方式,但是控制反轉還有其他的實現方式,例如說ServiceLocator,所以不能將控制反轉和依賴注入等同。

2.4 Spring中的依賴注入

上面我們提到,依賴注入是實現控制反轉的一種方式。下面我們結合Spring的IoC容器,簡單描述一下這個過程。

class MovieLister...
    private MovieFinder finder;
    public void setFinder(MovieFinder finder) {
        this.finder = finder;
    }

class ColonMovieFinder...
    public void setFilename(String filename) {
        this.filename = filename;
    }

我們先定義兩個類,可以看到都使用了依賴注入的方式,通過外部傳入依賴,而不是自己建立依賴。那麼問題來了,誰把依賴傳給他們,也就是說誰負責建立finder,並且把finder傳給MovieLister。答案是Spring的IoC容器。

要使用IoC容器,首先要進行配置。這裡我們使用xml的配置,也可以通過程式碼註解方式配置。下面是spring.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>

在Spring中,每個bean代表一個物件的例項,預設是單例模式,即在程式的生命週期內,所有的物件都只有一個例項,進行重複使用。通過配置bean,IoC容器在啟動的時候會根據配置生成bean例項。具體的配置語法參考Spring文件。這裡只要知道IoC容器會根據配置建立MovieFinder,在執行的時候把MovieFinder賦值給MovieLister的finder屬性,完成依賴注入的過程。

下面給出測試程式碼

public void testWithSpring() throws Exception {
    ApplicationContext ctx = new FileSystemXmlApplicationContext("spring.xml");//1
    MovieLister lister = (MovieLister) ctx.getBean("MovieLister");//2
    Movie[] movies = lister.moviesDirectedBy("Sergio Leone");
    assertEquals("Once Upon a Time in the West", movies[0].getTitle());
}
  1. 根據配置生成ApplicationContext,即IoC容器。
  2. 從容器中獲取MovieLister的例項。

2.5 總結

  1. 控制反轉是一種在軟體工程中解耦合的思想,呼叫類只依賴介面,而不依賴具體的實現類,減少了耦合。控制權交給了容器,在執行的時候才由容器決定將具體的實現動態的“注入”到呼叫類的物件中。

  2. 依賴注入是一種設計模式,可以作為控制反轉的一種實現方式。依賴注入就是將例項變數傳入到一個物件中去(Dependency injection means giving an object its instance variables)。

  3. 通過IoC框架,類A依賴類B的強耦合關係可以在執行時通過容器建立,也就是說把建立B例項的工作移交給容器,類A只管使用就可以。

3 汽車與輪子的關係 理解IoC 和 DI

要了解控制反轉( Inversion of Control ), 我覺得有必要先了解軟體設計的一個重要思想:依賴倒置原則(Dependency Inversion Principle )。

3.1 什麼是依賴倒置原則?

假設我們設計一輛汽車:先設計輪子,然後根據輪子大小設計底盤,接著根據底盤設計車身,最後根據車身設計好整個汽車。這裡就出現了一個“依賴”關係:汽車依賴車身,車身依賴底盤,底盤依賴輪子。
在這裡插入圖片描述
這樣的設計看起來沒問題,但是可維護性卻很低。假設設計完工之後,上司卻突然說根據市場需求的變動,要我們把車子的輪子設計都改大一碼。這下我們就蛋疼了:因為我們是根據輪子的尺寸設計的底盤,輪子的尺寸一改,底盤的設計就得修改;同樣因為我們是根據底盤設計的車身,那麼車身也得改,同理汽車設計也得改——整個設計幾乎都得改!

我們現在換一種思路。我們先設計汽車的大概樣子,然後根據汽車的樣子來設計車身,根據車身來設計底盤,最後根據底盤來設計輪子。這時候,依賴關係就倒置過來了:輪子依賴底盤, 底盤依賴車身, 車身依賴汽車。

在這裡插入圖片描述
這時候,上司再說要改動輪子的設計,我們就只需要改動輪子的設計,而不需要動底盤,車身,汽車的設計了。

這就是依賴倒置原則——把原本的高層建築依賴底層建築“倒置”過來,變成底層建築依賴高層建築。高層建築決定需要什麼,底層去實現這樣的需求,但是高層並不用管底層是怎麼實現的。這樣就不會出現前面的“牽一髮動全身”的情況。

3.2 控制反轉 Inversion of Control

就是依賴倒置原則的一種程式碼設計的思路。具體採用的方法就是所謂的依賴注入(Dependency Injection)。其實這些概念初次接觸都會感到雲裡霧裡的。說穿了,這幾種概念的關係大概如下:
在這裡插入圖片描述
為了理解這幾個概念,我們還是用上面汽車的例子。只不過這次換成程式碼。我們先定義四個Class,車,車身,底盤,輪胎。然後初始化這輛車,最後跑這輛車。程式碼結構如下:
在這裡插入圖片描述
這樣,就相當於上面第一個例子,上層建築依賴下層建築——每一個類的建構函式都直接呼叫了底層程式碼的建構函式。假設我們需要改動一下輪胎(Tire)類,把它的尺寸變成動態的,而不是一直都是30。我們需要這樣改:

在這裡插入圖片描述
由於我們修改了輪胎的定義,為了讓整個程式正常執行,我們需要做以下改動:

在這裡插入圖片描述
由此我們可以看到,僅僅是為了修改輪胎的建構函式,這種設計卻需要修改整個上層所有類的建構函式!在軟體工程中,這樣的設計幾乎是不可維護的——在實際工程專案中,有的類可能會是幾千個類的底層,如果每次修改這個類,我們都要修改所有以它作為依賴的類,那軟體的維護成本就太高了。

所以我們需要進行控制反轉(IoC),及上層控制下層,而不是下層控制著上層。我們用依賴注入(Dependency Injection)這種方式來實現控制反轉。所謂依賴注入,就是把底層類作為引數傳入上層類,實現上層類對下層類的“控制”。這裡我們用構造方法傳遞的依賴注入方式重新寫車類的定義:

在這裡插入圖片描述
這裡我們再把輪胎尺寸變成動態的,同樣為了讓整個系統順利執行,我們需要做如下修改:
在這裡插入圖片描述
看到沒?這裡我只需要修改輪胎類就行了,不用修改其他任何上層類。這顯然是更容易維護的程式碼。不僅如此,在實際的工程中,這種設計模式還有利於不同組的協同合作和單元測試:比如開發這四個類的分別是四個不同的組,那麼只要定義好了介面,四個不同的組可以同時進行開發而不相互受限制;而對於單元測試,如果我們要寫Car類的單元測試,就只需要Mock一下Framework類傳入Car就行了,而不用把Framework, Bottom, Tire全部new一遍再來構造Car。

3.3 Setter傳遞和介面傳遞 的依賴注入

這裡我們是採用的建構函式傳入的方式進行的依賴注入。其實還有另外兩種方法:Setter傳遞和介面傳遞。這裡就不多講了,核心思路都是一樣的,都是為了實現控制反轉。
在這裡插入圖片描述
看到這裡你應該能理解什麼控制反轉和依賴注入了。

4 控制反轉容器(IoC Container)

那什麼是控制反轉容器(IoC Container)呢?其實上面的例子中,對車類進行初始化的那段程式碼發生的地方,就是控制反轉容器。
在這裡插入圖片描述
顯然你也應該觀察到了,因為採用了依賴注入,在初始化的過程中就不可避免的會寫大量的new。這裡IoC容器就解決了這個問題。這個容器可以自動對你的程式碼進行初始化,你只需要維護一個Configuration(可以是xml可以是一段程式碼),而不用每次初始化一輛車都要親手去寫那一大段初始化的程式碼。這是引入IoC Container的第一個好處。

IoC Container的第二個好處是:我們在建立例項的時候不需要了解其中的細節。在上面的例子中,我們自己手動建立一個車instance時候,是從底層往上層new的:
在這裡插入圖片描述
這個過程中,我們需要了解整個Car/Framework/Bottom/Tire類建構函式是怎麼定義的,才能一步一步new/注入。

而IoC Container在進行這個工作的時候是反過來的,它先從最上層開始往下找依賴關係,到達最底層之後再往上一步一步new(有點像深度優先遍歷):
在這裡插入圖片描述
這裡IoC Container可以直接隱藏具體的建立例項的細節,在我們來看它就像一個工廠:

在這裡插入圖片描述

我們就像是工廠的客戶。我們只需要向工廠請求一個Car例項,然後它就給我們按照Config建立了一個Car例項。我們完全不用管這個Car例項是怎麼一步一步被建立出來。

實際專案中,有的Service Class可能是十年前寫的,有幾百個類作為它的底層。假設我們新寫的一個API需要例項化這個Service,我們總不可能回頭去搞清楚這幾百個類的建構函式吧?IoC Container的這個特性就很完美的解決了這類問題——因為這個架構要求你在寫class的時候需要寫相應的Config檔案,所以你要初始化很久以前的Service類的時候,前人都已經寫好了Config檔案,你直接在需要用的地方注入這個Service就可以了。這大大增加了專案的可維護性且降低了開發難度。

5 解耦的思路

在這裡插入圖片描述
消費者X需要消費類Y來完成某項工作。那都是自然而然的,但是X真的需要知道它使用Y嗎?

知道X使用具有Y的行為,方法,屬性等的東西而又不知道是誰真正實現了行為,這還不夠嗎?

通過提取X在Y中使用的行為的抽象定義(如下圖I所示),並讓消費者X使用該例項代替Y,它可以繼續執行其操作而不必瞭解有關Y的細節。

在這裡插入圖片描述
在上面的圖示中,Y實現了I,而X使用了I的例項。儘管X仍然很可能仍使用Y,但有趣的是X並不知道這一點。它只知道它使用實現I的東西。

參考

https://www.zhihu.com/question/23277575

https://www.jianshu.com/p/07af9dbbbc4b

https://martinfowler.com/articles/injection.html

http://joelabrahamsson.com/inversion-of-control-an-introduction-with-examples-in-net/

相關文章