跳到主要内容
版本:1.0.0

依赖注入手册

Midway 中使用了非常多的依赖注入的特性,通过装饰器的轻量特性,让依赖注入变的优雅,从而让开发过程变的便捷有趣。

背景

midway 默认使用 injection 这个包来做依赖注入,这个包也是 MidwayJs 团队根据业界已有的实现而产出的自研产品,它除了常见的依赖了注入之外,还满足了 Midway 自身的一些特殊需求。

这篇文章不仅仅是 IoC 体系的介绍,也是属于 injection 这个包的一份使用文档。

你不仅可以在 Midway 的开发过程中用到它,如果你希望,它也可以在你的模块开发中帮助到你,它可以单独使用,也可以和现有框架集成,比如 koa, thinkjs 等。

信息

我们在 midway 包上做了自动导出,所以 injection 包中的模块,都能从 midway 中获取到。 import {Container} from 'injection' 和 import {Container} from 'midway' 是一样的。

IoC 概览

IoC(Inversion of control) 控制反转,是 Java Spring 中非常重要的思想和核心,有不少人是第一次听说,也不禁会有许多疑问。

  • 什么是控制反转?
  • 什么是依赖注入?
  • 它们之间有什么关系?

软件中的对象就像齿轮一样,协同工作,但是互相耦合,一个零件不能正常工作,整个系统就崩溃了。这是一个强耦合的系统。

现在,伴随着工业级应用的规模越来越庞大,对象之间的依赖关系也越来越复杂,经常会出现对象之间的多重依赖性关系,因此,架构师和设计师对于系统的分析和设计,将面临更大的挑战。

// 常见的依赖
import { A } from './A';
import { B } from './B';

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

这里的 A 被 B 和 C 所依赖,而且在构造器需要进行实例化操作,这样的依赖关系在测试中会非常麻烦。这个依赖,一般被叫做 "耦合",而耦合度过高的系统,必然会出现牵一发而动全身的情形。

为了解决对象间耦合度过高的问题,软件专家 Michael Mattson 提出了 IoC 理论,用来实现对象之间的“解耦”。

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

使用 injection 解耦

如果你使用了 midway,这些创建的过程将会自动完成,这里为了更好理解,我们将从头开始展示。

首先是安装依赖:

npm i injection --save

然后我们将上面的代码进行解耦。

// 使用 IoC
import { Container } from 'injection';
import { A } from './A';
import { B } from './B';
const container = new Container();
container.bind(A);
container.bind(B);

class C {
constructor() {
this.a = container.get('a');
this.b = container.get('b');
}
}

这里的 container   就是 IoC 容器,是依赖注入这种设计模式的一种实现,使得 C 和 A, B 没有了强耦合关系,甚至,我们可以把 C 也交给 IoC 容器,所以,IoC 容器成了整个系统的关键核心。

信息

注意 IoC 容器就像是一个对象池,管理这每个对象实例的信息(Class Definition),所以用户无需关心什么时候创建,当用户希望拿到对象的实例 (Object Instance) 时,可以直接拿到实例,容器会 自动将所有依赖的对象都自动实例化

获取 IoC 容器

所谓的容器就是一个对象池,它会在应用初始化的时候自动处理类的依赖,并将类进行实例化。比如下面的 UserService 类,在经过容器初始化之后,会自动实例化,并且对 userModel 进行赋值,看不到实例化的过程。

class UserService {
private userModel;

async getUser(uid) {
// TODO
}
}

Midway 内部使用了自动扫描的机制,在应用初始化之前,会扫描所有的文件,包含装饰器的文件会 自动绑定 到容器。

injection 的容器有几种:

  • AppliationContext 基础容器,提供了基础的增加定义和根据定义获取对象实例的能力
  • Container 用的最多的容器,做了上层封装,通过 bind 函数能够方便的生成类定义,midway 从此类开始扩展
  • RequestContext 用于请求链路上的容器,会自动销毁对象并依赖另一个容器创建实例。

其中 Container 是我们最常用的容器,下面的代码就是创建一个容器。

import { Container } from 'injection';
const container = new Container();

对象定义

一个对象的基本元信息,比如名字,是否异步,有哪些属性,依赖等等,我们把这些信息组合到一起,形成一个对象定义。

对象定义往往表现在他的基础类型上, injection 内置了名为 ObjectDefinition 的对象定义类,它包含一系列属性,比如:

  • 有哪些属性
  • 是否有依赖的对象
  • 创建时是否是异步的
  • 初始化方法是哪个
  • 是否自动装配

以上只是列举了一小部分,通过这个定义,容器就可以将一个对象简单的创建出来。

ObjectDefinition 的具体属性文档,可以在 这里看到

绑定对象定义

我们在创建容器之后,将会往这个容器中添加一些对象定义,这样容器才能将对应对象创建出来。

class UserService {
private userModel;

async getUser(uid) {
// TODO
}
}

// 内部代码
const container = new Container(); // 创建容器
container.bind('userService', UserService); // 可以在绑定的时候传一个名字作为 key
container.bind(UserService); // 也可以直接传入 Class,自动分析对象的元信息生成对象定义

bind 方法通过传入类型,自动分析类型上面包含的元信息,具体的 API 参数可以查看这里

普通情况下获取对象

//... 省略绑定逻辑

const userService = await container.getAsync('userService'); // 这里根据 key 获取对象
const user = await userService.getUser('123');

// 如果对象以及对象的依赖中没有异步的情况,也可以同步获取
const userService = container.get('userService');
const user = userService.getUser('123');
//...

只有绑定过的对象定义才能通过 getgetAsync 方法创建出来。

如果一个对象依赖了另一个对象,那么在创建的时候,依赖的对象都会被自动创建并且在容器中管理起来。

信息

Tip 由于 Node.js 中大多对象或者依赖都需要支持异步的情况,所以一般情况下我们都使用 getAsync 方法。

使用装饰器注入

如果每次代码都需要手动绑定,然后通过 get/getAsync 方法拿到对应的对象,那将会非常繁琐,由于 在设计之初 midway/injection 体系就基于 ts,参考了业界的 IoC 实现,完成了属于自己的依赖注入能力,主要是通过 @provide@inject 两个装饰器来完成绑定定义和自动注入属性,大大简化了代码量。

信息

由于使用了依赖注入体系,我们希望所有的业务代码都通过 class 语法来完成

@provide()
export class UserService {
@inject()
userModel;

async getUser(userId) {
return await this.userModel.get(userId);
}
}

我们可以看到业务代码的样子和以往有着一些不同。

  • 类加了装饰器,同时直接导出,不需要关心如何实例化
  • 属性加了装饰器,但是没有任何初始化以及赋值的操作即可使用

@provide()

有了 @provide() 装饰器,就可以简化绑定,被 IoC 容器自动扫描,并绑定定义到容器上,对应的逻辑是 绑定对象定义

@provide(id?) 的参数为对象 id,可选。

信息

注意 @provide 装饰器是用于自动被 IoC 容器装载。

@inject()

@inject() 的作用是将容器中的定义实例化成一个对象,并且绑定到属性中,这样,在调用的时候就可以访问到该属性。

注入的时机为构造器(new)之后,所以在构造方法(constructor)中是无法获取注入的属性的,如果要获取注入的内容,可以使用 构造器注入

父类的属性使用 @inject() 装饰器装饰,子类实例会得到装饰后的属性。

class Parent {
@inject()
katana1;
}

class Child extends Parent {
@inject()
katana2;
}

class Grandson extends Child {
@inject()
katana3;
}

Grandson 的实例 gradson 拥有 @inject() 装饰器注入的 grandson.katana3, grandson.katana2, grandson.katana1 属性。

实现时,会查找 Gradson 的原型链,遍历原型链上所有用 @inject() 装饰的属性,运行装饰器,注入相应的属性。

查找类的原型使用 reflect-metadata 仓库的 OrdinaryGetPrototypeOf 方法,使用 recursiveGetPrototypeOf 方法以数组形式返回该类的所有原型。

function recursiveGetPrototypeOf(target: any): any[] {
const properties = [];
let parent = ordinaryGetPrototypeOf(target);
while (parent !== null) {
properties.push(parent);
parent = ordinaryGetPrototypeOf(parent);
}
return properties;
}

对象 id

在默认情况下,injection 会将类名变为 驼峰 形式作为对象 id,这样你可以通过容器获取实例。

container.getAsync('userService'); // 根据字符串 id 获取实例
container.getAsync(UserService); // 传入类名,自动根据类目获取实例

而默认情况下,Midway 的依赖注入使用的是 byName ,只要同名,就会自动进行注入。

而在某些场景下,用户希望注入不同的实例,这个时候可以对默认生成的 id 进行修改。

@provide('uModel')
export class UserModel {}

@provide('user')
export class UserService {
@inject('uModel')
userModel;

async getUser(userId) {
return await this.userModel.get(userId);
}
}

// 使用修改之后的 id 获取对象
const userService = await container.getAsync('user');

同理,在使用 @inject 的时候也可以使用不同的 id。

构造器注入

除了标准的属性注入方法之外,midway 在一定程度上支持了构造器注入的方式,来让一些应用或者三方包平稳过度。

同样还是使用 @inject 装饰器。

@provide()
export class A {
config = {
c: 20,
};
}

@provide()
export class B {
config = {
c: 40,
};
}

@provide()
export class BaseService {
config;
plugin2;

constructor(@inject() a, @config('hello') config, @inject() b, @plugin('plugin2') plugin2) {
this.config = Object.assign(config, {
c: a.config.c + b.config.c + config.c,
});
this.plugin2 = plugin2;
}
}

在一个类的构造器中,我们可以还可以使用其他的类似 @config, @plugin, @logger 等装饰器。只要是通过 IoC 管理的对象,都能够被自动依赖和注入。

配置作用域

在 injection 体系中,有三种作用域。

  • Singleton 单例,全局唯一(进程级别)
  • Request  默认,请求作用域,生命周期随着请求链路,在请求链路上唯一,请求结束立即销毁
  • Prototype 原型作用域,每次调用都会重复创建一个新的对象

在这三种作用域中,midway 的默认作用域为 请求作用域,这也意味着,如果我们需要将一个对象定义为其他两种作用域,需要额外的配置。

injection 提供了 @scope 装饰器来定义一个类的作用域。

@scope(ScopeEnum.Prototype)
@provide('petrol')
export class PetrolEngine implements Engine {
capacity = 10;
}

@scope(ScopeEnum.Singleton)
@provide('diesel')
export class DieselEngine implements Engine {
capacity = 20;
}

// in IoC Container
assert(container.getAsync('petrol') === container.getAsync('petrol')); // false
assert(container.getAsync('diesel') === container.getAsync('diesel')); // true

插件,在 midway 中为单例,不可配置。

异步初始化

在某些情况下,我们需要一个实例在被其他依赖调用前需要初始化,如果这个初始化只是读取某个文件,那么可以写成同步方式,而如果这个初始化是从远端拿取数据或者连接某个服务,这个情况下,普通的同步代码就非常的难写。

midway 提供了异步初始化的能力,通过 @init 标签来管理初始化方法。

@init 方法目前只能是一个。

@provide()
export class BaseService {
@config('hello')
config;

@plugin('plugin2')
plugin2;

@init()
async init() {
await new Promise((resolve) => {
setTimeout(() => {
this.config.c = 10;
resolve();
}, 100);
});
}
}

@async 装饰器已废弃,所有的 init 方法默认都会异步,同步初始化可以直接在构造器执行,此装饰器没有意义。

只要在方法上标记 @init 装饰器之后,这个时候会自动在实例化之后,通过异步的来调用 @init   标记的方法。

动态函数注入

在某些场景下,我们需要函数作为某个逻辑动态执行,而 IoC 中的对象属性则都是已经创建好的,无法满足动态的逻辑需求。

比如你需要一个工厂函数,根据不同的场景返回不同的实例,也可能有一个三方包,是个函数,在业务中想要直接调用,种种的场景下,你就需要直接注入一个函数,并且在函数中拿到上下文。

标准的函数注入样例。

export function contextHandler(context) {
return async () => {
// const xxx = context.getAsync('xxxx');
return true;
};
}

providerWrapper([
{
id: 'contextHandler',
provider: contextHandler,
},
]);

使用端。

@provide()
export class BaseService {
@inject()
contextHandler: () => Promise<boolean>;
}

midway 通过 providerWrapper 函数来包裹一个函数,并且指定提供的 key,供其他 IoC 对象使用。由于函数直接传递了一个 context 对象,可以轻松的通过此对象拿到所需的其他对象,而不需要管理依赖。

函数注入大多数为了创建一个简单的工厂。

export function adapterFactory(context: IApplicationContext) {
return async (adapterName: string) => {
if (adapterName === 'google') {
return await context.getAsync('googleAdapter');
}

if (adapterName === 'baidu') {
return await context.getAsync('baiduAdapter');
}

// return await context.getAsync(adapterName + 'Adapter');
};
}

providerWrapper([
{
id: 'adapterFactory',
provider: adapterFactory,
},
]);

这样在业务中,可以直接来使用了。

@provide()
export class BaseService {
@config('adapterName')
adapterName;

@inject('adapterFactory')
factory;

adapter: Adapter;

@init()
async init() {
this.adapter = await this.factory(this.adapterName);
}
}
信息

这个函数可以是异步的 (async)。

再举个例子,比如如果应用希望自己使用 sequelize, 而 sequelize 的创建 model 的过程是个异步操作,代码就可以这么写:

import { providerWrapper, IApplicationContext } from 'midway';
import * as Sequelize from 'sequelize';
import { Sequelize as SequelizeInstance } from 'sequelize';

// 可以直接写 async 方法
export async function factory(context: IApplicationContext) {
const instance = await context.getAsync<SequelizeInstance>('coreDB');

const UiKeyTraceModel = instance.define(
name,
{
gmtCreate: {
type: Sequelize.DATE,
allowNull: true,
field: 'gmt_create',
},
gmtModified: {
type: Sequelize.DATE,
allowNull: true,
field: 'gmt_modified',
},
},
{
timestamps: true,
createdAt: 'gmt_create',
updatedAt: 'gmt_modified',
freezeTableName: true,
tableName: 'xxxx',
}
);

return UiKeyTraceModel;
}

providerWrapper([
{
id: 'keyTraceModel',
provider: factory,
},
]);

通过 providerWrapper 我们将一个原本的函数写法进行了包裹,和现有的依赖注入体系可以融合到一起,让容器能够统一管理。

注入已有对象

有时候,应用已经有现有的实例,而不是类,比如引入了一个第三库,这个时候如果希望对象能够被其他 IoC 容器中的实例引用,也可以通过增加对象的方式进行处理。

我们拿常见的 http 请求库 urllib 来举例。

假如我们希望在不同的类中来使用,并且不通过 require 的方式,你需要在容器的入口通过 registerobject 方法添加这个对象。

在添加的时候需要给出一个 key,方便其他类中注入。

// in global file
import * as urllib from 'urllib';
container.registerobject('httpclient', urllib);

这个时候就可以在任意的类中通过 @inject 来使用了。

@provide()
export class BaseService {
@inject()
httpclient;

async getUser() {
return await this.httpclient.request('/api/getuser');
}
}
信息

在 midway 中可以在 src/app.ts 中进行添加。

通过依赖图排错

在业务代码中,我们可能会碰到依赖注入不生效或者作用域配置错误的问题,这个时候由于容器管理的问题显得不透明,用户也不太清楚容器里有哪些东西,分别依赖了什么。

我们提供了一个依赖树生成的方法,目前可以通过它生成文本形式的图形。

const container = new Container();
container.bind(UserService);
container.bind(UserController);
container.bind(DbAPI);
const newTree = await container.dumpDependency();

console.log(newTree);

通过 dumpDependency 方法生成的文本,可以直接在 viz-js 渲染为图案,方便排查问题。

也可以通过安装 graphviz 等工具将文本树转化为图片形式。

信息

midway 在启动时会将依赖树生成到 /run 目录下,方便排错。