乐趣区

关于typescript:如何基于-TypeScript-实现控制反转

图片起源:https://bz.zzzmh.cn/
本文作者:陈光通

一. 前言

最近接到工作,须要给团队封装一个基于 EggJS 的下层 NodeJS 框架,在这个过程中参考了 NestJS、Midway 等开源我的项目,发现它们都引入了一项重要个性 —— IoC,于是笔者借这个机会对 IoC 进行了一些学习和梳理。本文次要参考了 Midway 的源码,按本人的了解基于 TypeScript 实现了 IoC,心愿能给大家提供一些参考。

二. IoC

依照维基百科,IoC(Inversion of Control)管制反转,是面向对象编程中的一种设计准则,用来升高计算机代码之间的耦合度。
在传统面向对象的编码过程中,当类与类之间存在依赖关系时,通常会间接在类的外部创立依赖对象,这样就导致类与类之间造成了耦合,依赖关系越简单,耦合水平就会越高,而耦合度高的代码会十分难以进行批改和单元测试。而 IoC 则是专门提供一个容器进行依赖对象的创立和查找,将对依赖对象的控制权由类外部交到容器这里,这样就实现了类与类的解耦,保障所有的类都是能够灵便批改。

2.1 耦合

间接看维基百科对 IoC 的解释可能会感觉云里雾里,到底什么是耦合呢?在这里咱们举一个简略的例子,假如咱们有 AB 两个类,它们之间存在的依赖关系是 A 依赖 B,这种依赖关系在日常开发中很容易遇到,如果用传统的编码方式,咱们个别会这么实现:

// b.ts
class B {constructor() {}}
// a.ts
class A {
 b:B;
 constructor() {this.b = new B();
 }
}
// main.ts
const a = new A();

上述代码看上去仿佛没有什么问题,然而,这时咱们忽然接到了新需要,处于最底层的 B 在初始化对象的时候须要传递一个参数 p

// b.ts
class B {
 p: number;
 constructor(p: number) {this.p = p;}
}

批改完后,问题来了,因为 B 是在 A 的构造函数中进行实例化的,咱们不得不在 A 的构造函数里传入这个 p,然而 A 外面的 p 怎么来呢?咱们当然不能写死它,否则设定这个参数就没有意义了,因而咱们只能将 p 也设定为 A 构造函数中的一个参数,如下:

// a.ts
class A {
 b:B;
 constructor(p: number) {this.b = new B(p);
 }
}
// main.ts
const a = new A(10);
console.log(a); // => A {b: B { p: 10} }

更麻烦的是,当咱们改完了 A 后,发现 B 所须要的 p 不能是一个 number,须要变更为 string,于是咱们又不得不从新批改 A 中对参数 p 的类型润饰。这时咱们想想,假如还有下层类依赖 A,用的也是同样的形式,那是否下层类也要经验同样的批改。这就是耦合所带来的问题,明明是批改底层类的一项参数,却须要批改其依赖链路上的所有文件,当应用程序的依赖关系简单到肯定水平时,很容易造成牵一发而动全身的景象,为应用程序的保护带来极大的艰难。

2.2 解耦

事实上,咱们能够发现,在上述例子中,真正须要参数 p 的仅仅只有 B,而 A 齐全只是因为外部依赖的对象在实例化时须要 p,才不得不定义这个参数,实际上它对 p 是什么基本不关怀。于是,咱们能够思考将类所依赖对象的实例化从类自身剥离进去,比方下面的例子咱们能够这样改写:

// b.ts
class B {
 p: number;
 constructor(p: number) {this.p = p;}
}
// a.ts
class A {
 private b:B;
 constructor(b: B) {this.b = b;}
}
// main.ts
const b = new B(10);
const a = new A(b);
console.log(a); // A => {b: B { p: 10} }

在上述例子中,A 不再接管参数 p,而是抉择间接接管其外部所依赖的对象,至于这个对象在哪里进行实例化则并不关怀,这样无效解决了咱们在下面遇到的问题,当咱们须要批改参数 p 时,咱们仅仅只有批改 B 即可,而不须要批改 A,这个过程中咱们就实现了类与类之间的解耦。

2.3 容器

尽管咱们实现理解耦,但咱们仍须要本人初始化所有的类,并以结构函数参数的模式进行传递。如果存在一个全局的容器,外面 事后注册 好了咱们所需对象的类定义以及初始化参数,每个对象有一个惟一的 key。那么当咱们须要用到某个对象时,咱们只须要通知容器它对应的 key,就能够间接从容器中 取出 实例化好的对象,开发者就不必再关怀对象的实例化过程,也不须要将依赖对象作为构造函数的参数在依赖链路上传递。
也就是说,咱们的容器必须具体两个性能,实例的注册 获取,这很容易让人联想到 Map,基于这个思路,咱们首先简略实现一个容器:

// container.ts
export class Container {bindMap = new Map();
 // 实例的注册
 bind(identifier: string, clazz: any, constructorArgs: Array<any>) {
 this.bindMap.set(identifier, {
 clazz,
 constructorArgs
 });
 }
 // 实例的获取
 get<T>(identifier: string): T {const target = this.bindMap.get(identifier);
 const {clazz, constructorArgs} = target;
 const inst = Reflect.construct(clazz, constructorArgs);
 }
}

这里咱们用到了 Reflect.construct,它的行为有点像 new 操作符,帮忙咱们进行对象的实例化。有了容器之后,咱们就能够彻底摈弃传参实现解耦,如下所示:

// b.ts
class B {constructor(p: number) {this.p = p;}
}
// a.ts
class A {
 b:B;
 constructor() {this.b = container.get('b');
 }
}
// main.ts
const container = new Container();
container.bind('a', A);
container.bind('b', B, [10]);
// 从容器中取出 a
const a = container.get('a');
console.log(a); // A => {b: B { p: 10} }

到这里为止,咱们其实曾经根本实现了 IoC,基于容器实现了类与类的解耦。但从代码量上看仿佛并没有简洁多少,关键问题在于容器的初始化以及类的注册依然让咱们感觉繁琐,如果这部分代码能被封装到框架外面,所有类的注册都可能主动进行,同时,所有类在实例化的时候能够间接拿到依赖对象的实例,而不必在构造函数中手动指定,这样就能够彻底解放开发者的双手,专一编写类外部的逻辑,而这也就是所谓的 DI(Dependency Injection)依赖注入。

三. DI

很多人会混同 DI 和 IoC,IoC 只是一种设计准则,而 DI 则是实现 IoC 的一种实现技术,简略来说就是咱们能够将依赖注入给调用方,而不须要调用方来被动获取依赖。为了实现 DI,次要要解决以下两个问题:

  • 须要注册到 IoC 容器中的类可能在程序启动时主动进行注册
  • 在 IoC 容器中的类实例化时能够间接拿到依赖对象的实例,而不必在构造函数中手动指定

针对这两个问题其实也有不同的解决思路,比方赫赫有名的 Java Spring 须要开发者针对容器中的依赖关系定义一份 XML 文件,框架基于这份 XML 文件实例的注册和依赖的注入。但对于前端开发来说,基于 XML 的依赖治理显得太过繁琐,Midway 的 Injection 提供的思路是利用 TypeScript 具备的装璜器个性,通过对元数据的润饰来辨认出须要进行注册以及注入的依赖,从而实现依赖的注入。

3.1 Reflect Metadata

要应用装璜器解决上述提到的两个问题,咱们须要先简略理解下 Reflect Metadata。Reflect Metadata 是 ES7 的一个提案,它次要用来在申明的时候增加和读取元数据,TypeScript 在 1.5+ 的版本曾经反对它。
元数据能够了解为针对类或类外面某个属性的形容信息,它自身不影响类的行为,但你能够在随时拿到某个类上定义的元数据,并依据这些元数据进行对类进行特定的操作。
Reflect Metadata 的应用非常简单,首先,你须要装置 reflect-metadata 库:

npm i reflect-metadata --save

在 tsconfig.json 里,emitDecoratorMetadata 须要配置为 true
而后,咱们就能够依据 Reflect.defineMetadata 和 Reflect.getMetadata 进行元数据的定义和获取,比方:

import 'reflect-metadata';
const CLASS_KEY = 'ioc:key';
function ClassDecorator() {return function (target: any) {
 Reflect.defineMetadata(CLASS_KEY, {metaData: 'metaData',}, target);
 return target;
 };
}
@ClassDecorator()
class D {constructor(){}}
console.log(Reflect.getMetadata(CLASS_KEY, D)); // => {metaData: 'metaData'}

有了 Reflect,咱们就能够对任意类进行标记,并对标记的类进行非凡的解决。更多无关元数据的内容能够参考 Reflect Metadata。

3.2 Provider

回到咱们刚刚提到的问题,咱们须要在利用启动的时候主动对所有类进行定义和参数的注册,问题是并不是所有的类都须要注册到容器中,咱们并不分明哪些类须要注册的,同时也不分明须要注册的类,它的初始化参数是什么样的。
这里就能够引入元数据来解决这个问题,只有在定义的时候为这个类的元数据增加非凡的标记,就能够在扫描的时候辨认进去。依照这个思路,咱们先来实现一个装璜器标记须要注册的类,这个装璜器能够命名 Provider,代表它将会作为提供者给其余类进行生产。

// provider.ts
import 'reflect-metadata'
export const CLASS_KEY = 'ioc:tagged_class';
export function Provider(identifier: string, args?: Array<any>) {return function (target: any) {
 Reflect.defineMetadata(CLASS_KEY, {
 id: identifier,
 args: args || []}, target);
 return target;
 };
}

能够看到,这里的标记蕴含了 idargs,其中 id 是咱们筹备用来注册 IoC 容器的 key,而 args 则是实例初始化时须要的参数。Provider 能够以装璜器的模式间接进行应用,应用形式如下:

// b.ts
import {Provider} from 'provider';
@Provider('b', [10])
export class B {constructor(p: number) {this.p = p;}
}

标记实现后,问题又来了,如果在利用启动的时候拿到这些类的定义呢?
比拟容易想到的思路是在启动的时候对所有文件进行扫描,获取每个文件导出的类,而后依据元数据进行绑定。简略起见,咱们假如我的项目目录只有一级文件,实现如下:

// load.ts
import * as fs from 'fs';
import {CLASS_KEY} from './provider';
export function load(container) { // container 为全局的 IoC 容器
 const list = fs.readdirSync('./');
 for (const file of list) {if (/.ts$/.test(file)) { // 扫描 ts 文件
 const exports = require(`./${file}`);
 for (const m in exports) {const module = exports[m];
 if (typeof module === 'function') {const metadata = Reflect.getMetadata(CLASS_KEY, module);
 // 注册实例
 if (metadata) {container.bind(metadata.id, module, metadata.args)
 }
 }
 }
 }
 }
}

那么当初,咱们只有在 main 中运行 load 即可实现我的项目目录下所有被润饰的类的绑定工作,值得注意的是,load 和 Container 的逻辑是齐全通用的,它们齐全能够被封装成包,一个简化的 IoC 框架就成型了。

import {Container} from './container';
import {load} from './load';
// 初始化 IOC 容器,扫描文件
const container = new Container();
load(container);
console.log(container.get('a')); // A => {b: B { p: 10} }

3.2 Inject

解决注册的问题后,咱们来看上文中提到的第二个问题:如何在类初始化的时候能间接拿到它所依赖的对象的实例,而不须要手动通过构造函数进行传参。其实思路也很简略,咱们曾经将所有须要注册的类都放入了 IoC 容器,那么,当咱们须要用到某个类时,在获取这个类的实例时能够递归遍历类上的属性,并从 IoC 容器中取出相应的对象并进行赋值,即可实现依赖的注入。
那么,又是相似的问题,如何辨别哪些属性须要注入?同样,咱们能够应用元数据来解决。只有定义一个装璜器,以此来标记哪些属性须要注入即可,这个装璜器命名为 Inject,代表该属性须要注入依赖。

// inject.ts
import 'reflect-metadata';
export const PROPS_KEY = 'ioc:inject_props';
export function Inject() {return function (target: any, targetKey: string) {
 const annotationTarget = target.constructor;
 let props = {};
 if (Reflect.hasOwnMetadata(PROPS_KEY, annotationTarget)) {props = Reflect.getMetadata(PROPS_KEY, annotationTarget);
 }
 props[targetKey] = {value: targetKey};
 Reflect.defineMetadata(PROPS_KEY, props, annotationTarget);
 };
}

须要留神的是,这里咱们尽管是对属性进行润饰,但理论元数据是要定义在类上,以保护该类须要注入的属性列表,因而咱们必须取 target.constructor 作为要操作的 target。另外,为了不便起见,这里间接用了属性名(targetKey)作为从 IoC 容器中实例对应的 key。
而后,咱们须要批改 IoC 容器的 get 办法,递归注入所有属性:

// container.ts
import {PROPS_KEY} from './inject';
export class Container {bindMap = new Map();
 bind(identifier: string, clazz: any, constructorArgs?: Array<any>) {
 this.bindMap.set(identifier, {
 clazz,
 constructorArgs: constructorArgs || []});
 }
 get<T>(identifier: string): T {const target = this.bindMap.get(identifier);
 const {clazz, constructorArgs} = target;
 const props = Reflect.getMetadata(PROPS_KEY, clazz);
 const inst = Reflect.construct(clazz, constructorArgs);
 for (let prop in props) {const identifier = props[prop].value;
 // 递归获取注入的对象
 inst[prop] = this.get(identifier);
 }
 return inst;
 }
}

应用的时候,用 Inject 对须要的属性进行润饰即可:

// a.ts
import {Provider} from 'provider';
@Provider('a')
export class A {@Inject()
 b: B;
}

3.3 最终代码

通过上述调整后,最终咱们的业务代码成了这样:

// b.ts
@Proivder('b', [10])
class B {constructor(p: number) {this.p = p;}
}
// a.ts
@Proivder('a')
class A {@Inject()
 private b:B;
}
// main.ts
const container = new Container();
load(container);
console.log(container.get('a'));  // => A {b: B { p: 10} }

能够看到,代码中不会再有手动进行实例化的状况,无论要注册多少个类,框架层都能够主动解决好所有,并在这些类实例化的时候注入须要的属性。所有类可提供的实例都由类本身来保护,即便存在批改也不须要改变其余文件。

小结

本文从类的解耦开始形容了 IoC 的必要性,基于 TypeScript 实现了一个精简的 IoC 框架。事实上,除理解耦外,IoC 还能够给咱们带来很多益处,比方基于容器疾速进行单元测试,剖析类与类之间的依赖关系等等。
尽管 IoC 最后是服务端提出的概念,但目前在前端畛域也曾经有了各种各样的利用,比方 AngularJS 就实现了本人的 IoC 框架来晋升开发效率和模块化水平,有趣味的读者能够通过官网的案例感受一下 IoC 给前端编码带来的益处。置信随着前端职能的扩张,利用复杂度的晋升,这些经典的设计准则正在逐步成为每个前端开发的必修课程。

参考资料

  • 深刻了解 TypeScript
  • 管制反转 - 维基百科
  • MDN
  • Midway
  • AngularJS DI

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!

退出移动版