教你如何构建自己的依赖注入工具
在阅读这篇文档之前,你可以先了解一下这些知识,方便跟上文档里面的内容:
- 概念知识:控制反转、依赖注入、依赖倒置;
- 技术知识:装饰器 Decorator、反射 Reflect;
- TSyringe 的定义:Token、Provider https://github.com/microsoft/tsyringe#injection-token 。
本篇文章所有实现的实践都写到了 codesandbox 里面,感兴趣可以点进去看看源码 https://codesandbox.io/s/di-playground-oz2j9。
什么是依赖注入简单的例子
我们这里来实现一个简单的例子来解释什么是依赖注入:学生从家里驾驶交通工具去上学。
class Transportation {
drive() {
console.log('driving by transportation')
}
}
class Student {
transportation = new Transportation()
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
那么在现实的生活中,比较远的学生会选择开车去上学,近的的学生选择骑自行车去上学,那么上面的代码我们可能会继续抽象,写成下面的样子:
class Car extends Transportation {
drive() {
console.log('driving by car')
}
}
class FarStudent extends Student {
transportation = new Car()
}
这样的确满足了比较远的学生驾车去上学的需求,但是这里有一个问题,学生也有自己的具体选择和偏好,有的人喜欢宝马、有的人喜欢特斯拉。我们可以为了解决这样的问题继续使用继承的方式,继续抽象,得到喜欢宝马的有钱学生、喜欢特斯拉的有钱学生。
大家估计也会觉得这样写代码完全不可行,耦合度太高,每个类型的学生在抽象的时候都直接和一个具体的交通工具绑定在一起。学生拥有的交通工具并不是这个学生创建出来决定的,而是根据他的家庭状况、喜好确定他使用什么样子的交通工具去上学;甚至家里可能有很多的车,每天看心情开车去上学。
那么为了降低耦合性,根据具体的状态和条件进行依赖的创建,就要说到下面的模式了。
控制反转
控制反转(Inversion of Control,缩写为 IoC)是一种设计原则,通过反转程序逻辑来降低代码之间的耦合性。
控制反转容器(IoC 容器)是某一种具体的工具或者框架,用来执行从内部程序反转出来的代码逻辑,从而提高代码的复用性和可读性。我们常常用到的 DI 工具,就扮演了 IoC 容器的角色,连接着所有的对象和其依赖。
参考 Martin Fowler 关于控制反转和依赖注入的文章 https://martinfowler.com/articles/injection.html
依赖注入
依赖注入是控制反转的一种具体的实现,通过放弃程序内部对象生命创建的控制,由外部去创建并注入依赖的对象。
依赖注入的方法主要是以下四种:
- 基于接口。实现特定接口以供外部容器注入所依赖类型的对象。
- 基于 set 方法。实现特定属性的 public set 方法,来让外部容器调用传入所依赖类型的对象。
- 基于构造函数。实现特定参数的构造函数,在新建对象时传入所依赖类型的对象。
- 基于注解,在私有变量前加类似 “@Inject” 的注解,让工具或者框架能够分析依赖,自动注入依赖。
前两种方法不会在前端常用的 DI 工具中用到,这里主要介绍后面两种。
如果从构造函数传递,就可以写成这样:
class Student {
constructor(transportation: Transportation) {
this.transportation = transportation
}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
class Car extends Transportation {
drive() {
console.log('driving by car')
}
}
const car = new Car()
const student = new Student(car)
student.gotoSchool()
在没有工具的情况情况下,基于构造函数的定义方式是可以手写出来的,只不过这里的写法虽然是属于依赖注入了,但是过多繁琐的手动实例化会是研发人员的噩梦;特别是 Car 对象本身可能会依赖着不同的轮胎、不同的发动机的实例。
依赖注入的工具
依赖注入的的工具是 IoC 容器的一种,通过自动分析依赖,然后在工具内完成了本来手动执行的对象实例化的过程。
@Injectable()
class Student {
constructor(transportation: Transportation) {
this.transportation = transportation
}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()
如果是使用注解的方式,就可以写成这样:
@Injectable()
class Student {
@Inject()
private transportation: Transportation
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()
两者的区别在于对工具的依赖性,从构造函数定义的 class 即使用手动创建仍然能正常运行,但是以注解的方式定义的 class 就只能通过工具进行创建,无法通过手动创建。
依赖倒置
软件设计模式六大原则之一,依赖倒置原则,英文缩写 DIP,全称 Dependence Inversion Principle。
高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
在拥有 loC 容器的场景下,对象创建的控制不在我们手上,而是在工具或者框架内部创建,一个学生在上学时开的是宝马还是特斯拉,是由运行环境决定的。而在 JS 的实际运行环境中,遵循的更是鸭子模型,不管是不是交通工具,只要能开,什么车都可以。
所以我们可以把代码改成下面这样,依赖的是一个抽象,而不是某个具体的实现。
// src/transportations/car.ts
class Car {
drive() {
console.log('driving by car')
}
}
// src/students/student.ts
interface Drivable {
drive(): void
}
class Student {
constructor(transportation: Drivable) {
this.transportation = transportation
}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
为什么依赖抽象而不依赖实现那么重要呢?在复杂的架构里面,合理的抽象能够帮助我们在保持简洁,在领域边界内提高内聚,不同的边界降低耦合,指导项目进行合理的目录结构划分。在实现 SSR 和 CSR 的复合能力的时候,在客户端运行的时候,需要通过 HTTP 去请求数据,而在服务端,我们只需要直接调用 DB 或者 RPC 就能够获取数据。
我们可以抽象一下请求数据的对象,定义一个抽象的 Service,在客户端和服务端分别实现同样的函数,去请求数据:
interface IUserService {
getUserInfo(): Promise<{ name: string }>
}
class Page {
constructor(userService: IUserService) {
this. userService = userService
}
async render() {
const user = await this.userService. getUserInfo()
return `<h1> My name is ${user.name}. </h1>`
}
}
在网页上这么用:
class WebUserService implements IUserService {
async getUserInfo() {
return fetch('/api/users/me')
}
}
const userService = new WebUserService()
const page = new Page(userService)
page.render().then((html) => {
document.body.innerHTML = html
})
在服务端这么用:
class ServerUserService implements IUserService {
async getUserInfo() {
return db.query('...')
}
}
class HomeController {
async renderHome() {
const userService = new ServerUserService()
const page = new Page(userService)
ctx.body = await page.render()
}
}
可测试性
除了实现了软件工程里面最重要的高内聚低耦合,依赖注入还能够提高代码的可测试性。
一般的测试我们可能会这样写:
class Car extends Transportation {
drive() {
console.log('driving by car')
}
}
class Student {
this.transportation = new Car()
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
it('goto school successfully', async () => {
const driveStub = sinon.sub(Car.prototype, 'drive').resolve()
const student = new Student()
student. gotoSchool()
expect(driveStub.callCount).toBe(1)
})
这样的单元测试虽然能够正常运行,但是由于 Stub 的函数是入侵在 prototype 上面,这是一个全局的副作用影响,会让其他单元测试如果运行到这里受到影响。如果在测试结束的时候,清除了 sinon 的副作用,倒是不会影响串行的单元测试,但是就无法进行并行测试了。而使用了依赖注入的方法,就不会有这些问题了。
class Student {
constructor(transportation: Transportation) {
this.transportation = transportation
}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
it('goto school successfully', async () => {
const driveFn = sinon.fake()
const student = new Student({
{ drive: driveFn },
})
student.gotoSchool()
expect(driveFn.callCount).toBe(1)
})
循环依赖
在拥有循环依赖的情况下,一般我们无法将对象创建出来,比如下面的这两个类定义。虽然从逻辑上面需要避免这样的情况发生,但是很难说代码写的过程中不会完全用到这样的情况。
export class Foo {
constructor(public bar: Bar) {}
}
export class Bar {
constructor(public foo: Foo) {}
}
有了 DI 之后,通过 IoC 的创建控制反转,一开始创建对象的时候不会真正创建实例,而是给一个 proxy 对象,当这个对象真正被使用的时候才会创建实例,然后解决循环依赖的问题。至于为什么这里的 Lazy 装饰器是一定要存在的,等到我们后面实现的时候再解释。
@Injectable()
export class Foo {
constructor(public @Lazy(() => Bar) bar: Bar) {}
}
@Injectable()
export class Bar {
constructor(public @Lazy(() => Foo) foo: Foo) {}
}
一些缺点
当然,使用 DI 工具也不是完全没有坏处,比较明显的坏处包括:
- 无法控制的生命周期,因为对象的实例化在 IoC 里面,所以对象什么时候创建出来并不完全由当前程序说了算。所以这要求我们在使用工具或者框架之前,需要非常了解其中的原理,最好是读过里面的源码。
- 当依赖出错的时候,比较难定位到是哪个在出错。因为依赖是注入进来的,所以当依赖出错的时候只能通过经验去分析,或者现场 debug 住,一点点进行深入调试,才能知道是哪个地方的内容出错了。这对 Debug 能力,或者项目的整体把控能力要求很高。
- 代码无法连贯阅读。如果是依赖实现,你从入口一直往下,就能看到整个代码执行树;而如果是依赖抽象,具体的实现和实现之间的连接关系是分开的,通常需要文档才能够看到项目全貌。
社区的工具
从 github 的 DI 分类可以查看到一些流行的 DI 工具 https://github.com/topics/dependency-injection?l=typescript。
InversifyJS(https://github.com/inversify/InversifyJS): 能力强大的,依赖注入的工具,比较严格的依赖抽象的执行方式;虽然严格的申明很好,但是写起来很重复和啰嗦。
TSyringe(https://github.com/microsoft/tsyringe): 简单易用,继承自 angular 的抽象定义,比较值得学习。
实现基本的能力为了实现基本的 DI 工具的能力,去接管对象创建,实现依赖倒置和依赖注入,我们主要实现三个能力:
- 依赖分析:为了能够创建对象,需要让工具知道有那些依赖。
- 注册创建器:为了支持不同类型的实例创建方式,支持直接依赖、支持抽象依赖、支持工厂创建;不同的上下文注册不同的实现。
- 创建实例:利用创建器将实例创建出来,支持单例模式,多例模式。
假定我们的最终形态是这样的执行代码,如果要想要最终的结果的话,可以点击在线编码的链接 https://codesandbox.io/s/di-playground-oz2j9 。
@Injectable()
class Transportation {
drive() {
console.log('driving by transportation')
}
}
@Injectable()
class Student {
constructor(
private transportation: Transportation,
) {}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
依赖分析
为了能够让 DI 工具能够进行依赖分析,需要开启 TS 的装饰器功能,以及装饰器的元数据功能。
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Decorator
那么首先让我们来看一下构造函数的依赖是怎么分析出来的。开启了装饰器和元数据的功能之后,前面的代码尝试在 TS 的 playground 进行一次编译,能够看到运行的 JS 代码是这样的。
能够注意到比较关键的代码定义是这样的:
Student = __decorate([
Injectable(),
__metadata("design:paramtypes", [Transportation])
], Student);
仔细去阅读 __decorate 函数的逻辑的话,实际上就是一个高阶函数,为了倒序执行 ClassDecorator 和 Metadata 的 Decorator,翻译一下上面的代码,就等于:
Student = __metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)
然后我们再仔细阅读 __metadata 函数的逻辑,执行的是 Reflect 的函数,就等于代码:
Student = Reflect.metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)
反射
前面的代码结果,我们暂时可以不管第一行,来阅读一下第二行的含义,这里正是我们需要的依赖分析的能力。Reflect.metadata 是一个高阶函数,返回的是一个 decorator 函数,执行后将数据定义在构造函数上,可以通过 getMetadata 从这个构造函数或者其继承者都能找到定义的数据。
比如上面的反射,我们能够通过下面的方式拿到定义的数据:
const args = Reflect.getMetadata("design:paramtypes", Student)
expect(args).toEqual([Transportation])
反射元数据的提案:https://rbuckton.github.io/reflect-metadata/#syntax
开启了 emitDecoratorMetadata 之后,被装饰的地方,TS 会在编译的时候自动填充三种元数据:
- design:type 当前属性的类型元数据,出现在 PropertyDecorator 和 MethodDecorator;
- design:paramtypes 入参的元数据,出现在 ClassDecorator 和 MethodDecorator;
- design:returntype 返回类型元数据,出现在 MethodDecorator。
标记依赖
为了让 DI 工具能够收集并存储依赖,我们需要在 Injectable 中,将依赖的构造函数解析出来,然后也通过反射定义构造函数的方式,将数据描述通过一个 Symbol 值记录在反射中。
const DESIGN_TYPE_NAME = {
DesignType: "design:type",
ParamType: "design:paramtypes",
ReturnType: "design:returntype"
};
const DECORATOR_KEY = {
Injectable: Symbol.for("Injectable"),
};
export function Injectable<T>() {
return (target: new (...args: any[]) => T) => {
const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
const injectableOpts = { deps };
Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
};
}
这样做有两个目的:
- 通过内部的 Symbol 标记配置数据,表面这个构造函数是经过 Injectable 装饰的,可以被 IoC 创建出来。
- 采集并组装配置数据,定义在构造函数中,包括依赖的数据,后面可能会用到的比如单例多例的配置数据。
定义容器
有了装饰器在反射中定义的数据,可以就可以创建 IoC 中最重要的 Container 部分,我们就实现一个 resolve 函数,将实例自动创建出来:
const DECORATOR_KEY = {
Injectable: Symbol.for("Injectable"),
};
const ERROR_MSG = {
NO_INJECTABLE: "Constructor should be wrapped with decorator Injectable.",
}
export class ContainerV1 {
resolve<T>(target: ConstructorOf<T>): T {
const injectableOpts = this.parseInjectableOpts(target);
const args = injectableOpts.deps.map((dep) => this.resolve(dep));
return new target(...args);
}
private parseInjectableOpts(target: ConstructorOf<any>): InjectableOpts {
const ret = Reflect.getOwnMetadata(DECORATOR_KEY.Injectable, target);
if (!ret) {
throw new Error(ERROR_MSG.NO_INJECTABLE);
}
return ret;
}
}
主要的核心逻辑是下面几个:
- 从构造函数中解析 Injectable 装饰器通过反射定义的数据,如果没有的话,就抛出错误;稍微要注意一下的是,由于反射数据具备继承性,所以这里只能用 getOwnMetadata 取当前目标的反射数据,保证当前目标一定是被装饰过的。
- 然后通过依赖再递归创建出依赖的实例,得到构造函数的入参列表。
- 最后通过实例化构造函数,得到我们要的结果。
创建实例
到这里最基本的创建对象的功能就实现好了,下面这样的代码终于能够正常运行了。
@Injectable()
class Transportation {
drive() {
console.log('driving by transportation')
}
}
@Injectable()
class Student {
constructor(
private transportation: Transportation,
) {}
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
也可以通过访问 codesandbox,左边选择 ContainerV1 的模式,看到这样的结果。
依赖抽象那么基本的 IoC 我们就完成了,但是接下来我们要改变一下需求,希望能够在运行时候将交通工具替换成想要的任何工具,而 Student 的依赖仍然应该是一个可以开的交通工具。
接下来我们分两步实现:
- 实例替换:运行时把 Transportation 替换成 Bicycle。
- 依赖抽象:把 Transportation 从 class 变成 Interface。
在实现实例替换和依赖抽象的能力前,我们先得定义清楚依赖和依赖实现的关系,让 IoC 能够知道创建哪个实例去注入依赖,那就得先说一下 Token 和 Provier。
Token
作为依赖的唯一标记,可以是 String、Symbol、Constructor、或者 TokenFactory。在没有依赖抽象的情况下,其实就是不同的 Constructor 之间直接依赖;String 和 Symbol 是我们在依赖抽象之后会使用到的依赖 ID;而 TokenFactory 是实在想要进行文件循环引用的时候,用来进行解析依赖的方案。
我们可以先不管 TokenFactory, 其他的定义部分 Token 并不需要单独实现,只是一个类型定义:
export type Token<T = any> = string | symbol | ConstructorOf<T>;
Provider
注册到容器里面的和 Token 形成对应关系的实例创建定义,然后 IoC 在拿到 Token 之后,能够通过 Provider 创建出正确的实例对象。再细分一下,Provider 又可以分成三个类型:
- ClassProvider
- ValueProvider
- FactoryProvider
ClassProvider
使用构造函数来进行实例化的定义,一般我们前面实现的简单版本的例子,其实就是这个模式的简化版;再稍微改一下,就很容易实现这个版本,并且实现了 ClassProvider 之后,我们就能够通过注册 Provider 的方式去替换前面例子中的交通工具了。
interface ClassProvider<T = any> {
token: Token<T>
useClass: ConstructorOf<T>
}
ValueProvider
ValueProvider 在全局已经拥有一个唯一实现,但是在内部却定义了抽象依赖的情况下非常好用。举个简单的例子,在进行简洁架构的模式下,我们要求核心的代码逻辑是和上下文无关的,那么前端如果想要使用浏览器环境中的全局对象的时候,需要进行抽象定义,然后把这个对象通过 ValueProvider 传递进去。
interface ClassProvider<T = any> {
token: Token<T>
useValue: T
}
FactoryProvider
这个 Provider 会有一个工厂函数,然后去创建实例,当我们需要使用工厂模式的时候,就会非常有用。
interface FactoryProvider<T = any> {
token: Token<T>;
useFactory(c: ContainerInterface): T;
}
实现注册和创建
定义了 Token 和 Provider 之后,我们就可以通过他们实现一个注册函数,并将 Provider 和创建连接起来。逻辑也比较简单,重点就两个:
- 使用 Map 形成 Token 和 Provider 的映射关系,同时对 Provider 的实现进行去重,后注册的覆盖前面的。TSyringe 可以进行多次注册,如果构造函数依赖的是一个示例数组的话,就会依次对每次的 Provider 都创建一个实例;不同这种情况实际上用得很少,并且会让 Provider 实现的复杂增加很高,感兴趣的同学可以去研究它的这部分实现和定义方式。
- 通过解析不同类型的 Provider,然后去做不同的依赖的创建。
export class ContainerV2 implements ContainerInterface {
private providerMap = new Map<Token, Provider>();
resolve<T>(token: Token<T>): T {
const provider = this.providerMap.get(token);
if (provider) {
if (ProviderAssertion.isClassProvider(provider)) {
return this.resolveClassProvider(provider);
} else if (ProviderAssertion.isValueProvider(provider)) {
return this.resolveValueProvider(provider);
} else {
return this.resolveFactoryProvider(provider);
}
}
return this.resolveClassProvider({
token,
useClass: token
});
}
register(...providers: Provider[]) {
providers.forEach((p) => {
this.providerMap.set(p.token, p);
});
}
}
实例替换
实现了支持 Provider 注册的函数之后,我们就可以通过定义 Transportation 的 Provider 的方式,去替换学生上学时候的交通工具了。
const container = new ContainerV2();
container.register({
token: Transportation,
useClass: Bicycle
});
const student = container.resolve(Student);
return student.gotoSchool();
于是我们在 codesandbox 就能够看到下面的效果,终于可以骑车去上学了。
工厂模式
我们实现了依赖的替换,在实现依赖抽象之前,我们先插入一个新的需求,因为平时骑车上学实在是太辛苦了,所以周末路况比较好,希望能够开车上学。通过工厂模式,我们就能够使用下面的方式进行实现:
const container = new ContainerV2();
container.register({
token: Transportation,
useFactory: (c) => {
if (weekday > 5) {
return c.resolve(Car);
} else {
return c.resolve(Bicycle);
}
}
});
const student = container.resolve(Student);
return student.gotoSchool();
这里是简单的工厂模式介绍,TSyringe 和 InversifyJS 都有工厂模式的创建函数,这是比较推荐的方式;同时大家也可以在其他的的 DI 工具设计里面,有一些工具会把工厂函数的判断放到 class 申明的地方。
这样不是不可以,单个实现单个作用的时候写起来会更简单,但是这里就要说到我们引入 DI 的目的,为了解耦。工厂函数的逻辑判断其实是业务逻辑的一部分,本身不属于具体的实现所归属的领域;并且当实现被多个工厂逻辑中使用的时候,这个地方的逻辑就会变得很奇怪。
定义抽象
那么做完实例替换之后,我们来看看怎么让 Transportation 变成一个抽象,而不是一个具体的实现对象。那么首先第一步,就是需要把 Student 的依赖从具体的实现逻辑,变成一个抽象逻辑。
我们需要的是一个交通工具抽象,一个可以开的交通工具,自行车、摩托车、小汽车都可以;只要可以开,什么车都可以。然后再创建一个新的学生 class 继承一下旧的对象,用于区分和对比。
interface ITransportation {
drive(): string
}
@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
constructor(protected transportation: ITransportation) {
super(transportation);
}
}
如果这样写的话,会发现依赖解析出来会是错误的;因为在 TS 编译的时候,interface 是一个类型,运行时就会变成类型所对应的构造对象,无法正确解析依赖。
所以这里除了定义一个抽象类型,同时我们还需要为这个抽象类型定义一个唯一标记,也就是 Token 里面的 string 或者 symbol。我们一般会选择 symbol,这样全局唯一的值。这里可以利用 TS 同名在值和类型的多重定义,当作值和当作类型让 TS 自己去分析。
const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
drive(): string
}
@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
constructor(
protected @Inject(ITransportation) transportation: ITransportation,
) {
super(transportation);
}
}
替换抽象依赖
注意到的是,除了定义了抽象依赖的 Token 值,我们还需要加一个额外的装饰器,让这个标记构造函数的入参依赖,给它一个 Token 标记。
function Inject(token: Token) {
return (target: ConstructorOf<any>, key: string | symbol, index: number) => {
if (!Reflect.hasOwnMetadata(DECORATOR_KEY.Inject, target)) {
const tokenMap = new Map([[key, token]]);
Reflect.defineMetadata(DECORATOR_KEY.Inject, tokenMap, target);
} else {
const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
DECORATOR_KEY.Inject,
target
);
tokenMap.set(index, token);
}
};
}
同时在 Injectable 中的逻辑也需要改一下,把相应位置的依赖替换掉。
export function Injectable<T>(opts: InjectableDecoratorOpts = {}) {
return (target: new (...args: any[]) => T) => {
const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
DECORATOR_KEY.Inject,
target
);
if (tokenMap) {
for (const [index, token] of tokenMap.entries()) {
deps[index] = token;
}
}
const injectableOpts = {
...opts,
deps
};
Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
};
}
注册抽象的 Provider
到这里还剩下最后的一步,注入 Token 对应的 Provider 就可以使用了,我们只需要更改一下之前的 FactoryProvider 的 Token 定义,然后就达到了我们的目标了。
const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
drive(): string
}
const container = new ContainerV2();
container.register({
token: ITransportation,
useFactory: (c) => {
if (weekday > 5) {
return c.resolve(Car);
} else {
return c.resolve(Bicycle);
}
}
});
const student = container.resolve(StudentWithAbstraction);
return student.gotoSchool();
实现惰性创建
前面我们已经实现了基于构造函数的依赖注入的方式,这种方式很好,不影响构造函数正常的使用。但是这样有一个问题是,依赖树上面所有的对象实例都会在根对象被创建出来的时候,全部创建出来。这样子会有一些浪费,那些没有被使用到的实例原本是可以不创建出来的。
为了保证被创建的实例都是被使用的,那么我们选择使用时创建实例,而不是初始化根对象的时候。
定义使用方式
在这里我们需要更改一下 Inject 函数,使其能够同时支持构造函数的入参装饰和 Property 的装饰。
const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
drive(): string
}
@Injectable()
class Student {
@Inject(ITransportation)
private transportation: ITransportation
gotoSchool() {
console.log('go to school by')
this.transportation.drive()
}
}
const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
属性装饰器
我们结合 TS 编译的结果和类型定义,来看看 ParameterDecorator 和 PropertyDecorator 的特征。
下面是 .d.ts 中的描述
下面是编译的结果
可以看到的是有以下几个区别:
- 入参个数是不一样的,ParameterDecorator 因为会有第几个参数的数据。
- 描述对象是不一样的,构造函数的 ParameterDecorator 描述的是构造函数;而 PropertyDecorator 描述的是构造函数的 Prototype。
于是通过识别标记,然后返回 property 的描述文件,在 Prototype 上面添加了对应属性的 getter 函数,实现了使用时进行对象创建的逻辑。
function decorateProperty(_1: object, _2: string | symbol, token: Token) {
const valueKey = Symbol.for("PropertyValue");
const ret: PropertyDescriptor = {
get(this: any) {
if (!this.hasOwnProperty(valueKey)) {
const container: IContainer = this[REFLECT_KEY.Container];
const instance = container.resolve(token);
this[valueKey] = instance;
}
return this[valueKey];
}
};
return ret;
}
export function Inject(token: Token): any {
return (
target: ConstructorOf<any> | object,
key: string | symbol,
index?: number
) => {
if (typeof index !== "number" || typeof target === "object") {
return decorateProperty(target, key, token);
} else {
return decorateConstructorParameter(target, index, token);
}
};
}
这里可以稍微注意一点的是,TS 本身的描述的设计里面是不推荐返回 PropertyDescriptor 去更改属性的定义,但是实际上在标准和 TS 的实现里面,他其实是做了这个事情的,所以这里未来也许会发生变化。
循环依赖
做完惰性创建,我们来说一个有一点点关系的问题,循环依赖。一般来说,我们应该从逻辑中避免循环依赖,但是如果不得不使用的时候,还是需要提供解决方案来解决循环依赖。
比如这样一个例子:
@Injectable()
class Son {
@Inject()
father: Father
name = 'Thrall'
getDescription() {
return `I am ${this.name}, son of ${this.father.name}.`
}
}
@Injectable()
class Father {
@Inject()
son: Son
name = 'Durotan'
getDescription() {
return `I am ${this.name}, my son is ${this.son.name}.`
}
}
const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())
为什么会出问题
出问题的原因是因为装饰器的运行时机。构造函数装饰器的目的是描述构造函数,也就是当构造函数被申明出来之后,紧接着就会运行装饰器的逻辑,而此时它的依赖还没有被申明出来,取到的值还是 undefined。
文件循环
除了文件内循环,还有文件之间的循环,比如下面的这个例子。
会发生下面的事情:
- Father 文件被 Node 读取;
- Father 文件在 Node 会初始化一个 module 注册在总的 modules 里面;但是 exports 还是一个空对象,等待执行赋值;
- Father 文件开始执行第一行,引用 Son 的结果;
- 开始读取 Son 文件;
- Son 文件在 Node 会初始化一个 module 注册在总的 modules 里面;但是 exports 还是一个空对象,等待执行赋值;
- Son 文件执行第一行,引用 Father 的结果,然后读取到 Father 注册的空 module;
- Son 开始申明构造函数;然后读取 Father 的构造函数,但是此时是 undefined,执行装饰器逻辑;
- Son 的 Module 赋值 exports 并结束执行;
- Father 读取到 Son 的构造函数之后,开始申明构造函数;正确读取 Son 的构造函数执行装饰器逻辑。
打破循环
当发生循环依赖的时候,第一个思路应该是打破循环;让依赖变成没有循环的抽象逻辑,打破执行之间的先后问题。
export const IFather = Symbol.for("IFather");
export const ISon = Symbol.for("ISon");
export interface IPerson {
name: string;
}
@Injectable()
export class FatherWithAbstraction {
@Inject(ISon)
son!: IPerson;
name = "Durotan";
getDescription() {
return `I am ${this.name}, my son is ${this.son.name}.`;
}
}
@Injectable()
export class SonWithAbstraction {
@Inject(IFather)
father!: IPerson;
name = "Thrall";
getDescription() {
return `I am ${this.name}, son of ${this.father.name}.`;
}
}
const container = new ContainerV2(
{ token: IFather, useClass: FatherWithAbstraction },
{ token: ISon, useClass: SonWithAbstraction }
);
const father = container.resolve(FatherWithAbstraction);
const son = container.resolve(SonWithAbstraction);
console.log(father.getDescription())
console.log(son.getDescription())
通过定义公共的 Person 抽象,让 getDescription 函数能够正常执行;通过提供 ISon 和 IFather 的 Provider,提供了各自依赖的具体实现,逻辑代码就能够正常运行了。
惰性依赖
除了依赖抽象以外,如果实在是需要进行循环依赖,我们仍然能够通过技术手段解决这个问题,那就是让依赖的解析在构造函数定义之后能够执行,而不是和构造函数申明时执行。此时只需要一个简单的手段,使用函数执行,这就是我们前面说到的 Lazy 逻辑了。
因为 JS 的作用域内变量提升,在函数中是能持有变量引用的,只要保证函数在执行的时候,变量已经赋值过了,就能够正确解析依赖了。
@Injectable()
class Son {
@LazyInject(() => Father)
father: Father
name = 'Thrall'
getDescription() {
return `I am ${this.name}, son of ${this.father.name}.`
}
}
@Injectable()
class Father {
@LazyInject(() => Son)
son: Son
name = 'Durotan'
getDescription() {
return `I am ${this.name}, my son is ${this.son.name}.`
}
}
const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())
TokenFactory
我们需要做的,是增加一个新的 Token 解析方式,能够使用函数动态获取依赖。
interface TokenFactory<T = any> {
getToken(): Token<T>;
}
然后增加一个 LazyInject 的装饰器,并兼容这个逻辑。
export function LazyInject(tokenFn: () => Token): any {
return (
target: ConstructorOf<any> | object,
key: string | symbol,
index?: number
) => {
if (typeof index !== "number" || typeof target === "object") {
return decorateProperty(target, key, { getToken: tokenFn });
} else {
return decorateConstructorParameter(target, index, { getToken: tokenFn });
}
};
}
最后在 Container 中兼容一下这个逻辑,写一个 V3 的版本 Container。
export class ContainerV3 extends ContainerV2 implements IContainer {
resolve<T>(tokenOrFactory: Token<T> | TokenFactory<T>): T {
const token =
typeof tokenOrFactory === "object"
? tokenOrFactory.getToken()
: tokenOrFactory;
return super.resolve(token);
}
}
最后看一下使用效果:
const container = new ContainerV3();
const father = container.resolve(FatherWithLazy);
const son = container.resolve(SonWithLazy);
father.getDescription();
son.getDescription();
最后
到这里基本上实现了一个基本可用的 DI 工具,稍微回顾一下我们的内容:
- 使用反射和 TS 的装饰器逻辑,我们实现了依赖的解析和对象创建;
- 通过 Provider 定义,实现了实例替换、依赖抽象、工厂模式;
- 通过使用 PropertyDecorator 定义 getter 函数,我们实现了惰性创建;
- 通过 TokenFactory 动态获取依赖,我们解决了循环依赖。