什么是依赖注入
简介
依赖注入的概念可以参考 Angular 的文档,Angular 中的依赖注入。
关于什么是依赖注入,我在网上搜索了很多文章,专业术语也有很多,看的我迷迷糊糊的。我尝试记录一下我自己的理解。
首先从名字入手,依赖注入可以分成依赖和注入。
什么是依赖
在讨论依赖之前,必须先了解什么是服务,不考虑边界情况下,可以把服务理解为包含数据和方法的对象。
一般我们会通过实例化一个类来得到这个服务对象。我们可以想象这个类的某个实例属性有可能又是另一个类的实例对象。
这个过程可以一直进行下去。比如这样的依赖关系图:
A --> B、C、D
B --> C、D
C --> D、E
D --> E、F
E --> F
F 没有依赖
上面的依赖图代表 A 这个类有三个实例属性b、c、d
分别是B、C、D
这三个类的实例对象。意味着A
类是依赖B、C、D
这 3 个类的。
而B
类又是依赖C、D
这两个类的。依次类推我们可以知道C、D、E
这三个类的依赖,注意到F
类是没有依赖的,所以这个例子中不存在循环依赖。
当我们说到依赖这两个字时,其实依赖既可以是动词又可以是名词,作动词时可以说类 A 依赖着类 B,C,D。作名词时类 B,C,D 就是类 A 的依赖。
什么是注入
在了解了什么是依赖之后,注入就非常简单了,比如 A 依赖 B,那么通过某种手段
把 B 注入到 A 的过程就是注入。
常用的注入手段有 3 种,构造函数注入,属性注入,setter 注入。
先看看在没有依赖注入框架的帮助下,我们怎么手动实现注入。
- 手动通过构造函数注入
class B {
name = 'B';
}
class A {
name = 'A';
b: B;
constructor(b: B) {
this.b = b;
}
}
const b = new B();
const a = new A(b);
- 手动通过属性注入
class B {
name = 'B';
}
class A {
name = 'A';
b: B;
}
const b = new B();
const a = new A();
a.b = b;
- 手动通过 setter 注入
class B {
name = 'B';
}
class A {
name = 'A';
b: B;
setB(b: B) {
this.b = b;
}
}
const b = new B();
const a = new A();
a.setB(b);
以上代码展示了 3 种手动注入的过程,下面介绍依赖注入框架怎么实现类似的功能。
- 依赖注入框架会自动把 B 注入到 A 中-通过构造函数的方式
import { Container, Inject } from '@kaokei/di';
class B {
name = 'B';
}
class A {
name = 'A';
constructor(
@Inject(B)
public b: B
) {}
}
const container = new Container();
const a = container.get(A);
- 依赖注入框架会自动把 B 注入到 A 中-通过属性注入的方式
import { Container, Inject } from '@kaokei/di';
class B {
name = 'B';
}
class A {
name = 'A';
@Inject()
b: B;
}
const container = new Container();
const a = container.get(A);
Note
虽然有些框架实现了 setter 注入,比如 Spring,不过本库并没有支持 setter 注入。
和上面的手动的实例化过程对比,我们发现依赖注入框架屏蔽了注入细节,业务在使用时只需要关注container.get(A)
即可。
因为依赖关系已经在类中通过@Inject
声明了,依赖注入框架会自动寻找依赖并完成自动注入。
这就是依赖注入框架的魅力,实际上依赖关系越复杂,依赖注入框架的优势就越明显。
依赖注入的简单实现
我的总结是依赖注入实在没有什么技术含量,也没有什么高大上的地方。不要被陌生的技术名词给吓到了。本质上就是 Key-Value 的魔法。比如:
这里的 bindService 和 getService 属于伪代码,其中 bindService 负责绑定 token 和服务,getService 则是负责获取服务。
// 这里只是伪代码
// 内部使用map来记录对应关系
bindService('tokenA', 'valueA');
// 再通过map.get(key)获取数据即可
const value = getService('tokenA');
可以说这就是最简单的依赖注入的简单实现。但是它实在是太简单了,处理的场景有限,所以价值不大。至少要再加上类的实例化能力。
// 这里只是伪代码
// 内部使用map来记录对应关系
bindService('tokenA', ClassA);
// 再通过map.get(key)获取到ClassA,这里判断是一个类,则实例化后返回一个对象,否则直接返回
const instanceOfClassA = getService('tokenA');
现在我们这个简易的依赖注入库实现了两种能力,如果判断是类,则去实例化;否值直接返回。
我们可以沿着这个思路继续添加新能力。比如如果是普通函数,那么就当作普通函数来执行,然后把这个函数的返回值当作服务返回,这样我们就有三种能力了。
延续这种扩展思路,我们可以继续扩展更多的能力,无非就是添加一个if-else
分支的事情。
排除掉这种扩展思路本身,我们的依赖注入框架还有什么局限性吗?
其实还有命名空间单一的问题。显然上面所有的数据都处于同一个全局命名空间下。因为bindService
和getService
是一个全局函数。那么所有的配置信息就只有一份。
这种状况在大多数场景应该也没有什么问题。但是确实还可以继续提升一下。
这里需要继续引入一个新的概念,就是Container
。通过下面的伪代码我们可以快速了解为什么需要 Container。参考这里可以了解什么是容器。
// 这里只是伪代码
const parent = new Container();
const child = parent.createChild();
parent.bind('tokenA', ClassA);
child.bind('tokenB', ClassB);
// 注意到child中并没有定义tokenA,但是仍然可以获取到服务实例
// 因为child的父级容器parent中有tokenB
const serviceA = child.get('tokenA');
// 这里会抛出异常,因为parent中没有tokenB,并且parent也没有父级容器了。
const serviceB = parent.get('tokenB');
以上伪代码展示了分层注入的特性,之所以引入 Container 这个概念主要是为了避免只有全局一份配置信息。我们可以做到每次实例化一个 Container 对象,这个 Container 对象就具有依赖注入的能力;除此之外我们还可以给 Container 对象增加一个 parent 属性,从而可以把 Container 对象关联起来,如果当前 Container 对象中找不到某个服务,就会从其 parent Container 对象中寻找服务,直到根 Container 为空。
Note
以上是从服务配置和获取服务这两个角度来剖析了如何简单实现一个依赖注入框架。当然如果要处理依赖的依赖,甚至循环依赖等复杂场景,还需要其他方面的支持。比如 typescript 以及 decorator。不过这属于技术细节,不影响理解整体概念,这里不再细述。有兴趣可以直接参考源代码即可。
在 vue 中使用依赖注入
以上是从依赖注入本身的角度来思考的,和具体业务是无关的。考虑到在具体前端的场景下,比如在 vue 中,应该怎样去结合使用呢?
这里提供一个适用于 vue 的依赖注入框架@kaokei/use-vue-service。
该库提供了 2 个关键的 API。declareProviders
和useService
。
declareProviders([A, B])
这行代码的作用类似于下方代码。
const container = new Container();
container.bind(A).toSelf();
container.bind(B).toSelf();
const a = useService(A); const b = useService(B)
这行代码的作用类似于下方代码。
const a = container.get(A);
const b = container.get(B);
有如下优点:
- 屏蔽了依赖注入相关概念,主要是不需要自己创建以及维护 container 了。
- useService 是基于 provide/inject 开发的,所以支持在子孙组件中直接获取任意层级的祖先组件中关联的服务。
- 相比于全局 store,服务的生命周期是和 declareProviders 所在的组件的生命周期相关联的,也就是组件销毁时,服务也会自动销毁。
- 页面中组件是一个树状结构,天然适配 container 的树状结构。所以子节点的 container 中同名服务会自动屏蔽父节点的 container 中同名服务。类似于原型链中寻找属性的机制。
- 利用 Activation 钩子默认返回 reactive 对象,所以可以直接在 vue template 中消费服务数据。服务数据变化时,页面会自动更新。
依赖注入 vs import/export
- import/export 隐含着服务是单例的。假设有一个模块 moduleA,任意其他模块都可以 import moduleA,而且获取的都是同一个对象,这个对象是全局单例唯一的。
虽然我们也可以 import 一个类,然后在不同的业务模块中实例化这个类,从而可以得到多个实例对象。此时确实不是单例的,但是也面临着另一个问题,就是所有业务模块得到的实例对象都是不同的对象.
如果期望对各个业务模块进行分组,不同组的业务模块需要不同的实例对象,但是同一组的又需要同一个实例对象。这种需求就很难组织代码了。
本质上还是因为模块和模块之间是通过 import 产生直接依赖关系的,缺少一个抽象依赖层,依赖注入库则是提供了这一个抽象层。
依赖注入库通过依赖 token 使得各个模块之间从直接依赖关系变成间接依赖关系。
- import/export 导致业务强制依赖某个服务,不存在干预服务创建过程的可能性。因为我们一般会直接 import 一个服务本身,然后在业务代码中使用这个服务,这样就导致业务直接依赖这个服务对象。
相反依赖注入使业务解藕了依赖声明和依赖的实例化。比如业务代码声明依赖 LoggerService。
如果依赖注入框架是采用默认绑定策略,那么最终获取的确实是 LoggerService 的实例对象。
但是也可以绑定到其他服务,比如container.bind(LoggerService).to(OtherLoggerService)
,那么业务代码不用修改的情况下,就可以替换服务的逻辑,最终获取到的就是 OtherLoggerService 实例对象。
Note
import/export 是强依赖关系:服务A --> 服务B
依赖注入是弱依赖关系:服务A --> tokenB --> 服务B
- 依赖注入功能还是离不开 import/export 的,比如在依赖注入场景中类 A 依赖类 B。
如果把类 B 自身作为 token,显然是需要在类 A 中 import 类 B 的。
如果使用专门的 Token 实例对象,比如 const tokenB = new Token()
,那么业务代码也需要 import tokenB
,而且依赖注入框架需要将 tokenB 和类 B 进行绑定。
inversify 支持使用字符串作为 tokenB,那么确实可以不需要 import 任何 token,因为在所有文件中相同的字符串肯定是相等的。但是本库不支持使用字符串作为 token。