一、依赖注入(Dependency Injection)
今天我们讨论的内容核心是面向接口编程,我决定还是要从依赖注入开始讲起,因为DI的思想可以说是面向接口编程思想的特殊表现,也可以说是与面向接口编程相辅相成。
举个例子~
我们有一个公交车类(Bus),每天早上6点钟需要发车(work),为其分配对应的司机(Driver),看代码
<pre>
@implementation Bus
- (void)work {
Driver *driver = [[Driver alloc] initWithName:@"张三"];
//dosomething
}
@end
</pre>
上面这段代码中,Bus对象的运作需要用到Driver对象,因而创建了一个Driver对象,我们称Bus对Driver有一个依赖。这样的强耦合关系会因为日后的变化而给我们带来很多麻烦,不久将来张三师傅辞职了,我们需要修改Bus-work()的代码,也就是说在开发过程中非常不便于单元测试(一是不能方便地更换各种Driver对象,二是如果Driver这个职位创建是耗时操作或者高成本操作,我们并不能使用准备好的Driver实现快速重复测试)。
<pre>
@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
</pre>
以上这段代码我们通过init方法,为Bus对象传入了一个Driver对象,像这种非自己主动初始化依赖,而从外部通过注入点注入依赖的方式,我们就称为依赖注入,而例子中的这种注入的方法称之为构造器注入。明显的,这个场景中Bus和Driver的耦合因此轻了一层。说到解耦,并不是说Bus和Driver之间的依赖关系就不存在了,在Bus的范围内看来,只是将依赖建立从编译期间推迟到了运行期间,毕竟Bus无论如何也是需要Driver提供服务的。
“依赖就像是系统中的plugin,主系统并不强依赖于任何一个插件,但一旦插件被加载,主系统就应该可以准确调用适当插件的功能”。
类似这样的注入方式还有
- 属性注入
- 方法注入
- 环境上下文注入
- 子类重写方法注入等
不同的知识注入的手段,思想还是一样的。
二、开闭原则(Open-Closed Principle)
他的原文解释是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方式(类似于其他语言的反射)。
下面还是用一个简单的例子来增强对通过配置文件做依赖获取的认识:
<pre>
//定义一个主题接口,让所有主题都实现它
@protocol ItfThemeFactory
- (void)drawing;
@end
//主题
@implementation SpringFactory
- (void)drawing {
//drawing theme...
}
@end
@implementation SummerFactory
- (void)drawing {
//drawing theme...
}
@end
//主题工厂Animator
@interface ThemeFactoryAnimator : NSObject
@property (strong, nonatomic) id themeFactory;
@end
@implementation ThemeFactoryAnimator
- (id)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 themeFactory = tfAnimator.themeFactory;
[themeFactory drawing];
}
</pre>
以上,我们只需要在执行方法(-work())中拿到themeFactory,对界面进行渲染即可,而原本有可能出现依赖的地方——ThemeFactoryAnimator已经不依赖于外部注入,而仅仅依赖于我的theme.plist配置文件,也可以说我们将多态封装到了这个“获取点”内,因此主题的改变映射到了配置文件中对应内容的改变,但是这个更换主题系统目前就利用DI变得符合OCP原则了吗?不是的,虽然依赖的改变已经映射到了客户类封装的外部——配置文件中,可是我们还是无法避免if-else结构的存在,我们可以不修改代码自由更换主题,可是如果又开发出了一套新的主题呢?这个系统对于未来还是无能为力,这一part的重点是依赖获取,至于怎么消除这种缺陷?看完这篇文章也许你就自然明白了。
五、面向接口编程(Protocol-oriented programming)
接口泛指实体把自己提供给外界的一种抽象化物,用以由内部操作分离出外部沟通方法,使其能被修改内部而不影响外界其他实体与其交互的方式
举个例子说明下:
<pre>
//首先我们定义一个交通工具接口
@protocol Transportation
- (void)drinking;
- (void)freight;
@end
//还有一个发光体接口
@protocol Irradiative - (void)shine;
@end
//当然drinking就代表补需,汽车飞机的内部实现就是加油,马牛的内部实现就是吃草喝水什么的。freight就是装载
//当上帝创造马的时候,让马遵守并实现这个接口:
@implementation Horse
(void)drinking {
//吃草,喝果汁
}(void)freight {
//停住脚步,或者半蹲,让友好的人类骑上去
}
@end
//当人类创造飞机的时候,惨了,不知道去哪里找上帝沟通,又怕疏漏了什么影响了这个世界的运行规律?没事上帝给我们留下了Transportation接口,而且飞机同时还要遵守发光体接口Irradiative,于是:
@implementation Aircraft(void)drinking {
//加油, 92的
}(void)freight {
//降落,熄火,开舱门
}(void)shine {
//燃烧汽油,生物质能转化成电能,照你
}
@end
</pre>
六、面向接口编程(架构)
无论是哪种架构方式,层次关系肯定是撇不开的,并且层次关系也代表着一种架构的主心骨,无论业务分层,功能分层,还是角色分层,存在于各个位置的依赖关系都需要我们去正视,而POP的目的正是为了化解这些强依赖,打破上层实例化下层去为其提供服务的强耦合,在大型项目中,一层的变化可能会联动1+N层,这样的变化是致命的,正如上文我们提到过的,让一个实体由依赖另一个实体,转变成依赖一个接口,将被依赖实体的变化隔绝于接口之外。
七、iOS面向协议的编程思想
其实讲了这么多,大家或许已经发现,在我们日常OC编码中似乎随处可见接口编程的痕迹?——侵蚀了我们项目各个模块的代理模式,代理模式的工作原理就是,一方使用protocol(接口)划定一个或一组规则,成为其代理的角色必须遵守这一系列规则,最后根据规则去办事,好处依然是那么明显,主体并不需要与代理沟通,代理也不需要做多余的培训,直接上岗,从这里又强化了一遍接口即一种由内部操作分离出外部沟通方法,而核心就是一系列规则,通过接口工作,比直接访问属性或者方法稳健得多。
举个例子说明~
<pre>
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
- (void)requestWithUrl:(NSURL *)url Param:(NSDictionary *)param;
@end
//甲方
@interface MusicLoadingProtocolObj()
@end
@implementation MusicLoadingProtocolObj
- (void)requestWithUrl:(NSURL *)url Param:(NSDictionary *)param {
//do something
}
@end
//乙方
@interface Client()
@end
@implementation Client
- (void)work {
id service = [[JSObjection defaultInjector] getObject:@protocol(MusicLoadingProtocol)];
[service requestWithUrl:url Param:param];
}
@end
</pre>
上例中笔者借助了OC的一个轻量级的DI框架objection,服务实现者甲方独立编写服务实现,而后将服务通过objection绑定到protocol之上,去看看服务使用者,乙方利用objection通过protocol拿到服务类实例,根据protocol中定义的规则,马上就实现了服务。不需要import,不需要实例化,高度解耦,并且符合OCP原则。objection的原理就是上文提到的key-value内存映射表,对于大型项目,多小组分项目开发再合并的生产线,POP是必不可少的。
如果说我们在轻型开发中不想使用框架,我们也可以谈谈自己实现POP+DI,利用起OC的利器——runtime。其实在上例已经埋下伏笔,这次我们的乙方可以这样做:
<pre>
//乙方
@interface Client()
@end
@implementation Client
- (void)work {
NSString *clazzName = @"MusicLoadingProtocol";
[clazzName stringByAppendingString:@"Obj"];
Class serviceClazz = NSClassFromString(clazzName);
id service = [serviceClazz alloc] init];
[service requestWithUrl:url Param:param];
}
@end
</pre>
就是这样,甲乙双发约定了以接口名+Obj字符串的规则去定义服务类,乙方做DL时只需要配合runtime,也是轻而易举。
那么如果服务类实例化需要参数呢?
配置文件能解决这个问题,上文有提到Spring框架做DI的原理就是反射+xml,一般来说大部分支持反射机制语言的DI框架原理都是相似的,这里说下笔者了解的两种主流注入原理,构造器注入和属性注入,记得上文也提到过着两种注入方式,笔者强调过那只是一种思想,不是定性的一种方法,ok来看下那些DI框架是怎样做的。
构造器注入
在进行依赖获取的时候,DI框架通过反射机制得到待创建类的构造方法,然后根据构造器所需参数的类型或者顺序,在DI容器节点中寻找,然后提供参数,创建实例。属性注入
同样的,在进行DL时,通过反射得到待创建类型的所有属性,然后根据属性在DI容器节点中进行匹配,有则创建提供,无则跳过。
八、最后
- 依赖注入+接口编程
- 调用者无须关心对象任何实现,只需按照接口规则调用服务
- 在系统分析和架构中,分清层次和依赖关系,每个层次不是直接向其上层提供服务(即不是直接实例化在上层中),而是通过定义一组接口,仅向上层暴露其接口功能,上层对于下层仅仅是接口依赖,而不依赖具体类。
- 服务使用端由对对象的依赖转变成对接口的依赖,这样甚至可以在服务提供对象还未存在之前编码(分子项目开发)
依赖注入只是一种思想,其实也就是一个过程,依赖注入用到了面向接口的编程思想,面向接口的架构实现用到了依赖注入的执行方式。而面向接口编程和面向对象编程并不是平级的,它并不是比面向对象编程更先进的一种独立的编程思想,而是附属于面向对象思想体系,属于其中一部分。或者说,它是面向对象编程体系中的思想精髓之一。