談談依賴注入與面向接口編程思想
依賴注入(Dependency Injection)
今天我們討論的內容核心是面向接口編程,我決定還是要從依賴注入開始講起,因為DI的思想可以說是面向接口編程思想的特殊表現,也可以說是與面向接口編程相輔相成。先撇開讓人頭腦發暈的文字定義,我們還是用我們最忠實和伙伴——代碼來了解依賴注入。我們先來一個粗略的例子,由淺入深: 我們有一個公交車類(Bus),每天早上6點鐘需要發車(work),為其分配對應的司機(Driver),看代碼
@implementation Bus
- (void)work {
Driver *driver = [[Driver alloc] initWithName:@"張三"];
//dosomething
}
@end
</code></pre>
在上面這段代碼中,Bus對象的運作需要用到Driver對象,因而創建了一個Driver對象,我們稱Bus對Driver有一個依賴。這樣的強耦合關系會因為日后的變化而給我們帶來很多麻煩,不久將來張三師傅辭職了,我們需要修改Bus-work()的代碼,也就是說在開發過程中非常不便于單元測試(一是不能方便地更換各種Driver對象,二是如果Driver這個職位創建是耗時操作或者高成本操作,我們并不能使用準備好的Driver實現快速重復測試)。 我們繼續:
@implementation Bus
@property (strong, nonatomic) Driver *driver;
- (instancetype)initWithDriver:(Driver *)driver {
self = [super init];
if (self) {
self.dirver = driver;
}
return self;
}
- (void)work {
//dosomething
}
@end
</code></pre>
以上這段代碼我們通過init方法,為Bus對象傳入了一個Driver對象,像這種非自己主動初始化依賴,而從外部通過注入點注入依賴的方式,我們就稱為依賴注入,而例子中的這種注入的方法稱之為構造器注入。明顯的,這個場景中Bus和Driver的耦合因此輕了一層。說到解耦,并不是說Bus和Driver之間的依賴關系就不存在了,在Bus的范圍內看來,只是將依賴建立從編譯期間推遲到了運行期間,畢竟Bus無論如何也是需要Driver提供服務的。對此, 這篇文章 有一個非常形象的比喻,“依賴就像是系統中的plugin,主系統并不強依賴于任何一個插件,但一旦插件被加載,主系統就應該可以準確調用適當插件的功能”。
類似這樣的注入方式還有
- 屬性注入
- 方法注入
- 環境上下文注入
- 子類重寫方法注入等
不同的只是注入的手段,思想還是一樣的。
輕輕地思考
例子說完了,那是不是說我們對所有的依賴都要這樣一視同仁,破壞程序的封裝性而減輕所有的依賴呢?不,這僅僅是讓我們認識依賴注入的思想。但是對于測試驅動開發(TDD),一定量的依賴抽取又是必須的。如果說實在不希望把那么多的拉環暴露出來,又必須貫徹測試驅動開發,objc的 這篇文章 這么說到:
"This can be done by declaring them in a class category in a separate header file. For example, if we’re dealing with Example.h, then create an additional header ExampleInternal.h. This will be imported only by Example.m and by test code."
我們可以通過強大的Category,將注入的針口放在Category中,而對應的Category放在一個專門用來測試的header中,思考下這個Category中做了什么?swizzle掉依賴所在的方法,并且執行依賴注入,當然這兩者是分開的。
看到這里,是不是有點覺得DI完全就是為了單測服務?我以前也是這么認為的,其實不然,這僅僅是一個簡單介紹DI思想的一個例子,層次不同,我們不能從中體驗到DI帶來的好處。
組件化
也是objc的 那篇文章 中提到一種叫做 “pluggable排插思想” ,用原話來說,如果一個類的initializer需要提供一個 id<foo> 的參數,說明我們需要為之提供一個遵守foo協議的對象才可以讓這個類運作起來,有沒有發現DI外衣下的 面向接口思想 的肉體?所以說更深層的,DI的一個目標是為了實現組件化架構,DI讓依賴更加明顯,DI劃定了組件的邊界和組件的組裝方式。
開閉原則(Open-Closed Principle)
這里要帶入一個比較重要的思想——OCP,國內比較少筆墨對OCP思想的介紹和強調,他的原文解釋是Software entities should be open for extension,but closed for modification,對擴展開放,對修改關閉。也就是說我們對模塊的設計,應該滿足將來在不可修改源代碼的情況下對模塊的職能擴展,或者改變模塊的行為。單單這句話就能表現出OCP可怕的地位,他迫使我們主動考慮了將來,使應用保證了核心代碼的穩定性和對新需求的靈活性。
依賴獲取(Dependency Locate)
上面我們理解了依賴注入的基礎思想,讓依賴顯式化,為依賴提供合適的注入點(針口),提升程序的靈活性。帶來的結果就是當我們需要更換依賴的時候不需要對使用服務的類(姑且叫作客戶類)作代碼修改,將提供服務的類(服務類)由注入點注入到客戶類中,耦合的確輕了一層,也符合OCP原則,ok現在我們往外跳一層,在實例化客戶類的角色上下文中,需要實例化服務類進而完成對客戶類注入,服務類的更變必然導致此處代碼的修改,這時OCP又要站出來打差評。
此時有必要講下 依賴獲取 。既然有注入,當然也應該有獲取,但這兩者并不是先后執行的兩個過程,而是相同目的的同一種操作,換句話說,我們讓客戶類由被動注入轉換成主動獲取,繼續貫徹的仍然是依賴注入思想。
DL就是在系統中配置一個獲取點,客戶類依賴于服務類的接口而不直接依賴服務類,客戶類根據自身需要從獲取點主動獲取服務類為其提供服務。理解了DI,對DL的概念肯定是迎刃而解。
我們思考下,客戶類只知道獲取點,按照道上的規矩交貨的對方的身份完全不需要去了解,有沒有發現面向接口(POP)的內體又暴露了一點?
更高級的依賴注入
認識完DI的另一種方式依賴獲取后,做依賴注入的辦法就不僅僅局限于上文列舉的幾種最基本的依賴注入方式。目前比較主流的有 配置文件依賴注入 , 反射依賴注入 ,例如java中強大的 Spring 和移植到.NET平臺的 Spring.NET ,.NET中自己的 Autofac ,他們是結合配置文件和反射工作的,而oc中的 objection 我看了下是通過key-Value內存容器來做的DI,如果我自己做的話,還可以使用runtime target-action方式(類似于其他語言的反射),而重型項目中需不需要用到NSInvocation筆者缺乏這方面的經驗不敢獨斷。
下面還是用一個簡單的例子來增強對通過配置文件做依賴獲取的認識:
最近有看qq瀏覽器莊延軍老師關于內存管理的公開課,就用手Q瀏覽器更換主題打一個例子吧:
//定義一個主題接口,讓所有主題都實現它
@protocol ItfThemeFactory <NSObject>
- (void)drawing;
@end
//主題
@implementation SpringFactory <ItfThemeFactory>
- (void)drawing {
//drawing theme...
}
@end
@implementation SummerFactory <ItfThemeFactory>
- (void)drawing {
//drawing theme...
}
@end
//主題工廠Animator
@interface ThemeFactoryAnimator : NSObject
@property (strong, nonatomic) id<ItfThemeFactory> themeFactory;
@end
@implementation ThemeFactoryAnimator
- (id<ItfTheme>)themeFactory {
NSString path = [[NSBundle mainBundle] pathForResource:@"theme" ofType:@"plist"];
NSDictionary dict = [[NSDictionary alloc] initWithContentsOfFile:path];
NSString *theme = [dict objectForKey:@"theme"];
if ([theme isEqualToString:@"spring"]) {
_themeFactory = [[SpringFactory alloc] init];
} else if ([theme isEqualToString:@"summer"]) {
_themeFactory = [[SummerFactory alloc] init];
} else {
//assert
}
}
@end
//在執行方法里我們要做什么?
- (void)work {
ThemeFactoryAnimator *tfAnimator = [ThemeFactoryAnimator alloc] init];
id<ItfThemeFactory> themeFactory = tfAnimator.themeFactory;
[themeFactory drawing];
}
</code></pre>
以上,我們只需要在執行方法(-work())中拿到themeFactory,對界面進行渲染即可,而原本有可能出現依賴的地方——ThemeFactoryAnimator已經不依賴于外部注入,而僅僅依賴于我的theme.plist配置文件,也可以說我們將多態封裝到了這個“獲取點”內,因此主題的改變映射到了配置文件中對應內容的改變,但是這個更換主題系統目前就利用DI變得符合OCP原則了嗎?不是的,雖然依賴的改變已經映射到了客戶類封裝的外部——配置文件中,可是我們還是無法避免if-else結構的存在,我們可以不修改代碼自由更換主題,可是如果又開發出了一套新的主題呢?這個系統對于未來還是無能為力,這一part的重點是依賴獲取,至于怎么消除這種缺陷?看完這篇文章也許你就自然明白了。
面向接口編程(Protocol-oriented programming)
我們是時候談談面向接口了,如果對筆者上面說的還沒能很好理解沒關系,思想的認識需要時間去沉淀、矯正,出來的才是真理。首先我們怎樣定義接口:“接口泛指實體把自己提供給外界的一種抽象化物,用以由內部操作分離出外部溝通方法,使其能被修改內部而不影響外界其他實體與其交互的方式”,換句話說,在我們程序的世界里,接口的作用就是用于定義一個或一組規則,實現對應接口的實體需要遵守對應的這些規則。也可以說是對“同類事物”的抽象表示,而“同類事物”的界定就看是否實現了同一個接口,譬如有一個Animal接口和一個NightWorking接口,公雞實現了Animal接口,貓頭鷹實現了Animal接口和NightWorking接口,還有一個實現了NightWorking接口的路燈,在Animal的范疇下,我們可以稱公雞和貓頭鷹是同類事物,而在NightWorking的范疇下,我們可以稱貓頭鷹和路燈是同類事物。。。。相對的東西真恐怖,不知道筆者什么時候會跟什么東西被劃分為同類。。。
面向接口編程(編碼)
面向接口比較抽象,也比較廣泛,它不僅僅是指一個定性的東西,我們可以從POP為程序帶來的一個一個優越性為切入點研究,下面繼續是一個簡單的例子,讓我們來感受下POP思想的初衷:這次還是拿交通工具來說,
//首先我們定義一個交通工具接口
@protocol Transportation <NSObject>
- (void)drinking;
- (void)freight;
@end
//還有一個發光體接口
@protocol Irradiative <NSObject>
- (void)shine;
@end
//當然drinking就代表補需,汽車飛機的內部實現就是加油,馬牛的內部實現就是吃草喝水什么的。freight就是裝載
//當上帝創造馬的時候,讓馬遵守并實現這個接口:
@implementation Horse <Transportation>
(void)drinking {
//吃草,喝果汁
}
(void)freight {
//停住腳步,或者半蹲,讓友好的人類騎上去
}
@end
//當人類創造飛機的時候,慘了,不知道去哪里找上帝溝通,又怕疏漏了什么影響了這個世界的運行規律?沒事上帝給我們留下了Transportation接口,而且飛機同時還要遵守發光體接口Irradiative,于是:
@implementation Aircraft <Transportation, Irradiative>
(void)drinking {
//加油, 92的
}
(void)freight {
//降落,熄火,開艙門
}
(void)shine {
//燃燒汽油,生物質能轉化成電能,照你
}
@end
</code></pre>
當然物理學上能通過轉化其他物質發出可見光的也不一定叫發光體,已經畢業了就容我不按規矩來吧。以上,因為我們按規矩辦事,制造出來的飛機從來沒有自爆過。而馬匹也重來不需要死機重啟。感覺很有道理!如果不久將來我們著手創造時空穿梭機,我們第一步工作,就是要讓其遵守實現Transportation接口等,如果我們要求這個穿梭機還能幫我們敲代碼,我們繼續讓其遵守objcAble接口。
面向接口編程(架構)
不知不覺文章篇幅已經比較大了,讓我們來再往上爬一層,讓POP應用于更大的一個領域,甚至改變架構,雖然上一part已經算是一種架構思想,但是筆者更希望表現的是他在編碼應用中的優越性,而這一part將賦予POP在大型項目中不可撼動的地位。
無論是哪種架構方式,層次關系肯定是撇不開的,并且層次關系也代表著一種架構的主心骨,無論業務分層,功能分層,還是角色分層,存在于各個位置的依賴關系都需要我們去正視,而POP的目的正是為了化解這些強依賴,打破上層實例化下層去為其提供服務的強耦合,在大型項目中,一層的變化可能會聯動1+N層,這樣的變化是致命的,正如上文我們提到過的,讓一個實體由依賴另一個實體,轉變成依賴一個接口,將被依賴實體的變化隔絕于接口之外。
補充一句,這里的接口指代的并不是上一part中實體化的"接口",而是相對意義上的接口,一種思想!
iOS實際生產中的接口編程
不知道大家看了“面向接口編程(編碼)”后,有沒有發現日常OC編碼中似乎隨處可見接口編程的痕跡?——侵蝕了我們項目各個模塊的代理模式,代理模式的工作原理就是,一方使用protocol(接口)劃定一個或一組規則,成為其代理的角色必須遵守這一系列規則,最后根據規則去辦事,好處依然是那么明顯,主體并不需要與代理溝通,代理也不需要做多余的培訓,直接上崗,從這里又強化了一遍接口即一種由內部操作分離出外部溝通方法,而核心就是一系列規則,通過接口工作,比直接訪問屬性或者方法穩健得多。
而這一part中我們的主題并不是這個,為了思想上的升華,這里給出一個簡單例子,這里例子參考 龐海礁師兄文章例子 變換而來,講到那種相對意義上的接口思想。
#pragma mark - 面向對象傳統的方式:
//服務實現者 甲方 ,編寫一個服務類
@interface MusicLoadingProtocolObj()
@end
@implementation MusicLoadingProtocolObj
- (void)requestWithUrl:(NSURL )url Param:(NSDictionary )param {
//do something
}
@end
//服務使用者 乙方 ,通過接口獲取服務類
import "MusicLoadingProtocolObj.h"
@interface Client()
@end
@implementation Client
- (void)work {
MusicLoadingProtocolObj *musicLoadingProtocolObj = [MusicLoadingProtocolObj alloc] init];
[musicLoadingProtocolObj requestWithUrl:url Param:param];
}
@end
//當然,在這里我們已經應用了構造器注入的DI思想。或者我們如果使用屬性注入?那么當然就沒那么直觀,沒有貫徹接口編程的思想。
pragma mark - 接下來就是面向接口(POP)的做法:
//首先,定義一個ServiceProtocol
@protocol MusicLoadingProtocol <NSObject>
- (void)requestWithUrl:(NSURL )url Param:(NSDictionary )param;
@end
//甲方
@interface MusicLoadingProtocolObj() <MusicLoadingProtocol>
@end
@implementation MusicLoadingProtocolObj
- (void)requestWithUrl:(NSURL )url Param:(NSDictionary )param {
//do something
}
@end
//乙方
@interface Client()
@end
@implementation Client
- (void)work {
id<MusicLoadingProtocol> service = [[JSObjection defaultInjector] getObject:@protocol(MusicLoadingProtocol)];
[service requestWithUrl:url Param:param];
}
@end
</code></pre>
上例中筆者借助了OC的一個輕量級的DI框架objection,服務實現者甲方獨立編寫服務實現,而后將服務通過objection綁定到protocol之上,去看看服務使用者,乙方利用objection通過protocol拿到服務類實例,根據protocol中定義的規則,馬上就實現了服務。不需要import,不需要實例化,高度解耦,并且符合OCP原則。objection的原理就是上文提到的key-value內存映射表,對于大型項目,多小組分項目開發再合并的生產線,POP是必不可少的。
如果說我們在輕型開發中不想使用框架,我們也可以談談自己實現POP+DI,利用起OC的利器——runtime。其實在上例已經埋下伏筆,這次我們的乙方可以這樣做:
//乙方
@interface Client()
@end
@implementation Client
- (void)work {
NSString *clazzName = @"MusicLoadingProtocol";
[clazzName stringByAppendingString:@"Obj"];
Class serviceClazz = NSClassFromString(@"clazzName");
id<MusicLoadingProtocol> service = [serviceClazz alloc] init];
[service requestWithUrl:url Param:param];
}
@end
</code></pre>
就是這樣,甲乙雙發約定了以接口名+Obj字符串的規則去定義服務類,乙方做DL時只需要配合runtime,也是輕而易舉。
那么如果服務類實例化需要參數呢?
配置文件能解決這個問題,上文有提到Spring框架做DI的原理就是 反射+xml ,一般來說大部分支持反射機制語言的DI框架原理都是相似的,這里說下筆者了解的兩種主流注入原理,構造器注入和屬性注入,記得上文也提到過著兩種注入方式,筆者強調過那只是一種思想,不是定性的一種方法,ok來看下那些DI框架是怎樣做的。
- 構造器注入
在進行依賴獲取的時候,DI框架通過反射機制得到待創建類的構造方法,然后根據構造器所需參數的類型或者順序,在DI容器節點中尋找,然后提供參數,創建實例。
- 屬性注入
同樣的,在進行DL時,通過反射得到待創建類型的所有屬性,然后根據屬性在DI容器節點中進行匹配,有則創建提供,無則跳過。
最后利用詞條做個局部總結:
- 依賴注入+接口編程
- 調用者無須關心對象任何實現,只需按照接口規則調用服務
- 在系統分析和架構中,分清層次和依賴關系,每個層次不是直接向其上層提供服務(即不是直接實例化在上層中),而是通過定義一組接口,僅向上層暴露其接口功能,上層對于下層僅僅是接口依賴,而不依賴具體類。
- 服務使用端由對對象的依賴轉變成對接口的依賴,這樣甚至可以在服務提供對象還未存在之前編碼(分子項目開發)
End
依賴注入只是一種思想,其實也就是一個過程,依賴注入用到了面向接口的編程思想,面向接口的架構實現用到了依賴注入的執行方式。而面向接口編程和面向對象編程并不是平級的,它并不是比面向對象編程更先進的一種獨立的編程思想,而是附屬于面向對象思想體系,屬于其中一部分。或者說,它是面向對象編程體系中的思想精髓之一。
同時我要贊嘆POP的強大,對于未來的未知事物,我們先認知這個東西的行為(使用接口來實現這個行為),再認知這種行為的具體(使用具體的代碼實現這個接口)。
這篇文章中有提到廣義的"接口"也有專指的"接口",讀后具體的理解和認識就靠自己用時間去慢慢沉淀了。
寫在最后
在我對依賴注入理解得比較淺的時候,只是淺層地理解這種思想的存在,并沒有相關開發經驗足以支撐我深入對DI的思考和感受,網上的文章全部都僅僅局限在那幾個淺層的例子,并沒有繼續深入挖掘解釋,全靠一位師兄為我講解,所以我希望有篇文章可以聚集大家思考討論,同時為他人提供學習的途徑。心中有疑惑又無能為力的感覺的確非常痛苦。謝謝!
參考資料
http://www.olinone.com/?p=429 https://www.objc.io/issues/15-testing/dependency-injection/ http://sharpfivesoftware.com/2013/03/20/dependency-injection-is-not-a-virtue-in-objective-c/ http://objection-framework.org/ http://www.myexception.cn/operating-system/1894403.html http://www.cnblogs.com/devinzhang/p/3862942.html