「这是我参与2022首次更文挑战的第16天,活动详情查看:2022首次更文挑战」。
IoC
控制反转(Inversion of Control)简称为IoC。是一种为面向对象编程范式设计的设计模式,它经常被用于解耦各个类之间的关系。
耦合
什么是耦合?我们通过一个简单的例子来说明。假设我们具有两个类, A 与 B,他们的依赖关系为:A依赖于B,表示为: 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.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类中的类型声明。另一个更加令人崩溃的问题是:如果有更多的类依赖于类A的话,我们不得不以同样的方式修改这些依赖了类A的其他类!这就是由耦合带来的问题,由于被依赖项的一个小小的修改而要去修改其他的类。当应用的依赖变得越来越复杂时,就越容易牵一发而动全身,这为我们后期维护造成了巨大的困难。
解耦
事实上,在上述例子中我们发现,仅仅只有类B需要参数p,而类A中除了实例化B时几乎不会使用参数p,所以我们可以考虑将实例化类B的任务移到类A以外的地方。我们可以将代码重写为以下形式:
// 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了。我们将这两个类解耦开了。
容器
即时我们通过上面的方法解耦了对象,但是我们还是得手动的实例化所有的对象并且为他们传入相应的参数。如果这里存在一个全局容器,并且每个对象都具有唯一键值再将这些对象“预注册”到其中,那么后续我们可以通过该容器和之前设置的键值直接获取到实例化的对象。这样,开发者就不再需要关系对象是如何被实例化的,也需要为他们的构造函数传入相应的参数。
换句话说,我们的容器需要完成两项功能:注册实例和获取实例。这让人很容易联想到使用Map来完成这项任务。我们简单的实现一些这个容器:
// container.ts
export class Container {
bindMap = new Map();
// Registering the instances
bind(identifier: string, clazz: any, constructorArgs: Array<any>) {
this.bindMap.set(identifier, {clazz, constructorArgs});
}
// get the instances
get<T>(identifier: string) : T {
const target = this.bindMap.get(identifier);
const { clazz, constructorArgs } = target;
const inst = Reflect.construct(clazz, constructorArgs);
}
}
这里我们使用了 Reflect.construct 这个API,它与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]);
// get a from container
const a = container.get('a');
console.log(a); // A => {b: B { p: 10}}
到此,我们实际上就已经实现了一个最简单的IoC容器了,并使用这个容器实现了类之间的解耦。但是从代码量来看的话,代码并没有比之前的看起来简洁。相反,容器的初始化和类注册让我们感觉代码更冗长了。如果这部分的代码能够被封装进框架中并且所有的类能够自动注册,并且所有的类能够在类实例化的时候获取到其依赖,那么开发者就完全从类的实例化中被解放了,开发者能够更加的专注于类的逻辑本身。这就是DI(Dependency Injection)所存在的意义。
DI (Dependency Injection)
大部分人都没办法说清楚DI与 IoC 之间的不同,对于作者来说也同样如此。 IoC仅仅是一种编程范式,DI是IoC 的一种具体实现,我们可以往调用者中注入依赖,不需要显式的为调用者增加依赖。为了实现DI,我们需要解决2个问题:
- 类需要注册在 IoC 容器中,他们需要在程序启动时自动注册所有的类。
- 当 IoC 容器中的类被实例化时,其依赖的对象能够被自动获取而不需要我们手动在构造器中去指定。
上述的两个问题有许多不同的解决方案,例如,在Java Spring中需要开发者定义XML文件来描述依赖之间的关系,Spring框架就可以根据依赖关系完成依赖注入。但是基于XML定义的依赖管理过于麻烦,Midway采用了装饰器的解决方案,通过为依赖设置元数据来完成依赖注入。
Reflect Metadata
为了使用装饰器里解决这两个问题,我们需要了解一下Reflect Metadata的基本知识。Reflect Metadata是 ES7的提案,并且被TypeScript1.5+版本所支持。
元数据可以被看出是对类或者是类中成员的补充说明信息,他们不会影响到类本身的行为,但是你可以获取到类中的元数据并且根据元数据为其赋予不同的行为。
Reflect Metadata的使用相当简单,首先我们先安装 reflect-metadata这个包
npm i reflect-metadata --save
然后,需要在 tsconfig.json 中将 emitDecoratorMetadata 这个配置项设置为 true。
这样我们就可以在文件中使用 Reflect.defineMetdata 和 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,我们可以将任意类用作我们的token, 并且为其增加不同的行为。
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;
};
}
我们可以看到 id 和 args 被添加到了 token 中,其中 id 和 key 被用于在 IoC 容器中注册, 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) { // The container is the global IoC container
const list = fs.readdirSync('./');
for (const file of list) {
if (/.ts$/.test(file)) {
const exports = require(`./${file}`);
for (const m in exports) {
const module = exports[m];
if (typeof module === 'function') {
const metadata = Reflect.getMetadata(CLASS_KEY, module);
// register
if (metadata) {
container.bind(metadata.id, module, metadata.args);
}
}
}
}
}
}
现在我们完成了类的注册工作。
import {Container} from './container';
import {load} from './load';
const container = new Container();
load(container);
console.log(container.get('a')); // A => {b: B {p: 10}}
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.construct 来作为 target 来进行后续的操作。因此我们需要修改 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;
// get injected object recursively
inst[prop] = this.get(identier);
}
return inst;
}
}
}
我们可以直接使用 Inject 装饰器来装饰需要被注入的属性。
// a.ts
import {Provider} from 'provider';
@Provider('a')
export class A {
@Inject()
b: B;
}
最终的代码:
// b.ts
@Provider('b', [10])
class B {
constructor(p: number) {
this.p = p;
}
}
// a.ts
@Provider('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 的概念是从服务端兴起,但是现在在前端也有很多应用使用了这一思想,比如 Angular 实现了自己的IoC框架来提高开发效率。