IOC与DI,Decorator,reflect Matedata

IOC与DI,Decorator,reflect Matedata

十月 10, 2021

内容来自互联网, 仅用于个人学习记忆

IoC

IoC(Inversion of Control)控制反转,是面向对象编程中的一种设计原则,用来降低计算机代码之间的耦合度

在传统面向对象的编码过程中,当类与类之间存在依赖关系时,通常会直接在类的内部创建依赖对象,这样就导致类与类之间形成了耦合,依赖关系越复杂,耦合程度就会越高,而耦合度高的代码会非常难以进行修改和单元测试。而 IoC 则是专门提供一个容器进行依赖对象的创建和查找,将对依赖对象的控制权由类内部交到容器这里,这样就实现了类与类的解耦,保证所有的类都是可以灵活修改

耦合

在不使用 IoC 的情况下,我们很容易写出来这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// modB.ts
class B {
p: number;
constructor(p) {
this.p = p;
}
}

// main.ts
import { A } from "./modA";
import { B } from "./modB";

class C {
a: A;
b: B;
constructor() {
this.a = new A();
this.b = new B("b");
}
}

可以看到C类需要依赖A,B类 与其耦合, 当随着项目的迭代, 依赖关系发生变化,或依赖项A,B初始化参数变化时, 这种依赖关系将变得难以维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { A } from "./modA";
import { B } from "./modB";

class C {
a: A;
b: B;
constructor(a, b) {
this.a = a;
this.b = b;
}
}

let a = new A();
let b = new B("what ever");
let c = new C(a, b);

实际上对于C类型实例化而言,并不在乎B实例化的参数, 稍加修改只需要通过A,B的实例就可进行构造, 这样就能体现出类与类之间的解耦

容器

虽然我们实现了解耦,但我们仍需要自己初始化所有的类,并以构造函数参数的形式进行传递。如果存在一个全局的容器,里面预先注册好了我们所需对象的类定义以及初始化参数,每个对象有一个唯一的 key。那么当我们需要用到某个对象时,我们只需要告诉容器它对应的 key,就可以直接从容器中取出实例化好的对象,开发者就不用再关心对象的实例化过程,也不需要将依赖对象作为构造函数的参数在依赖链路上传递, 也就是说,我们的容器必须具体两个功能,实例的注册和获取

即我们可以构造一个容器,维护未来需要使用的类及其对照关系,当需要具体类型的实例时, 通过对照关系可以找到对应的类, 并在容器中直接初始化

再举个栗子,当我们想要处对象时,会上 Soul、Summer、陌陌…等等去一个个找,找哪种的与怎么找是由我自己决定的,这叫 控制正转。现在我觉得有点麻烦,直接把自己的介绍上传到世纪佳缘,如果有人看上我了,就会主动向我发起聊天,这叫 控制反转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { A } from "./modA";
import { B } from "./modB";

class C {
a: A;
b: B;
constructor() {
// 手动从容器中获取实例
this.a = container.get("a");
this.b = container.get("b");
}
}

const container = new Container();
// 手动向容器注册类型
container.bind("a", A);
container.bind("b", B, ["what ever"]);
container.bind("c", C);

// 从容器中取出c
const c = container.get("c");

到此为止, 虽然容器完成了类与类的解耦, 但容器的初始化及各类型的注册看上去还是很繁琐的, 如果这部分可以通过封装进框架自动进行, 并且在类实例化时无需手动指定, 而可以直接自动拿到所需实例, 这样就可以更专注于内部逻辑的开发, 及实现依赖注入

DI(Dependency Injection)依赖注入

IoC 是一种思想, 而 DI 是其一种技术实现, 即将依赖注入给调用方,而无需调用发主动获取, 需要解决一下问题

  • 程序启动时,所有类型会在容器中自动注册
  • 实例化时类型所需的对象的自动注入, 无需在构造函数中指定

而这些即可通过 TS装饰器 结合 Reflect Metadata实现

TS Decorator

首先在tsconfig.json中设置experimentalDecoratorsemitDecoratorMetadatatrue

类装饰器
1
2
3
4
5
6
7
8
9
10
11
12
function addProp(constructor: Function) {
constructor.prototype.sayhi = "hi";
}

@addProp
class P {
constructor() {}
}

let p = new P();

console.log(p.sayhi); // hi

对于类装饰器来说 入参为 此 Class 即 构造函数

方法装饰器

方法装饰器的入参为 类的原型对象 属性名 以及 **属性描述符(descriptor)**,其属性描述符包含writable enumerable configurable value ,我们可以在这里去配置其相关信息 类似Object.defineProperty()

  1. Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
  2. The name of the member.
  3. The Property Descriptor for the member.

注意,对于静态成员来说,首个参数会是类的构造函数。而对于实例成员(比如下面的例子),则是类的原型对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function addProps(): MethodDecorator {
return (target, propertyKey, descriptor) => {
// A.prototype
console.log(target);
console.log(propertyKey);
console.log(JSON.stringify(descriptor));
};
}

class A {
@addProps()
sayhi() {
console.log("hi~");
}
}
属性装饰器

类似于方法装饰器,但它的入参少了属性描述符。原因则是目前没有方法在定义原型对象成员同时去描述一个实例的属性(创建描述符)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addProp(target, propertyKey) {
// A.prototype
console.log(target);
// 装饰的属性
console.log(propertyKey);
}

class A {
@addProp
public name: string;
constructor(name: string) {
this.name = name;
}
}
参数装饰器

参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数中的索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function paramDeco(params?: any): ParameterDecorator {
return (target, propertyKey, index) => {
console.log(target);
console.log(propertyKey);
console.log(index);
target.constructor.prototype.fromParamDeco = "呀呼!";
};
}

class B {
someMethod(@paramDeco() param: any) {
console.log(`${param}`);
}
}

new B().someMethod("okok");
// @ts-ignore
console.log(B.prototype.fromParamDeco);
多个装饰器声明

As such, the following steps are performed when evaluating multiple decorators on a single declaration in TypeScript:

  1. The expressions for each decorator are evaluated top-to-bottom.
  2. The results are then called as functions from bottom-to-top.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function first() {
console.log("first(): factory evaluated");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log("first(): called");
};
}

function second() {
console.log("second(): factory evaluated");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log("second(): called");
};
}

class ExampleClass {
@first()
@second()
method() {}
}

Which would print this output to the console:

1
2
3
4
first(): factory evaluated
second(): factory evaluated
second(): called
first(): called

There is a well defined order to how decorators applied to various declarations inside of a class are applied:

  1. Parameter Decorators(参数装饰器), followed by Method(方法装饰器), Accessor(如属性 getter 访问符装饰器), or Property Decorators(属性装饰器) are applied for each instance member.
  2. Parameter Decorators, followed by Method, Accessor, or Property Decorators are applied for each static member.
  3. Parameter Decorators are applied for the constructor.
  4. Class Decorators are applied for the class.

Reflect Metadata

Reflect Metadata 是属于 ES7 的一个提案,其主要作用是在声明时去读写元数据

为类或类属性添加了元数据后,构造函数的原型(或是构造函数,根据静态成员还是实例成员决定)会具有[[Metadata]]属性,该属性内部包含一个Map结构,键为属性键,值为元数据键值对。

metadata 方法
1
2
3
4
5
6
7
8
9
/**
* @param {string} metadataKey - 元数据入口的key
* @param {*} metadataValue 元数据入口的value
* @returns 装饰器函数
*/
function metadata(metadataKey: any, metadataValue: any) {
(target: Function): void;
(target: Object, propertyKey: string | symbol): void;
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const nameSymbol = Symbol('veloma');

// 类元数据
@Reflect.metadata('class', 'class');
class MetaDataClass {
// 实例属性元数据
@Reflect.metadata(nameSymbol, 'nihao')
public name = 'origin';

// 实例方法元数据
@Reflect.metadata('getName', 'getName')
public getName() {}

// 静态方法元数据
@Reflect.metadata('static', 'static')
static staticMethod() {}
}

// 创建元数据类的实例
const metadataInstance = new MetaDataClass();

// 获取MetaDataClass的name元数据
const value = Reflect.getMetadata('name', MetaDataClass); // undefined
// 获取实例中name属性的nameSymbol元数据
const name = Reflect.getMetadata(nameSymbol, metadataInstance, 'name'); // nihao
// 获取实例中getName属性的getName元数据
const methodVal = Reflect.getMetadata('getName', metadataInstance, 'getName'); // getName
// 获取元数据类的staticMethod属性的static元数据
const staticVal = Reflect.getMetadata('static', MetaDataClass, 'staticMethod'); // static

console.log(value, name, methodVal, staticVal); // undefined nihao getName static

我们注意到,注入在静态成员 如类上及静态属性的元数据在取出时target为这个,而注入在实例成员 如方法,属性上的元数据在取出时target则为实例。原因其实我们实际上在上面的装饰器执行顺序提到了,这是由于注入在方法、属性、参数上的元数据实际上是被添加在了实例对应的位置上,因此需要实例化才能取出

defineMetadata 方法

该方法是metadata的定义版本, 也就是非@版本, 会多传一个参数target, 表示待装饰的对象

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @param {string} metadataKey - 设置或获取时的key
* @param {*} metadataValue - 元数据内容
* @param {Object} target - 待装饰的target
* @param {string} targetKey - target的property
*/
function defineMetadata(
metadataKey: any,
metadataValue: any,
target: Object,
targetKey: string | symbol
): void;

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class DefineMetadata {
static staticMethod() {}
static staticProperty = "static";
getName() {}
}

const type = "type";
// 给DefineMetadata设置元数据type, 值为class
Reflect.defineMetadata(type, "class", DefineMetadata);
// 给DefineMetadata.staticMethod设置元数据type, 值为staticMethod
Reflect.defineMetadata(type, "staticMethod", DefineMetadata.staticMethod);
// 给DefineMeatadata.prorotype.getName设置元数据type, 值为method
Reflect.defineMetadata(type, "method", DefineMetadata.prorotype.getName);
// 给DefineMetadata的staticProperty属性设置元数据type, 值为staticProperty
Reflect.defineMetadata(
type,
"staticProperty",
DefineMetadata,
"staticProperty"
);

// 获取DefineMetadata身上的type元数据
const t1 = Reflect.getMetadata(type, DefineMetadata); // class
// 获取DefineMetadata.staticMethod身上的type元数据
const t2 = Reflect.getMetadata(type, DefineMetadata.staticMethod); // staticMethod
// 获取DefineMetadata.prototype.getName身上的type元数据
const t3 = Reflect.getMetadata(type, DefineMetadata.prototype.getName); // method
// 获取DefineMetadata上staticProperty属性的type元数据
const t4 = Reflect.getMetadata(type, DefineMetadata, "staticProperty"); // staticProperty

console.log(t1, t2, t3, t4); // class staticMethod method staticProperty

首先 npm 安装

1
npm i reflect-metadata --save

When enabled, as long as the reflect-metadata library has been imported, additional design-time type information will be exposed at runtime.

We can see this in action in the following example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import "reflect-metadata";

class Point {
constructor(public x: number, public y: number) {}
}

class Line {
private _start: Point;
private _end: Point;

@validate
set start(value: Point) {
this._start = value;
}

get start() {
return this._start;
}

@validate
set end(value: Point) {
this._end = value;
}

get end() {
return this._end;
}
}

function validate<T>(
target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<T>
) {
let set = descriptor.set!;

descriptor.set = function (value: T) {
let type = Reflect.getMetadata("design:type", target, propertyKey);

if (!(value instanceof type)) {
throw new TypeError(
`Invalid type, got ${typeof value} not ${type.name}.`
);
}

set.call(this, value);
};
}

const line = new Line();
line.start = new Point(0, 0);

// @ts-ignore
// line.end = {}

// Fails at runtime with:
// > Invalid type, got object not Point

The TypeScript compiler will inject design-time type information using the @Reflect.metadata decorator. You could consider it the equivalent of the following TypeScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Line {
private _start: Point;
private _end: Point;
@validate
@Reflect.metadata("design:type", Point)
set start(value: Point) {
this._start = value;
}
get start() {
return this._start;
}
@validate
@Reflect.metadata("design:type", Point)
set end(value: Point) {
this._end = value;
}
get end() {
return this._end;
}
}

这里的 design:type 即是 TS 的内置元数据,你可以理解为 TS 在编译前还手动执行了@Reflect.metadata("design:type", Point)。TS 还内置了 design:paramtypes(获取目标参数类型)与design:returntype(获取方法返回值类型) 这两种元数据字段来提供帮助。但有一点需要注意,即使对于基本类型,这些元数据也返回对应的包装类型,如number -> [Function: Number]

DI Provider

回到我们刚刚提到的问题,我们需要在应用启动的时候自动对所有类进行定义和参数的注册,问题是并不是所有的类都需要注册到容器中,我们并不清楚哪些类需要注册的,同时也不清楚需要注册的类,它的初始化参数是什么样的。

这里就可以引入元数据来解决这个问题,只要在定义的时候为这个类的元数据添加特殊的标记,就可以在扫描的时候识别出来。按照这个思路,我们先来实现一个装饰器标记需要注册的类,这个装饰器可以命名 Provider,代表它将会作为提供者给其他类进行消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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;
};
}

可使用Provider对类 进行标记, 为了之后将其自动注册到容器中

1
2
3
4
5
6
7
8
9
10
// modB.ts
import { Provider } from "provider";

@Provider("b", [10])
class B {
p: number;
constructor(p: number) {
this.p = p;
}
}

自动注册到容器中的思路则是在启动的时候对所有文件进行扫描,获取每个文件导出的类,然后根据元数据进行绑定。简单起见,我们假设项目目录只有一级文件,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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);
}
}
}
}
}
}

只需在项目启动时运行load,就可将声明时被Provider装饰的类,自动注册到容器中

1
2
3
4
5
6
7
8
9
// main.ts
import { Container } from "./container";
import { load } from "./load";

// 初始化 IOC 容器,扫描文件
const container = new Container();
load(container);

console.log(container.get("b"));

DI Inject

上文提及

  • 程序启动时,所有类型会在容器中自动注册
  • 实例化时类型所需的对象的自动注入, 无需在构造函数中指定

第一个问题解决后,再来看第二个问题,我们已经将所有需要注册的类都放入了 IoC 容器,那么,当我们需要用到某个类时,在获取这个类的实例时可以递归遍历类上的属性,并从 IoC 容器中取出相应的对象并进行赋值,即可完成依赖的注入

那么,又是类似的问题,如何区分哪些属性需要注入?同样,我们可以使用元数据来解决。只要定义一个装饰器,以此来标记哪些属性需要注入即可,这个装饰器命名为 Inject,代表该属性需要注入依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Inject.ts
import "reflect-metadata";

export const PROPS_KEY = "ioc:inject_props";

export function Inject() {
return function (target: any, propertyKey: 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 方法,递归注入所有属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 对属性(依赖的实例)进行装饰即可:

1
2
3
4
5
6
7
8
9
10
import { Provider } from 'provider';

@Provider('c')
class C {
@Inject()
a: A;
@Inject()
b: B;
}

最终代码则有:

1
2
3
4
5
6
7
8
9
10
// main.ts
import { Container } from "./container";
import { load } from "./load";

// 初始化 IOC 容器,扫描文件
const container = new Container();
load(container);

// get 时扫描元数据获取依赖的属性, 将递归注入实例对象
console.log(container.get("c"));

则可理解为 Provider装饰的类将自动注册到容器中, Inject装饰的属性 则为依赖的类的实例, 当从容器get一个类型的实例时,会将该类型所依赖实例自动注入到产出的实例中

文章来自:

ts decorators

走近 MidwayJS:初识 TS 装饰器与 IoC 机制

如何基于 TypeScript 实现控制反转

Reflect Metadata(元数据)学习笔记

metadata-reflection-api

有趣的装饰器:使用 Reflect Metadata 实践依赖注入