软件设计杂谈——依赖倒置
昨天看到知乎一个问题问“JavaScript中如何使用依赖注入”,正好最近在写软件设计杂谈系列,就顺便以这个问题为例把依赖倒置原则这个OOP理论中的重要原则讲一讲。
我们在Java Spring中经常听到"依赖注入"和"控制反转"两个术语,他们和"依赖倒置原则"是什么关系呢,这些术语是什么意思呢?
到底什么是依赖注入(DI)和控制反转(IoC)?
DI和IoC是实现依赖倒置原则的具体手段,依赖倒置是面向对象编程(OOP)的产物,一句话解释下依赖倒置原则:
抽象不应该依赖实现,实现也不应该依赖实现,实现应该依赖抽象。
乍一听很玄乎,什么是"抽象"、"实现"?
我们举个通俗的栗子。
假设你想去吃一碗牛肉面。
如果按照面向过程编程的思维,大概是这样的:
- 输入:面粉、牛肉、辣椒酱 ;
- 制作牛肉面,你要按菜谱一步一步做;
- 输出:牛肉面。
如果你不想自己做,那就按面向对象编程的思维,大概是这样的:
- 你是一个Object,现在需要一碗牛肉面;
- "你"需要一个依赖厨师Object,因为厨师有"制作牛肉面"这个方法,于是你雇了一个厨师;
- 你又调用了"超市Object"的购买方法,买到了 面粉、牛肉、辣椒酱;
- 跟厨师说你要的口味,把买到的食材给厨师,调用厨师Object的"制作牛肉面"方法,完成制作。
等等,有没有发现哪里不对劲?
- 我为了吃一碗牛肉面还要雇一个厨师?
- 我雇了厨师还要自己买食材?
问题在于,"我"这个Object依赖了一个厨师Object,这个就叫"实现"依赖了"实现"。因为依赖了具体的"实现",所以很多细节被暴露出来了,于是我试图把更多本不该我管的细节(买食材)传递给了具体的”实现"(厨师)。
吃牛肉面的解决之道,不是雇一个厨师,而是去一个面馆,在面馆里看着菜单:"一份微辣大份牛肉面,谢谢"。
这里,"菜单"就是"抽象","厨师Object"就是"实现"。
"我"这个Object只需要依赖"菜单"提供的抽象接口,调用"下单"就能吃到牛肉面,而不关心背后的厨师是哪些,他们怎么买的食材,具体是怎么做出来的。这就叫"实现应该依赖抽象"。
如果"我"这个Object如果依赖了厨师Object,调用了 new Cook(),就必然要管理这个厨师从初始化到解雇的整个流程了。
也就是说当我调用 new 的瞬间之后:对象完整的生命周期、资源如何创建和销毁全都要我去管了。
但实际上按照下馆子的方式,厨师是餐馆管理的,这一点非常关键:
- 餐馆就是那个控制反转(IoC)容器,总要有一个东西来管理这些抽象的具体实现,比如餐馆对内管理了数十个不同的厨师,对外提供10个菜品。
- 餐馆给"我"这个Object"注入"菜单的过程,就是依赖注入(DI)
- 我应该依赖 抽象的"菜单" 去下单,而不是试图把食材递给厨师张三看着他做,这就是依赖倒置原则。
对比 面向过程、初级面向对象、符合依赖倒置原则的面向对象 这三个方式,我们发现事情似乎变简单了,我不用自己买食材做面条,直接下馆子就OK了,这就是面向对象编程的封装和信息隐藏的力量。做牛肉面的复杂度并没有被降低,但整个流程和"我"这个Object的耦合解开了。
再回到之前对依赖倒置原则的解释:
抽象不应该依赖实现,实现也不应该依赖实现,实现应该依赖抽象。
我们换成 厨师 菜单 客人:
菜单不应该依赖厨师,客人也不应该依赖厨师,客人应该依赖菜单。
一下子清晰多了。
我这里刻意避开类(Class)这个概念,是为了说明OOP的思维并不一定要"类"这个概念,重点在于通过信息隐藏来解耦,让复杂的软件系统可以分而治之。
Java Spring中的DI和IoC
Spring框架提供了XML和Java Config注解两种方式来告诉Spring这个IoC容器,需要管理哪些抽象接口的具体实现。如今XML方式几乎没有多少人用了,注解声明一个Class是@Bean @Component @Service @Controller @Repository等等这些的时候,Spring就把这个类初始化一个单例出来,管理整个声明周期,提供了一些诸如 @PostConstruct @PreDestroy等钩子用来定制Bean。依赖方直接通过 @Resource @Autowired 等注解,或者直接构造器声明,就能拿到一个Bean的具体实现了。
通常这些Bean是作为Interface类型的,这样方便扩展不同的Implementation,用@Qualified或按名称注入依赖,可以选择不同的实现。
Spring这个IoC容器管理Bean的生命周期流程,参考下面这张图:
如何在JavaScript中使用IoC?
其实主流的几个组件化MVVM框架,Angular,Vue,React,就已经用了依赖注入了,框架本身就是IoC容器。
不知庐山真面目,只缘身在此山中。
以Vue为例:
- 我们在组件中用"components"声明依赖的组件时,也是一种依赖注入。也许有人说,注入的明明是具体的组件"实现"而不是"抽象"啊? 组件B依赖组件A,但在组件B中根本没有去 new 组件A,也没有管A什么时候创建,什么时候销毁,需要怎么初始化,只是为了告诉Vue这个IoC容器:组件B依赖组件A这个事情,组件的A的 init compile mount destroy 这些具体的流程和实现的管理不需要B去关心,因此这个声明可以看做是依赖了A的"抽象"。这里的"抽象"并不一定是类似Java的"Interface"这种形式。
- 控制反转(IoC)容器,就是统一管理各个实现如何初始化、从生到死整个过程的超级管家,Vue框架本身就干了这个事情,当你用Vue.component, Vue.use把组件注册到Vue里面的时候,这个组件的实例什么时候挂载到什么地方,都可以看作由Vue这个IoC容器来控制的。
- 上面说Vue的父子组件之间直接声明components是一种依赖注入,还有一个更明显的 inject provide 直接给所有后代组件都注入依赖。同样,inject/provide注入给子孙后代组件,这些后代也不用管祖先组件是怎么创建和销毁的。
Angular从1.x的AngularJS,在参数中直接传递依赖组件的字符串,到后来新的Angular框架,都具有非常明显的IoC和DI的特征。而require.js这类工具解决的不是对象与对象之间的耦合问题,所以不完全算依赖注入和控制反转。
另一个非前端的例子,Node.js服务端框架 nest.js,和Java Spring以及Angular的用法非常类似,可以阅读官方文档,也有对IoC和DI的解释和具体使用示例,讲的非常详尽。
https://docs.nestjs.com/ 因此,如果项目相对复杂,开始用这些前后端框架,构造器代码中很少 new 非DTO/VO/PO对象出来的时候,就已经在欢快地使用依赖注入了,而IoC容器就是那个为你管理这些具体实现对象的生与死的幕后Boss。
依赖注入的问题和局限性
依赖注入一定是"好的模式"吗?
不完全是。今天我去餐馆说要一份不辣的牛肉面,结果上来一份巨辣无比的牛肉面。这就是"信息隐藏"的代价。在Java中,SpringBoot已经把IoC和DI发展的淋漓尽致了,一个@EnableAutoConfiguration注解,背后做了很多黑箱的事情,各种约定式的配置直接告诉Spring容器该做什么事情,甚至无需写一行代码。物极必反,这样反而让项目容易出现过多冗余的依赖、大量被Spring容器中的Bean在背后难以控制、一个接口存在过多的实现类、不确定的互相影响、依赖加载顺序问题等等。
虽然可能存在这些问题,但我觉得在以面向对象编程为主的复杂系统引入IoC容器和DI仍然是有必要的,上述这些问题也有办法避免或解决。让对象自己管理所依赖对象的生命周期,就像直接雇一个厨师来做牛肉面一样简单粗暴,但更容易违背迪米特法则等其他OOP的理念,项目的可扩展性和可维护性会受到更强的制约。这里前提是OOP情况下的建议,当然OOP也有一些局限性,不一定非要用OOP作为编程范式。
另一个场景,如果只是一些简单的页面或服务,没有复杂的组件/服务之间的交互,是没有必要为了用DI而用DI的。
总结
依赖注入(DI)和控制反转(IoC)是具体的手段,是OOP理论中依赖倒置原则的体现形式,通过信息隐藏来降低对象之间的耦合,这就是依赖倒置解决的问题。这种思想的运用不限于语言和框架。像Java Spring用工厂/模板方法/代理/单例模式、、注解、反射、动态代理这一系列设计模式和相关技术实现了IoC容器,而在没有类似Spring的语言和框架中运用这一思想的时候,无需实现如此复杂的框架,只要达到依赖倒置的"实现"和"实现"的解耦效果即可。