當前位置:
首頁 > 最新 > 我看依賴注入

我看依賴注入

new代碼味道——狎昵(xia ni)關係:過分親近

這個主題是我比較想重點聊聊的,因為我個人的理解是依賴注入思想最終想解決的問題就是消除對象之間的耦合,再通俗一點講就是消除new代碼味道,解決的指導思想是將組件的配置和使用分離

什麼是代碼味道?

如果某段代碼可能存在問題,就可以說有代碼味道。這裡使用「可能」是因為少量的代碼味道並不一定就是問題。

代碼味道還可能表明有技術債務存在,而技術債務的修復是有代價的。背負技術債務越久,債務修復就會越難。

代碼味道有許多分類。

思考一下為什麼除了一些特殊情況外,凡是出現new關鍵字的地方都是代碼味道?

一個示例,展示如何通過實例化對象來破壞代碼的自適應能力

這段代碼就比較接近業務代碼了,代碼中有下列一些問題,這些問題是由於兩個顯式調用new關鍵字的構造對象實例引起的。

AccoutController類永遠依賴SecurityService類以及UserRepository類的具體實現。

AccoutController類隱式依賴SecurityService類以及UserRepository類的所有依賴。

AccoutController類很難測試,因為無法使用偽實現(模擬對象或存根)來模擬和替代SecurityService類和UserRepository類。

SecurityService類的ChangePassword方法需要客戶端預選載入好User類的實例對象(變相的依賴)。

詳細剖析一下這幾個問題。

1.無法增強實現——違反了OCP開閉原則

當我們想改變SecurityService類的實現時,只有兩種選擇,要麼改動AccountController來直接引用新的實現,要麼給現有的SecurityService類添加新功能。我們會發現這兩種選擇都不好。第一種選擇違反了對修改關閉,對擴展開放的開閉原則;第二種可能會違反SRP單一職責原則。這樣的代碼無法增強實現,無異於一鎚子買賣。

2.依賴關係鏈——違反了DIP控制反轉原則

AccoutController類依賴SecurityService類,SecurityService類也會有自己的依賴關係。上面的示例代碼SecurityService類可能看起來沒有什麼依賴,但是實際上可能會是這樣:

3.缺乏可測試性——違反了代碼的可測試性

代碼的可測試性也非常重要,它需要代碼以一定的格式構建。如果不這樣做,測試將變得極其困難。我們寫過單元測試一定知道,單元測試第一步便是要對待測試對象進行依賴隔離,只有這樣我們的測試才是穩定的(排除了依賴對象的不穩定性)、可重複的。我們使用的隔離框架moq(其實是所有隔離框架)都是通過使用模擬實現來替代待測試對象的依賴對象工作的。示例代碼中依賴的對象在代碼編譯階段就已經被確定了,無法在代碼運行階段動態的替換依賴對象,所以也就不具備可測試性了。

對象構造的替代方法

怎樣做才可以同時改進AccountController和SecurityService這兩個類,或者其他任何不合適的對象構造調用呢?如何才能正確設計和實現這兩個類以避免上節所講述的任何問題呢?下面有一些互補的方式可供選擇。

1.針對介面編程

我們首先需要做的改動是將SecurityService類的實現隱藏在一個介面後。這樣AccountController類只會依賴SecurityService類的介面而不是它的具體實現。第一個代碼重構就是為SecurityService類提取一個介面。

為SecurityService類提取一個介面:

AccountController類現在依賴ISecurityService介面:

2.使用依賴注入

這個主題比較大,無法用很短的篇幅講完。並且後面我們會詳細的探討依賴注入,所以現在我只會從使用依賴注入的類的角度來講解一些基本的要點。

繼續我們的重構,重構後的構造函數代碼部分已經加粗顯示,重構動作的改動非常小,但是管理依賴的能力卻大不相同。AccountController類不再要求構造SecurityService類的實例,而是要求它的客戶端代碼提供一個ISecurityService介面的實現。

使用依賴注入從AccountController類中移除對SecurityService類的依賴:

本節我們主要討論了new代碼味道及其缺點,也通過重構代碼的方式引出了new代碼味道兩種互補的方式--針對介面編碼和使用依賴注入。之所以說是互補的方式,是因為針對介面編碼只能讓代碼部分解耦,還是沒有解決直接調用被依賴類的構造函數的問題;而使用依賴注入雖然解決了這個問題,但是使用依賴注入是依賴於針對介面編程的。可以說只有我們針對介面編碼,才有可能使用依賴注入解決掉new代碼味道

忘記是誰說的了,了解學習一件事物之前要先了解學習它的發展歷史。學習任何知識,很重要的一點是學習其中的思維方式,看待問題,解決問題的思維方式。所以我希望能通過一個很簡單的小遊戲力求形象的描述依賴注入的演變歷程,以及是什麼推進了依賴注入的演變歷程。希望大家看完之後都能有所收穫,也希望大家看完之後對於依賴注入有自己的理解。讓我們開始吧!

鴨貓大戰

好了,讓我們從最簡單的開始,希望我們能從簡單到複雜,慢慢理解從面向介面編程到依賴注入的思想:

我現在要設計一個鴨貓大戰的遊戲,採用標準的OO技術,首先設計一個鴨子的抽象類。

假設在遊戲中鴨子的吃東西、跑等行為都是相同的,唯一不同的是鴨子的外觀,所以Display方法設置為抽象的,具體的實現在子類中實現。

好了,現在鴨鴨大戰第一版已經上線了。現在產品想讓遊戲中的鴨子可以叫,最簡單的一種實現方式就是在抽象基類中增加一個Shout()方法,這樣所有的繼承鴨子類型都可以叫了。不過我們很快就會發現問題來了,這樣做的話所有的所有的鴨子都會叫了,這顯然是不符合邏輯的。那麼有人肯定會想到使用介面了,將Shout()方法提取到

介面中,然後讓會叫的鴨子類型實現介面就可以了。

上面的實現看起來很好,但是、但是、但是需求總是在變化的。

現在產品要求鴨子不僅要會叫,而且每種鴨子類型叫聲還要求不一樣,並且不同的鴨子類型叫聲還可能會一樣。那麼上面的這種實現當時的缺點就顯示出來了,代碼會在多個子類中重複,並且運行時不能修改(繼承體系的缺點,代碼在編譯時就已經確定,無法動態改變)等。

理解為什麼要「面向介面編程,而不要面向實現編程」

接下來我們可以把變化的地方提取出來,多種行為的實現用統一的介面實現。當我們想增加一種行為時,只需要繼承介面就可以了,對其它行為沒有任何影響。

現在某一種具體的鴨子類型實現就變成了:

這樣的設計的優點就在於可以在運行時動態的改變行為,而且在不影響其他類的情況下增加更改行為。所以這樣的設計是充滿彈性的。而對比前面的設計我們就會發現,之前的設計依賴於繼承抽象類和實現介面,這兩種設計都依賴於「實現」,對象的行為在編譯完成的那一刻就已經被決定了,無法改變。(組合優於繼承)

理解為什麼要「依賴抽象,而不要依賴具體類」

現在我們要開始鴨貓遊戲,首先我們創建一個鴨子對象才能開始遊戲,就像下面這樣。

問題又出現了,代碼中充斥著的大量的"new"代碼。當我們使用「new」的時候,就是在實例化具體類。當出現實體類的時候,代碼就會更缺乏「彈性」。越是缺乏彈性越是難於改造。在後面我們還會繼續討論「new代碼味道」。

簡單工廠

讓我們繼續回到遊戲。為了增加遊戲的交互性,你可以選擇鴨或貓中的任一角色開始遊戲。如果我們選擇了鴨子角色開始遊戲,那麼我們應該在固定的場景會遇到固定的貓。在波斯會遇到波斯貓,在中國遇到狸花貓,在歐洲遇到挪威森林貓。用簡單工廠不難實現。

生產貓的工廠:

使用工廠創建貓對象:

簡單工廠設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。這是設計模式里對於工廠模式的說明。

工廠模式確實在一定程度上解決了創建對象的難題,項目中不會再到處充斥了「new代碼味道」。但是有一個問題沒有解決,要實例化哪一個對象,是在運行時由一些條件決定。當一旦有變化或擴展時,就要打開這段代碼(工廠實現代碼)進行修改,這違反了「對修改關閉」的原則。還有就是這段代碼依賴特別緊密,並且是高層依賴底層(客戶端依賴具體類(工廠類)的實現),因為判斷創建哪種對象是在工廠類中實現的。幸運的是,我們還有「依賴倒置原則」和「抽象工廠模式」來拯救我們。

抽象工廠和依賴倒置原則

客戶端(高層組件)依賴於抽象Cat,各種貓咪(底層組件)也依賴於抽象Cat,雖然我們已經創建了一個抽象Cat,但是仍然在代碼中創建了具體的Cat,這個抽象其實並沒有什麼影響力。使用抽象工廠模式可以將這些實例化對象的代碼隔離出來。這符合軟體設計中的對於可以預見變化的部分,要使用介面進行隔離。讓我們繼續回到遊戲中,之前我們提到過,在固定的場景會遇到固定的遊戲角色,所以我們需要為不同遊戲場景創建場景對象。

首先我們要創建一個工廠介面:

然後創建創建場景的工廠類:

構建場景類(偽代碼):

這樣一來,遊戲場景改變不由代碼來改變,而是由客戶端動態的決定。相當於變相的減少了高層對底層的依賴。現在其實我們已經能大體理解依賴倒置的原則:依賴抽象,而不依賴具體類。客戶端代碼實現:

現在的代碼設計已經比較靠近「注入」的概念了(窮人的依賴注入),仔細看 ,創建場景的工廠對象是抽象的介面類型,而且是通過構造函數動態傳入的,通過這樣的改造就為我們使用依賴注入框架提供了可能性。當然在抽象工廠和依賴注入之間,還有一個問題值得我們去思考。這個問題就是「如何將組件的配置和使用分離」,答案也已經很明了了——依賴注入。

理解將組件的配置和使用分離

如果覺得組件這個比較抽象的話,我們可以把「組件」理解為「對象」(底層組件),那麼相應的「組件的配置」就理解成為「對象的初始化」。現在「將組件的配置和使用分離」這句話就很好理解了

,就是將對象的創建和使用分離。這樣做的優點很明顯,將對象的創建推遲到了部署階段(這句話可能不太好理解),就是說對象的創建全部依賴於我們統一的配置,我們可以修改配置動態的把我們不想使用的對象替換成我們想使用的對象,而不用修改任何使用對象的代碼。原則上我們需要把對象的裝配(配置)和業務代碼(使用)分離開來。

依賴注入

依賴注入(DI)是一個很簡單的概念,實現起來也很簡單。儘管如此,這種簡單性卻掩蓋了該模式的重要性。當某些事情很簡單也很重要時,人們就會將它過度複雜化,依賴注入也一樣。要理解依賴注入,我們首先要這個詞拆開來解讀——依賴和注入。

什麼是依賴?

要用文字解釋這個概念可能不太好理解(文不如表,表不如圖),我們可以使用有向圖對依賴建模。一個依賴關係包含了兩個實體,它們之間的聯繫方向是從依賴者到被依賴者

使用有向圖對依賴建模:

A依賴B:

B依賴A:

互聯網提供很多服務,服務依賴互聯網:

包(包括程序集和命名空間)既是客戶也是服務:

客戶端類依賴服務類:

有些服務會隱藏在介面後面:

有向圖中有一種特殊的循環叫做自循環

方法層的遞歸就是一個很好的自循環的例子。

軟體系統中的依賴

我們都知道,在採用面向對象設計的軟體系統中,萬物皆對象。所有的對象通過彼此的合作,完成整個系統的工作。就好比下面的齒輪系統,每個齒輪轉動帶動整個齒輪系統的運轉。但是這樣的設計就意味著強依賴,強耦合。如果某個齒輪出問題不轉動了,整個齒輪系統就會癱瘓掉,這顯然是我們所不能接受的。

圖1.軟體系統中耦合的對象:

什麼是控制反轉(IOC)?

耦合關係不僅會出現在對象與對象之間,也會出現在軟體系統的各模塊之間,以及軟體系統和硬體系統之間。如何降低系統之間、模塊之間和對象之間的耦合度,是軟體工程永遠追求的目標之一。為了解決對象之間的耦合度過高的問題,軟體專家Michael Mattson提出了IOC理論,用來實現對象之間的「解耦」。目前這個理論已經被成熟的應用到項目當中,衍生出了各式各樣的IOC框架產品。

IOC理論提出的觀點大致是這樣的:藉助於「第三方」實現具有依賴關係的對象之間的解耦。如下圖:

圖2.IOC解耦過程:

由於引進了中間位置的「第三方」,也就是IOC容器,使得A、B、C、D這4個對象沒有了耦合關係,齒輪之間的傳動全部依靠「第三方」了,全部對象的控制權全部上繳給「第三方」IOC容器,所以,IOC容器成了整個系統的關鍵核心,它起到了一種類似「粘合劑」的作用,把系統中的所有對象粘合在一起發揮作用,如果沒有這個「粘合劑」,對象與對象之間會彼此失去聯繫,這就是有人把IOC容器比喻成「粘合劑」的由來。

那麼如果我們把IOC容器拿掉,系統會是什麼樣子呢?

圖3.拿掉IOC容器的系統:

拿掉IOC容器的系統,A、B、C、D這4個對象之間已經沒有了耦合關係,彼此毫無聯繫,這樣的話,當你在實現A的時候,根本無須再去考慮B、C和D了,對象之間的依賴關係已經降低到了最低程度。

軟體系統在沒有引入IOC容器之前,如圖1所示,對象A依賴於對象B,那麼對象A在初始化或者運行到某一點的時候,自己必須主動去創建對象B或者使用已經創建的對象B。無論是創建還是使用對象B,控制權都在自己手上。軟體系統在引入IOC容器之後,這種情形就完全改變了,如圖3所示,由於IOC容器的加入,對象A與對象B之間失去了直接聯繫,所以,當對象A運行到需要對象B的時候,IOC容器會主動創建一個對象B注入到對象A需要的地方。通過前後的對比,我們不難看出來:對象A獲得依賴對象B的過程,由主動行為變為了被動行為,控制權顛倒過來了,這就是「控制反轉」這個名稱的由來。

什麼是依賴注入?

2004年,Martin Fowler探討了同一個問題,既然IOC是控制反轉,那麼到底是「哪些方面的控制被反轉了呢?」,經過詳細地分析和論證後,他得出了答案:「獲得依賴對象的過程被反轉了」。控制被反轉之後,獲得依賴對象的過程由自身管理變為了由IOC容器主動注入。於是,他給「控制反轉」取了一個更合適的名字叫做「依賴注入(Dependency Injection)」。他的這個答案,實際上給出了實現IOC的方法:注入。所謂依賴注入,就是由IOC容器在運行期間,動態地將某種依賴關係注入到對象之中

所以現在我們知道,控制反轉(IOC)和依賴注入(DI)是從不同角度對同一件事物的描述。就是通過引入IOC容器,利用注入依賴關係的方式,實現對象之間的解耦。

使用控制反轉(IOC)容器

我們在開發時經常會遇到這種情況,開發中的類委託某些抽象完成動作,而這些被委託的抽象又被其他的類實現,這些類又委託其他的一些抽象完成某種動作。最終,在依賴鏈終結的地方,都是一些小且直接的類,它們已經不需要任何依賴了。我們已經知道如何通過手動構造類實例並把它們傳遞給構造函數的方式來實現依賴注入的效果(窮人的依賴注入)。儘管這種方式可以任意替換依賴的實現,但是構造的實例對象圖依舊是靜態的,也就是說編譯時就已經確定了。控制反轉允許我們將構建對象圖的動作推遲到運行時。

控制反轉容器組成的系統能夠將應用程序使用的介面和它的實現類關聯起來,並能在獲取實例的的同時解析所有相關的依賴。

示例代碼中沒有手動構造實現的實例,而是通過使用Unity控制反轉容器來建立類和介面的映射關係:

1.代碼的第一步就是初始化得到一個UnityContainer實例。

2.在創建好Unity容器後,我們需要告訴該容器應用程序生命周期內每個介面對應的具體實現類是什麼。Unity遇到任何介面時,都會知道去解析哪個實現。如果我們沒有為某個介面指定對應的實現類,Unity會提醒我們該介面無法實例化。

3.在完成介面和對應實現類的關係註冊後,我們需要獲得一個TaskService類的實例。Unity容器的Resolve方法會檢查TaskService類的構造函數,然後嘗試去實例化構造函數要注入的依賴項。如此反覆,直到完全實例化整個依賴鏈上的所有依賴項的實例後,Resolve方法會成功實例化TaskService類的實例。

控制反轉(IOC)容器的工作模式——註冊、解析、釋放模式

所有的控制反轉容器都符合一個只有三個的方法的簡單介面,Unity也不例外。

儘管每個控制反轉容器實現不完全相同,但是都符合下面這個通用的介面:

Register:應用程序首先會調用此方法。而且該方法會被多次調用以註冊不同的介面及其實現之間的映射關係。這裡的Where子句用來強制TImplementation類型必須實現它所繼承的TInterface介面。

Resolve:應用程序運行時會調用此方法獲取對象實例。

Release:應用程序生命周期中,當某些類的的實例不再需要時,就可以調用此方法釋放它們佔用的資源。這有可能發生在應用程序結束時,也有可能發生在應用程序運行的某個恰當時機。

我們都知道在我們使用的Unity容器註冊時可以配置是否開啟單例模式。通常情況下,資源只對單次請求有效,每次請求後都會調用Release方法。但是當我們配置開啟單例模式時,只有在應用程序關閉時才會調用Release方法。

命令式與聲明式註冊

到此為止,我們都是使用的命令式註冊:命令式的從容器對象上調用方法。

命令式註冊優點:

比較簡潔,易讀。

編譯時檢查問題的代價非常小(比如防止代碼輸入錯誤等)。

命令式註冊缺點:

註冊的過程在編譯時已經確定了,如果想要替換實現,必須修改源代碼,然後重新編譯。

如果通過XML配置進行聲明式註冊,就不需要重新編譯。

應用程序配置文件:

應用程序入口:

聲明式註冊優點:

將介面和相應的實現的映射動作推遲到配置時。

聲明式註冊缺點:

太繁瑣,配置文件會巨大。

註冊時的錯誤會跳過編譯,直到運行時才能被發現和捕獲。

三種依賴注入方式及其優缺點

首先大家思考一下為什麼在項目中會要求大家在控制器層使用屬性注入,在業務邏輯層使用構造函數注入?

1.構造函數注入

優點:

在構造方法中體現出對其他類的依賴,一眼就能看出這個類需要其他那些類才能工作。

脫離了IOC框架,這個類仍然可以工作(窮人的依賴注入)。

一旦對象初始化成功了,這個對象的狀態肯定是正確的。

缺點:

構造函數會有很多參數。

有些類是需要默認構造函數的,比如MVC框架的Controller類,一旦使用構造函數注入,就無法使用默認構造函數。

2.屬性注入

在對象的整個生命周期內,可以隨時動態的改變依賴。

非常靈活。

缺點:

對象在創建後,被設置依賴對象之前這段時間狀態是不對的(從構造函數注入的依賴實例在類的整個生命周期內都可以使用,而從屬性注入的依賴實例還能從類生命周期的某個中間點開始起作用)。

不直觀,無法清晰地表示哪些屬性是必須的。

3.方法注入

優點:

比較靈活。

缺點:

新加入依賴時會破壞原有的方法簽名,如果這個方法已經被其他很多模塊用到就很麻煩。

與構造方法注入一樣,會有很多參數。

相信大家現在一定理解了項目中某一層指定某一種注入方式的原因:利用其優點,規避其缺點。

組合根和解析根

1.組合根

應用程序中只應該有一個地方直到依賴注入的細節,這個地方就是組合根。在使用窮人的依賴注入時就是我們手動構造類的地方,在使用控制反轉容器時就是我們註冊介面和實現類間映射關係的地方。組合根提供了一個查找依賴注入配置的公認位置,它能幫你避免把對容器的依賴擴散到應用程序的其他地方。

2.解析根

和組合根密切相關的一個概念是解析根。它是要解析的目標對象圖中根節點的對象類型。

這樣講很抽象,舉個例子:

MVC應用程序的解析根就是控制器。來自瀏覽器的請求都會被路由到被稱為動作(action)的控制器方法上。每當請求來臨時,MVC框架會將URL映射為某個控制器名稱,然後找到對應名稱的類實例化它,最後在該實例上觸發動作。更確切的講,實例化控制器的過程就是解析控制器的過程。這意味著,我們能輕易的按照註冊、解析和釋放的模式,最小化對Resolve方法的調用,理想狀況下,就只應該在一個地方調用該方法。

組合根和解析根又是前面所講的「將組件的配置和使用分離」一種體現。

依賴注入的技術點

IOC中最基本的技術就是「反射(Reflection)」編程。有關反射的相關概念大家應該都很清楚,通俗的講就是代碼運行階段,根據給出的信息動態的生成對象。

總結

做一下總結,我們從new代碼味道出發,引出了消除new代碼味道(代碼解耦)的兩種方式——針對介面編碼和使用依賴注入。然後我們通過開發一個小遊戲,了解了面向介面編程到依賴注入的歷程。最後深入了介紹了大Boss——控制反轉(依賴注入),主要介紹了什麼是依賴,控制反轉(依賴注入)的概念,使用控制反轉(IOC)容器,工作模式,命令式與聲明式註冊,三種依賴注入方式及其優缺點,組合根和解析根,依賴注入的技術點。

本次分享力求從原理和思想層面剖析依賴注入。因為我水平有限,可能有些點講的有些片面或不夠深入,所以給出我準備這次分享的參考資料。有興趣深入研究的同學,可以自行去看一下這些資料:

1.C#敏捷開發實踐

第2章 依賴和分層

第9章 依賴注入原則

2.HeadFirst設計模式

鴨貓大戰改編自第一章 設計模式入門


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 全球大搜羅 的精彩文章:

「百草園」,我來了
「北臉」並不只有一張臉

TAG:全球大搜羅 |