浅谈 DI(依赖注入)

浅谈 DI(依赖注入)

文章参考:https://zhuanlan.zhihu.com/p/311184005
项目参考:darukjs InversifyJS

依赖注入是 IoC(控制反转) 的一种实现方式,通过依赖注入可以动态的将某种依赖关系注入到对象中,而不用手动一个个实例化。
在依赖注入中,将实例化对象这个步骤交给外部(IoC 容器),即为控制反转。

一. 为什么需要依赖注入

传统面向对象开发中,当两个类之间存在依赖关系时会直接在类的内部创建另一个的实例,导致两个类之间形成耦合关系。
实际情况往往多个类之间会存在交叉依赖关系,一个类会被多个其它类依赖。这时如果类的功能有修改,则可能需要把所有依赖该类的地方统统改一遍。

如同上面解释的那样,AB 两个类可能存在一种关系为 A 依赖于 B,例如:

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

1
2
3
4
5
6
7
// b.ts
class B {
p: number;
constructor(p: number) {
this.p = p;
}
}

B 修改后那么所有实例化 B 的地方都需要添加参数。 A 构造函数中实例化 B 时也需要传入参数 pA 中的 p 并非凭空生成,也需要实例化 A 时传入参数,再将参数传给 B 的构造函数。此时 A 为:

1
2
3
4
5
6
7
// a.ts
class A {
b:B;
constructor(p: number) {
this.b = new B(p);
}
}

由上面的例子我们知道,修改 B 就需要修改 A,如果依赖层数很深则可能行程一条 修改链,由修改的那一层一直到表层。

二. 怎么实现 IoC

  1. 怎么解耦
    上诉情况产生的原因是由于 A 需要 B 的实例,实现时选择直接在 A 中实例化 B,那么 A 实际上依赖的是 B 这个类(构造器)而非 B 的实例。A 并不关心 B 的实例在何时何地以何种方式构造出来。基于这个原因我们其实可以做以下改变:

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

    这个例子中,我们将 AB 各自独立的实例化,在 A 中直接传入 B 的实例,而非 B 的参数,这样当依赖项 B 有任何改变时,A 不需要做任何变化。

  2. 容器
    上面的例子里我们实现了将 AB 解耦,A 不再因为 B 改变而改变了。但如果还有一个类需要 B 的话仍然需要再次实例化 B,这样的地方越多,那么我们维护 B 的成本也就越高。我们很容易可以想到,要是将所有的依赖都统一管理,使用的时候不去 new 实例,而是直接拿到已经 new 好的实例,就解决了这个问题。

    先新建一个容器类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Container {
    instanceMap = new Map()

    bind(id: string, clazz: any, constructorArgs: Array<any>) {
    this.instanceMap.set(id, {
    clazz,
    constructorArgs
    })
    }

    get<T>(id: string): T {
    const target = this.instanceMap.get(id);
    const { clazz, constructorArgs } = target;
    const inst = Reflect.construct(clazz, constructorArgs); // Reflect.construct 类似 new 操作符
    return inst
    }
    }

    export default new Container()

    原来 AB 的代码即可改为:

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

    // a.ts
    class A {
    private b:B;
    constructor() {
    this.b = container.get('b'); // 此处通过容器注入
    }
    }

    // main.ts
    import container from './container.ts'
    // 绑定到容器
    container.bind('a', A);
    container.bind('b', B, [10]);

    到此我们实现了一个容器,通过容器实现类和类之间的解耦。从功能上说,上面的实现就是一种 IoC,
    将实例化的过程交给容器实现,而非类内部。但整个实现过程还是略显复杂,类需要手动绑定到容器,
    依赖需要手动到容器上获取;如果这些步骤能自动实现,就能减少重复的代码,解放我们的双手。
    实际上大部分框架中的 DI(依赖注入)就是为了完成这件事。

三. DI

DI 即依赖注入,是 IoC 的一种实现方式,通过 DI,我们可以将依赖注入给调用方,不需要调用方主动去获取依赖。结合上文,为实现 DI,我们还需要完成以下两件事:

  • 需要将类自动绑定到容器。
  • 需自动将依赖注入到属性上(如例子中 A 需要关联 B 的实例)。

针对问题 1,一般由两个方法,一是通过一个清单文件,将需要注入的类列举出来,然后框架统一注入到容器中。二是通过注解(装饰器)直接在类中标识,然后逐一注入到容器中——本例采用的方式。
针对问题 2,可以通过装饰器将容器收集到的类实例化到对应的对象属性上

这里使用装饰器进行依赖注册及注入,这种方式在使用时更为方便,当增加新的类文件时完全不用做任何操作,可以自动完成类注册到容器。下面我们来完成一个 DI 实例:

  1. Reflect Metadata
    上面我们说 “针对问题一可以通过在类中标识,从而完成注入类到容器”Reflect Metadata 即是用来标识类的。不止如此,我们将依赖注入到实例中时仍然需要它。
    简单点理解,Reflect Metadata 就是用来在某一对象上写入一些数据,但是这些数据不能直接读取出来,也不影响对象的正常使用,等到我们需要的时候又可以通过 Reflect Metadata 提供的一些方法读出来。

    想了解更多可以参考 Reflect Metadata

  2. Provide
    Provide 用来解决上面提到的问题一。这是一个装饰器,用它标识的 class 将会自动绑定到容器中,下面是 Provide 的实现

    参考 inversify-binding-decoratorsProvide 的简化实现

    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
    import interfaces from "./interfaces";
    import { METADATA_KEY } from "./constants";
    import "reflect-metadata";

    export default function Provide(serviceIdentifier: string, args?: Array<any>) {
    return function (target: any) {
    // 当前 class 的信息
    const currentMetadata: interfaces.ProvideSyntax = {
    id: serviceIdentifier,
    args: args || [],
    clazz: target
    };
    // 已经收集的 class
    const previousMetadata: interfaces.ProvideSyntax[] = Reflect.getMetadata(
    METADATA_KEY.provide,
    Reflect
    ) || [];

    const newMetadata = [currentMetadata, ...previousMetadata];

    // 将所有使用 Provide 装饰器标识的 class 都作为 Reflect 的元数据暂存
    Reflect.defineMetadata(
    METADATA_KEY.provide,
    newMetadata,
    Reflect
    );
    return target;
    };
    }

    我们将所有需要绑定到容器的类都用 @Provide 去标记,收集并记录到 Reflect 对象中。如:

    1
    2
    3
    4
    5
    6
    @Provide('b')
    export class B {
    constructor(p: number) {
    this.p = p;
    }
    }

    但到这里收集依赖的部分还没完成,因为类都定义在文件内的。我们需要一个方法将需要的文件一次性都 require 进来,让依赖注册生效(@Provide)。下面的 binding 方法就是完成这件事,我们需要在应用的生命周期初始化阶段去手动调用它:

    参考 darukjs 项目实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // ./container.ts
    export async function binding() {
    // 对目录没有限制,这里只是将常规目录作为示例
    await _loadFile(join(__dirname, './middlewares'));
    await _loadFile(join(__dirname, './controllers'));
    await _loadFile(join(__dirname, './services'));
    container.load(buildProviderModule());
    }

    export async function _loadFile(path: string) {
    return recursive(path).then((files: Array<any>) => {
    return files
    .filter((file) => isJsTsFile(file))
    .map((file) => file.replace(JsTsReg, ''))
    .forEach((path: string) => {
    require(path);
    });
    }).catch(() => {});
    }

    调用 binding 函数就会将 middlewarescontrollersservices 目录中所有 @Provide 装饰的 class 都绑定到容器。
    这里还缺两块,Container 的 load 方法,还有 buildProviderModule 函数。

    buildProviderModule 函数就是为了从 Reflect 上拿到所有依赖项目。

    buildProviderModule 参考 inversify-binding-decorators 的实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // ./container.ts
    /**
    * 省略已实现的代码
    */
    function buildProviderModule() {
    return (bind: Function) => {
    let provideMetadata: interfaces.ProvideSyntax[] = Reflect.getMetadata(METADATA_KEY.provide, Reflect) || [];
    provideMetadata.map(metadata => bind(
    metadata.id,
    metadata.clazz,
    metadata.args
    ));
    };
    }

    上面代码中的核心就是 Reflect.getMetadata(METADATA_KEY.provide, Reflect),对比 Provide 中的这部分

    1
    2
    3
    4
    5
    Reflect.defineMetadata(
    METADATA_KEY.provide,
    newMetadata,
    Reflect
    )

    我们大致就能摸清依赖注册的原理,@Provide 通过 Reflect.defineMetadata 完成收集。 buildProviderModule 方法中通过 Reflect.getMetadata 拿到收集到的类交给 container.load 完成依赖注册。

    Container.load 如下:

    1
    2
    3
    4
    5
    6
    7
    export class Container {
    // ...省略前面已实现的代码
    public load(register) {
    // 注意,这里只是简化的例子
    register(this.bind)
    }
    }
  3. Inject
    前一步我们完成了依赖注册,将所有 待依赖 的 class 都在 container 中保存起来。接下来就是最后一步:在需要依赖的地方注入依赖项。
    我们要做到的就是在类初始化时能自动拿到依赖对象的实例,不需要在依赖对象初始化时传参。我们已经将所有相关类都注册到 container 中,要使用某个类时,只要将 container 中对应的类实例化给相应属性即可。有了上面 Provide 的经验我们很容易就可以想到,通过装饰器很容易办到。

    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
    // ./inject.ts
    import container from './container'
    const INJECTION = Symbol.for("INJECTION");

    export default function Inject(serviceIdentifier: string) {
    return function (proto: any, key: string) {
    let resolve = () => {
    // 从 container 中取值
    return container.get(serviceIdentifier);
    };

    function getter() {
    // 缓存值
    if (!Reflect.hasMetadata(INJECTION, this, key)) {
    Reflect.defineMetadata(INJECTION, resolve(), this, key);
    }
    if (Reflect.hasMetadata(INJECTION, this, key)) {
    return Reflect.getMetadata(INJECTION, this, key);
    } else {
    return resolve();
    }
    }

    function setter(newVal: any) {
    Reflect.defineMetadata(INJECTION, newVal, this, key);
    }

    Object.defineProperty(proto, key, {
    configurable: true,
    enumerable: true,
    get: getter,
    set: setter
    });
    };
    }

    Inject 通过设置属性的 getter 和 setter 拦截属性的获取和设置。获取时从 container 中拿到依赖对应依赖,设置时则替换当前依赖的缓存值,等到下次获取时使用这个设置的新值。

    经过上面的设置,原来 AB 的例子即可改为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 全局共用
    const container = new Container();
    // ./services/b.ts
    @Proivde('b', [10])
    class B {
    constructor(p: number) {
    this.p = p;
    }
    }

    // ./services/a.ts
    @Proivde('a')
    class A {
    @Inject('b')
    private b:B;
    }

    // ./main.ts
    binding()
    console.log(container.get('a')); // => A { b: B { p: 10 } }

尾巴

IoC 最早运用在后端服务上,随着时间的推移,像 Angular 这样的优秀框架实现了前端的 IoC,也给前端提出了新要求。技术思想是不分前后端的,不管是 MVC、IoC、AOP 抑或是别的,这些业界的经典设计,是一个在前后端都通用的思想或范式。我们不能给自己的技术设限,优秀的思想永远都是学习的目标。

前端在近些年日新月异,我们需要拥抱变化,融入变化;在未知的技术面前虚心学习是技术进阶的必经之路。

相关示例

. di-example

# DI, 原理

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×