decorator与依赖注入

1,649 阅读2分钟

前言

最近作者有在用Nestjs开发nodejs程序。

nestjs是构建在Express(默认)或Fastify之上的nodejs框架,借助Typescript和decorator提供了AOP(面向切面)、DI(依赖注入)等编程范式。

今天就来介绍,如何通过Typescript的decorator来为Express实现简单的依赖注入。

创建项目

初始化项目:

// 安装typescript
npm install -g typescript
// 初始化项目
npm init -y
tsc --init

安装依赖:

yarn add express
yarn add @types/express -D

同时我们需要将tsconfig中的experimentalDecorators选项设置为true,以使用装饰器:

{
  "compilerOptions": {
    "experimentalDecorators": true,
  }
}

Controller装饰器

Express的使用方法相信很多小伙伴都很熟悉:

const express = require('express')
const app = express()
const port = 3000

app.get('/main/hello', (req, res) => res.send('Hello World!'))

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

而在改造之后,希望得到的等效的写法是:

// index.ts
start({
  controllers: [MainController],
  port: 3000,
});

// controllers.ts
@Controller('main')
export class MainController {
  @Get('hello')
  hello(req, res) {
    res.send('Hello World!');
  }
}

设置元数据

为了设置与获取元数据,我们将安装reflect-metadata库(关于reflect-metadata大家可以参考语法提案git仓库):

yarn add reflect-metadata -D

我们将借助其提供的Reflect.defineMetadataReflect.getMetadata来实现元数据的存取。

defineMetadatagetMetadata在类和方法的装饰器上使用时分别为以下形式:

Reflect.defineMetadata(key, value, target); // 类
Reflect.defineMetadata(key, value, target, propertyKey); // 方法

Reflect.getMetadata(key, target); // 类
Reflect.getMetadata(key, target, propertyKey); // 方法

于是,我们便可以实现ControllerGet装饰器,用来存储请求路径和http方法的元数据:

// framework.ts
export function Controller(rootPath: string = '') {
  return (target) => {
    Reflect.defineMetadata('ROOT_PATH', rootPath, target);
  }
}

function getControllerDecorator(method) {
  return (path: string = '') => {
    return (target, propertyKey: string) => {
      Reflect.defineMetadata('HTTP_METHOD', method, target, propertyKey);
      Reflect.defineMetadata('PATH', path, target, propertyKey);
    }
  }
};

export const Get = getControllerDecorator('get');

获取元数据

之后,实现start方法:

// framework.ts
import * as express from 'express';

export const app = express();

export function start({ controllers, port = 3000 }: IConfig) {
  for (const cls of controllers) {
    const rootPath = Reflect.getMetadata('ROOT_PATH', cls);
    const instance = new cls();
    for (const propertyKey of Object.getOwnPropertyNames(cls.prototype)) {
      const method = Reflect.getMetadata('HTTP_METHOD', instance, propertyKey);
      const path = Reflect.getMetadata('PATH', instance, propertyKey);
      if (!method) continue;
      app[method](`${rootPath}/${path}`, instance[propertyKey].bind(instance));
    }
  }
  app.listen(port, () => console.log(`Example app listening on port ${port}!`));
}

以上代码也比较容易理解。我们遍历controllers数组,其中每一项为用Controller装饰器装饰过的控制器类,从类中我们获取路径前缀,创建类的实例后,遍历每个实例的方法,如果方法被GetPost之类的装饰器装饰过的话,就注册到Express实例上。

依赖注入

以上我们实现了Controller装饰器,不过还没有实现依赖注入。

我们希望实现依赖注入后,可以这样使用:

// controller.ts
@Controller('/math')
export class MathController {
  constructor(
    private readonly math: MathService,
  ) {}

  @Get('add')
  add(req, res) {
    res.send({ result: this.math.add(1, 2) });
  }
}

// service.ts
@Provider()
export class MathService {
  add(a, b) {
    return a + b;
  }
}

显而易见的,在我们实现的start方法中,需要修改new cls()这部分的代码,以正确的注入依赖的实例。

那问题来了,我们怎么知道控制器依赖了哪些东西呢?

这下,就轮到emitDecoratorMetadata出场了。修改tsconfig.json,将emitDecoratorMetadata设置为true:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
  }
}

先不急着实现功能,随便写个Provider装饰器,先用tsc命令编译一下,再编译出的controller.js中,我们可以找到类似如下的代码:

MathController = __decorate([
    framework_1.Controller('/math'),
    __metadata("design:paramtypes", [service_1.MathService])
], MathController);

哇哦,ts为我们处理好了依赖列表,并存在了design:paramtypes这个元数据内。

接下来,我们只要通过这个元数据获取类并生成实例就行了:

// framework.ts
const providerMap: WeakMap<any, Object> = new WeakMap();

export function Provider() {
  return (target) => {
    providerMap.set(target, null);
  };
}

function getInstance(cls) {
  const dependencies = (Reflect.getMetadata('design:paramtypes', cls) || []).map(cls => {
    const instance = providerMap.get(cls);
    if (instance === null) providerMap.set(cls, getInstance(cls));
    return providerMap.get(cls);
  });
  return new cls(...dependencies);
}

export function start({ controllers, port = 3000 }: IConfig) {
  for (const cls of controllers) {
    const instance = getInstance(cls);
    ...
  }
  app.listen(port, () => console.log(`Example app listening on port ${port}!`));
}

getInstance方法用来处理依赖并生成实例。其内部递归的调用了getInstance,这样一个Provider可以去依赖其他Provider。同时这里我们使用了一个类和实例的WeakMap,这样每个Provider都是单例模式。我们可以通过在Provider装饰器中传入参数来改造这一行为,这里就不展开了。