之前看过前辈Artech关于控制反转的一篇文章,文章通俗易懂且言语精炼,写技术文章既是积累也是分享,既然是分享那么必须让读者能够明白到底讲解的什么,所以在这里我也挑战下自己,看看能不能将概念通过简洁代码和语言的形式充分阐述清楚,若有错误之处,还望指正。
什么是控制反转
控制反转的英文名为Inversion Of Control,我们简称为IOC,控制反转是一个原则而不是一个设计模式,它是反转程序的控制流,这个术语在Steapano Mazzocchi的Apache软件基金会项目Avalon中被推广,然后在2004年由Robert C. Martin和Martin Fowler进一步推广。
正如Martin Fowler所说:控制反转是框架的共同特征,因此说这些轻量级容器之所以特别是因为它们使用控制反转,就好像在说我的车很特别,因为它带有轮子一样。它基本上是框架的定义特征,控制反转用于增加程序的模块化并使其可扩展。
那么问题来了,真正反转体现在哪里呢?在早期计算机软件,命令行用于通用程序,因此用户界面由应用程序本身控制,在程序中,我们可以通过将响应输入命令行来直接控制程序的流程,但是在GUI程序中,我们基本上是将控件移交给了窗口系统(UI框架),然后由窗口系统决定下一步要做什么,此时程序的主控件从我们移到了UI框架。
控制反转是库和框架之间的区别,使用库时,库本质上是调用特定的函数和方法来执行计算和操作,每个调用都会完成一些工作,并将控制权返回到客户端,而框架会为我们完成一些工作,我们只需要向框架不同位置注册我们所编写的代码,然后,框架将在需要时调用我们编写的代码。
用更加通俗易懂的话理解则是:不要叫我,我会叫你或者不要给我们打电话,我们会通知你(好莱坞法则)。有了对概念的初步理解,接下来我们通过代码的形式来加深对概念的理解。
我们反观上述代码,因为汽车的组成离不开引擎构造,当我们调用汽车对象实例时,将主动去构造引擎对象实例,表述上没有任何问题,但是我们意识到引擎和汽车紧密结合在了一起,如果构造引擎对象一旦发生变化,毫无疑问我们需要修改汽车对象,也就是说汽车对象强依赖引擎对象,现在我们将代码进行如下修改:
在此种情况下,汽车对象并不知道如何构造引擎对象,当调用汽车时,汽车的调用者有责任和义务将引擎对象实例传递给汽车,此时流程控制被反转,这种反转类似于基于事件的处理机制。也就是说流程管理从应用程序转移到了框架,经过如此修改后,引擎上升到了框架,如黑匣子一般,因为我们并不关心引擎具体如何构造。同时我们也可看出,通过控制反转使程序更加灵活和松散耦合。讲完了控制反转的概念和例子,我们似乎还有一个未进行讲解,好像我们听到更多的是依赖注入,那么依赖注入和控制反转有着怎样的联系呢?
依赖注入和控制反转两个相关但概念截然不同,依赖注入的思想就是一个单独对象,说白了就是编写类的方式,使得可以在构造时将类或函数的特定实例传递给它们,依赖注入其实就意味着控制反转,因为当我们在对象上调用方法时,它们不再定位它们所需的其他对象。取而代之的是,它们在构造时就已被赋予了依赖关系,但我们仍然必须管理构造,通过使用控件容器的反转,我们可以使依赖注入更进一步,通过反转控制容器,我们只需预先注册所有可用的类。当容器需要构造一个类的实例时,它可以检查该类的构造函数需要哪些对象,然后可以从向其注册的类中构造适当的实例,总的来说依赖注入只是实现控制反转的一种方式而已。
我们抛开依赖注入实现了控制反转,仅仅只讨论依赖注入带来了哪些好处。
既然是面向对象的语言,那么我们是编写基于面向对象的代码,那么对象自然而然就有其生命周期,有的对象可能我们只需要一个实例,有的对象可能在程序运行整个过程中一直存在也就是全局实例,而且有的对象里面存在着对其他对象的引用,如此一来会造成什么问题呢?导致代码难以理解而且难以更改,尤其是对于全局实例而言,全局实例离散性行为太强,分散在整个项目中的各个角落,最主要的是我们所编写的代码细节中也隐藏了对象之间的交互,有些实例就包含了对其他实例的引用,一旦出现问题,我们唯有通读每一行代码。
我们通过引入依赖注入代替全局实例方式,通过依赖注入常用方式即构造函数注入注入依赖项参数,此举将提高代码的可读性,我们只需快速浏览构造函数即可查看对应依赖关系。通过引入依赖注入我们需要注意的是对对应类进行合理划分,因为每次引入新的依赖项时,可能还是存在类与类之间的依赖,将不同行为划分到不同组,如此才能减少类与类之间的耦合,使得我们的设计更具凝聚力。通过引入依赖注入也使得我们在进行单元测试时更加方便,因为我们可通过隔离类来直接测试类实例。
控制反转代码说明
接下来我们讨论下如何利用程序实现控制反转,实现控制反转最常见的两种方式则是:服务定位器模式(SL)和依赖注入模式(DI)。接下来我们通过例子利用依赖注入和服务定位器模式实现控制反转。我们通过控制台实现获取图书馆库图书列表,查询我们想要的图书,如下我们定义图书类:
然后接下来我们将控制台程序名称修改为图书馆库,然后根据我们输入的图书来查询图书并打印,伪代码如下:
如上我们通过bookFinder获取图书馆图书列表,然后查询我们输入的图书名称并打印,我们一眼就能看出这个bookFinder从哪里来呢?我们可能查找深圳图书馆或者国家图书馆或者网上远程爬取呢?,所以接下来我们需要创建bookFinder的接口实现,如下:
经过上述改造后,我们提供了IBookFinder接口以及其实现,但是现在我们正在将其作为一个框架,需要被其他人可扩展和使用,若此时需要提供给国家图书馆使用呢?我们可以看到此时图书库即Library同时依赖IBookFinder和及其实现,当我们作为可扩展框架时,最佳效果则是依赖接口而不是依赖具体实现细节,那么此时该实例我们到底该如何使用呢?答案则是控制反转,我们通过依赖注入实现控制反转。
接下来我们将上述图书馆库Library修改为通过构造函数注入IBookFinder接口,此时库将仅仅只依赖于IBookFinder接口,IBookFinder内部具体实现Library并不关心,然后在控制台进行如下调用:
上述我们通过依赖注入使得我们可以进行可扩展,根据不同图书馆需要只需提供IBookFinder具体实现即可,依赖注入并不是实现控制反转唯一的方式,我们还可以通过服务定位器来实现,服务定位器的背后是一个对象,该对象知道如何获取应用程序可能需要的所有服务,也就是说服务定位器提供我们返回IBookFinder接口的实现,如下:
通过依赖注入和服务定位器实现控制反转都分离了相互依赖,只不过依赖注入让我们通过构造函数一目了然就可查看依赖关系,而服务定位器需要显式请求依赖关系,本质上没有任何区别,至于如何使用,主要取决于我们对二者的熟悉程度。正如Martin Fowler所说:使用服务定位器时,每个服务都依赖于服务定位器,它可以隐藏对其他实现的依赖关系,但是我们确实需要查看服务定位器,因此,是否采用定位器还是注入器主要决定于该依赖关系是否成问题。
讲到这里我们借助于IServiceProvider接口实现.NET Core中的服务定位器。如下:
除了以上写法外,我们还可以通过实例化ServiceLocator的方式来获取服务,如下:
接下来我们写一个简单的接口来验证是否正确:
不知道上述两种写法是否存在有什么不妥的地方,有的时候通过服务定位器的方式也非常清爽,因为当我们实例化最终具体实现时通过构造注入依赖项时,本没有什么,但是若后期一旦需要增加或减少依赖项时,我们同样需要修改最终具体实现,像这种情况是否可以考虑用服务定位器模式,直接通过服务定位器去获取指定服务,当在具体方法里时我们每次都得去获取服务,反而不如在构造器中一劳永逸注入。所以选择注入器和定位器根据个人而选择或者根据具体功能实现而定才是最佳。
控制反转举栗说明
上述我们通过代码的形式来进一步阐述了控制反转,在代码的世界里,我们运用控制反转游刃有余,在现实生活里,我们运用控制反转也是得心应手。
年末将至,全家欢聚一堂,这应该是一年中最热闹的一次家庭聚会了吧,为了准备年饭具体要提供哪些食材和食物作为家庭的一份子都得有基本了解,所以我们必须提前准备好这些,这就像我们编写一个没有依赖注入的基本程序一样,这是在自家做的情况,自家做饭吃完后,又不能抹抹嘴上油,拍拍屁股马上走人,还得收拾不是,于是乎我们将年饭地点切换到饭店进行,此时饭店类似取缔了我们自备食材这一块,饭店就像餐饮服务商一样,我们不用自己做,饭店会给我们提供食物,它会根据我们的不同需求注入不同的餐饮服务。从自家-》饭店,整个流程控制权进行反转,我们将年饭控制权交给了饭店,因为饭店成为了年饭这一事件的策划者,它是我们能不能成功吃上年饭的必要条件,我们告诉饭店老板:有几个人、带了小孩、口味需重一点等等,我们需要做的就是提供一些基本参数,然后饭店自会组织,我们并不需要关心和干涉细节,他们会处理所有问题,一切就绪后会通知我们。
写本文的目的是一直对控制反转和依赖注入不太理解,在脑海中一直处于模糊的概念,同时呢,之前面试官问我关于依赖注入的理解,我居然支支吾吾的说成依赖倒置原则(Dependency Inversion Principle),千万不要将依赖注入、依赖倒置、控制反转搞混淆了,依赖倒置是完全不同的原理,虽然它也可以提供类之间的松散耦合和反转依赖项。文中若有错误之处,还望指出,感谢您的阅读,谢谢。