学习 Nestjs 前,你需要了解什么是依赖注入(原理详解)

2,993 阅读8分钟

前言

前端全栈方向,避不开要学习一个成熟的后端框架, nodejs 属于基建,公司一般都会采用一些上层框架来进行开发。比较热门的有 expressNestjsMeteorKoajsfastify

众多框架中,Nestjs 排行第二,也是 star 增长最快的 TypeScript 的后端框架。也就意味着,如果你需要使用一个 Typescript 后端框架进行开发,Nestjs 当上不二之选。

Nestjs 中使用最多就是依赖注入技术,如果还不了解这一实现原理,学习起来会非常痛苦,该技术早就 Angular中已有广泛应用,写法类似 Java Spring。

现阶段函数式编程颇为流行,Vue3 的 Composition api 以及 React 的 Hooks ,一度使我以为函数式编程才是前端开发的趋势。可看完 NestJs 的使用面向对象装饰器,突然让我眼前一亮。

下面是 NestJs 一个简单的使用例子:

@Controller('cats')
export class CatsController {
    constructor(private catsService: CatsService) {}

    @Post()
    async create(@Body() createCatDto: CreateCatDto) {
      this.catsService.create(createCatDto);
    }

    @Get()
    async findAll(): Promise<Cat[]> {
      return this.catsService.findAll();
    }
}

接下来我将用非常通俗的例子来解析其实现原理。

概述

看完本文你会学到什么?

  • 了解什么是控制反转 & 依赖注入。

  • 通俗易懂的例子带你学会实现依赖注入。

  • 了解什么是 Reflect Metadat 以及如何使用。

接下来我将分别讲述 控制反转/依赖注入IOC Container使用装饰器实现依赖注入Reflect Metadat ,一步步带你了解其原理和实现。

控制反转 LOC & 依赖注入 DI

首先解释一下什么是控制反转和依赖注入,控制反转是一种设计模式,目的是解耦。而依赖注入是控制反转的一种技术手段。

下面用一个通俗的例子来具体说明:

员工小明每天都需要利用电脑完成工作,写代码,发邮件等。他现在有一台 Win 笔记本电脑。

class User {
    constructor (private name: string) {}
     work() {
        const win = new WinComputer()
        win.open()
    }
}

class WinComputer {
    open () {}
}

const user = new User('xiaomin')
user.work()

有一天小明 Win 电脑坏了,需要换成 Mac 电脑

```ts
class User {
    constructor (private name: string) {}
     work() {
        const win = new MacComputer()
        win.open()
    }
}

class MacComputer {
    open () {}
}

const user = new User('xiaomin')
user.work()

上面的改造虽然是解决问题,但是每次切换电脑都要对原始类进行修改,极其不方便。

从上面可以看出,用户在自己的类中控制着电脑,换电脑就必须重构代码。

这个时候我们就会想到将电脑抽象出来,让用户类依赖的是一个抽象类的实例,而非一个具体实例。

class User {
    constructor (private name: string, private computer: ) {}
    work() {
        this.computer.open()
    }
}

abstract class Computer {
   open () {}
}

class WinComputer extends Computer{
    open () {
        console.log('win start')
    }
}

class MacComputer extends Computer{
    open () {
        console.log('mac start')
    }
}

const user = new User('xiaomin', new WinComputer())

// if Win Error
// const user = new User('xiaomin', new MacComputer())

电脑类是抽离出来了,可是每天我们到公司还是需要先打开 Win 电脑,看看能不能开机,不能开机再切换 Mac 电脑... 可能以后还会有 Linux 电脑,还是还不方便呀。就不能一到工位就有人帮我检查好哪台电脑是好的?

当然可以,来了一个网管员,天天帮你管理电脑,然后每天早上检查你的哪台电脑能开机,坏的就送去维修。你到公司了只需要安心工作就可以了,其他电脑的事情完全不用你管。

class User {
    constructor (private name: string, private computer: Computer) {}
    work() {
        this.computer.open()
    }
}

class Admin () {
    getComputed () {
        const win = new WinComputer()
        if (win.open) {
            return win
        } else {
            return new MacComputer()
        }
    }
}

const admin = new Admin()
const user = new User('xiaomi', admin.getComputed())

// 再也不用自己管理电脑,work 执行失败就找 Admin
user.work()

这就是控制反转,把你的控制电脑的权限转交给其他人,让别人替你去管理。上面 User 类将电脑的管理权转交给 Admin ,通过 admin.getComputed() 去获取电脑。

const user = new User('xiaomi', admin.getComputed())

User 依赖一个 Computer 实例,必须在 constructor 注入依赖才能正常工作,而我们将 admin.getComputed() 作为依赖项注入到 User 中,这个过程就是 依赖注入

简单来说,依赖注入就是将一个对象所依赖的其他对象通过构造函数、方法参数或者属性注入到该对象中,而不是在该对象内部直接创建这些依赖对象。这样做的好处是,我们可以更容易地替换依赖对象,从而实现代码的可测试性和可扩展性。

什么是IOC Container?

上面我们实现了将Admin获取的电脑实例注入User 类中。

有没有一个通用的容器,把上面的注入过程封装起来,自动将任意服务提供者注入到控制器中?

上面的 User 类相当于 控制器(controller)

上面的 Admin 类相当于 服务提供者(service)

function container (controller, service) {
    // 这里面是将 service 自动注入到 controller 中
}

所以实现IOC的组件或者框架,我们可以叫它IOC Container。

使用装饰器实现依赖注入

如果你还不了解装饰器,可以先暂停,先看看装饰器相关的文档

上面我们并没有真正去实现一个通用的 依赖注入容器,下面我将用装饰器去简单实现一个 LOC

初步设想如下:

  1. 使用 InjectTable 装饰器标记的服务,收集到一个公共容器中。

  2. 使用 Inject 装饰器将服务注入到目标类中。

服务注入

简单框架:

@InjecTable('admin')
class Admin {
    open () {}
}

@Inject('admin')
class User () {
    work () {
        this.admin.open()
    }
}

const user = new User()
user.work()

我们要实现的原理其实很简单:InjectTable 负责收集服务,Inject 负责将收集到的服务注入到目标类中。首先我们将 InjectTable 标记的类都统一存在一个 容器里,需要的时候取出来放入到目标类的成员中,如:User['admin'] = new (容器.Admin), 这样就完成了将服务实例挂载到目标类的过程。

因为 User 类中的成员 admin 上有装饰器 @inject(),在 User 定义的时候就会执行装饰器方法,将 Admin 的实例挂载在 User 类中。

const map = new Map()

function InjecTable (key) {
    // target 类
    return function (target, propertyKey, descriptor) {
       map.set(key, target)
    }
}

function Inject (key) {
    return function (target, propertyKey, descriptor) {
       const item = map.get(key)
       target[key] = new item()
    }
}


@InjecTable('admin')
class Admin {
    open () {}
}

@Inject('admin')
class User () {
    work () {
        this.admin.open()
    }
}

const user = new User()
user.work()

上面 Inject 相当于把 Admin 实例挂载在了 User 的原型上。 User 类上就多了一个 admin 的成员属性,这样,work 方法就能直接调用 this.admin.open(),使用 Admin 的能力。

不过你有没有发现一个问题,就是上面的实现中,我们必须要指定一个注入的 key,用这个 key 去 Map 里取具体的服务类,还会用 key 作为注入成员属性的 name。有没有办法我在类里定义一个成员,就可以根据属性类型自动注入?如下:

class User () {
    @Inject()
    admin: Admin
    work () {
        this.admin.open()
    }
}

这里我们指定了 admin 成员的接口类型为一个 Admin。是不是可以直接通过这个类型,再去 Map 里找,找到了再注入进来?

问题是我们要如何获取 User 类中 admin: Admin 的类型?可以使用 Reflect Metadata 来获取。

Reflect.getMetadata('design:type', User, 'admin') // Admin

Reflect Metadat

什么是 Reflect Metadat ?Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时候添加和读取元数据。

相关 API 可以先去了解 GitHub - rbuckton/reflect-metadata: Prototype for a Metadata Reflection API for ECMAScript

示例:

@Reflect.metadata('key1', '这是类元数据')
class Test {
  @Reflect.metadata('key2', '这是方法元数据')
  public hello(): string {
    return 'hello world';
  }
}

console.log(Reflect.getMetadata('key1', Test)); // '这是类元数据'
console.log(Reflect.getMetadata('key2', new Test(), 'hello')); // '是方法元数据'

简单来说就是通过反射向类、属性、方法添加附加信息。如上,我们将 Test 类上添加了一个“字符串”,同时通过 Reflect.getMetadata 我们可以从外部取出我们定义在类上的“字符串”,这里的 “字符串” 可以是任意值,也可以是对象。

上面我们分别在类和方法上定义了两个元数据:

  • key1 --- '这是类元数据'
  • key2 --- '这是方法元数据'

我们通过自定义的 key 获取自定义的元数据信息,其实还有三个内置的 key 可以获取元数据。

  • design:type --- 获取属性类型
  • design:paramtypes --- 获取方法参数类型
  • design:returntype --- 获取方法返回类型
class User () {
    @Inject()
    admin: Admin
    work (time: Time): Boolean {
        this.admin.open()
    }
}

console.log(Reflect.getMetadata('design:type', User, 'admin')); 
// Admin
console.log(Reflect.getMetadata('design:paramtypes', User, 'work'));
// [Time]
console.log(Reflect.getMetadata('design:returntype', User, 'work'));
// Boolean

Ok, 理解完 Reflect Metadata 是什么后,我们继续回到上面的依赖注入实现, 现在可以通过

Reflect Metadata 拿到成员类型后就好办了。

完整代码如下:

@InjecTable('admin')
class Admin {
    open () {}
}

class User () {
    @Inject()
    admin: Admin
    work () {
        this.admin.open()
    }
}

const user = new User()
user.work()

// ----------------------- 装饰器 ----------------------------

const SERVER_CONTAINER = 'servers'

function InjecTable (key) {
    // target 类
    return function (target, propertyKey, descriptor) {
       // map.set(key, target)
       Reflect.defineMetadata(
           SERVER_CONTAINER, // meta key
           key, // meta value
           target, // meta Class
           propertyKey // meta Class attr
       )
    }
}

function Inject (key) {
    return function (target, propertyKey, descriptor) {
       //const item = map.get(key)
       // target[key] = new item()
       const type = Reflect.getMetadata('design:type', target, propertyKey)
       target[propertyKey] = new type()
    }
}

同样的原理,我们也可以通过构造函数参数上注入 利用:Reflect.getMetadata('design:paramtypes', User.constructor)

class User () {
    constructor (@Inject() private admin: Admin) {

    }
    work () {
        this.admin.open()
    }
}

总结

上面我们首先介绍了控制反转和依赖注入,再介绍了什么是 LOC 容器,然后再使用装饰器实现了一个简单的 LOC 容器。中间利用了 Reflect Metadata 来实现获取类型。最终实现了完整的依赖注入流程。

当然,上面的实现很简单,存在很多问题,例如不能注入多个服务。每个服务注入时都还 new 一个实例出来,没有使用单例模式,缓存实例等等。

参考文章