谈谈依赖注入与面向接口编程


依赖注入(Dependency Injection)

今天我们讨论的内容核心是面向接口编程,我决定还是要从依赖注入开始讲起,因为DI的思想可以说是面向接口编程思想的特殊表现,也可以说是与面向接口编程相辅相成。
先撇开让人头脑发晕的文字定义,我们还是用我们最忠实和伙伴——代码来了解依赖注入。我们先来一个粗略的例子,由浅入深:
我们有一个公交车类(Bus),每天早上6点钟需要发车(work),为其分配对应的司机(Driver),看代码

@implementation Bus

- (void)work {
    Driver *driver = [[Driver alloc] initWithName:@"张三"];
    //dosomething
}
@end

在上面这段代码中,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

以上这段代码我们通过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];
}

以上,我们只需要在执行方法(-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

当然物理学上能通过转化其他物质发出可见光的也不一定叫发光体,已经毕业了就容我不按规矩来吧。以上,因为我们按规矩办事,制造出来的飞机从来没有自爆过。而马匹也重来不需要死机重启。感觉很有道理!如果不久将来我们着手创造时空穿梭机,我们第一步工作,就是要让其遵守实现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

上例中笔者借助了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

就是这样,甲乙双发约定了以接口名+Obj字符串的规则去定义服务类,乙方做DL时只需要配合runtime,也是轻而易举。

那么如果服务类实例化需要参数呢?

配置文件能解决这个问题,上文有提到Spring框架做DI的原理就是反射+xml,一般来说大部分支持反射机制语言的DI框架原理都是相似的,这里说下笔者了解的两种主流注入原理,构造器注入和属性注入,记得上文也提到过着两种注入方式,笔者强调过那只是一种思想,不是定性的一种方法,ok来看下那些DI框架是怎样做的。

  • 构造器注入
    在进行依赖获取的时候,DI框架通过反射机制得到待创建类的构造方法,然后根据构造器所需参数的类型或者顺序,在DI容器节点中寻找,然后提供参数,创建实例。
  • 属性注入
    同样的,在进行DL时,通过反射得到待创建类型的所有属性,然后根据属性在DI容器节点中进行匹配,有则创建提供,无则跳过。


最后利用词条做个局部总结:
  • 依赖注入+接口编程
  • 调用者无须关心对象任何实现,只需按照接口规则调用服务
  • 在系统分析和架构中,分清层次和依赖关系,每个层次不是直接向其上层提供服务(即不是直接实例化在上层中),而是通过定义一组接口,仅向上层暴露其接口功能,上层对于下层仅仅是接口依赖,而不依赖具体类。
  • 服务使用端由对对象的依赖转变成对接口的依赖,这样甚至可以在服务提供对象还未存在之前编码(分子项目开发)


End

依赖注入只是一种思想,其实也就是一个过程,依赖注入用到了面向接口的编程思想,面向接口的架构实现用到了依赖注入的执行方式。而面向接口编程和面向对象编程并不是平级的,它并不是比面向对象编程更先进的一种独立的编程思想,而是附属于面向对象思想体系,属于其中一部分。或者说,它是面向对象编程体系中的思想精髓之一。

同时我要赞叹POP的强大,对于未来的未知事物,我们先认知这个东西的行为(使用接口来实现这个行为),再认知这种行为的具体(使用具体的代码实现这个接口)。

这篇文章中有提到广义的"接口"也有专指的"接口",读后具体的理解和认识就靠自己用时间去慢慢沉淀了。


写在最后

在我对依赖注入理解得比较浅的时候,只是浅层地理解这种思想的存在,并没有相关开发经验足以支撑我深入对DI的思考和感受,网上的文章全部都仅仅局限在那几个浅层的例子,并没有继续深入挖掘解释,全靠一位师兄为我讲解,所以我希望有篇文章可以聚集大家思考讨论,同时为他人提供学习的途径。心中有疑惑又无能为力的感觉的确非常痛苦。谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容