原来nest.js和midway.js框架中的依赖注入是这样实现的,手把手带你实现一下。

924 阅读13分钟

前言

使用midway.js框架也上线了几个小项目了,但是对于midway的底层实现一知半解,趁着过年期间在家没事,把midway.js和nest.js框架底层源码看了一下,终于知道了他们依赖注入是怎么实现的了。

下面我会带着大家一步步实现一个简单版的midway框架,让大家也掌握midway和nest底层实现原理。

midway.js和nest.js底层实现原理差不多,我midway使用的多一点,对midway更熟悉一点,所以这篇文章以midway为主。

midway例子

初始化midway项目

npm init midway@latest -y

这里选择koa

image.png

安装依赖

pnpm i

启动项目

npm run dev

测试接口

启动完项目后,在浏览器中输入http://127.0.0.1:7001/

image.png

分析代码

启动项目后,访问 http://127.0.0.1:7001 地址,相当于调用src/controller/home.controller.ts里的home方法。

image.png

把controller地址改一下,重启一下服务

image.png

再次访问 http://127.0.0.1:7001/ 就会报错了,因为那个路径不存在了,访问http://127.0.0.1:7001/home 就行了。

image.png

新加一个接口

image.png

访问 http://127.0.0.1:7001/home/list

image.png

给大家举上面例子,是想告诉大家Controller和Get装饰器的作用。

koa例子

下面我们使用koa框架实现一下上面两个接口,对比一下。

找一个空白文件夹,执行npm init -y初始化一个node项目。

安装koa koa-route依赖

pnpm i koa koa-router

启动一个koa服务,定义两个路由。

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = new Router();

router.get('/home', async (ctx) => {
  ctx.body = 'Hello Midwayjs!';
})

router.get('/home/list', async (ctx) => {
  ctx.body = [
    {
      name: 'midway',
    },
  ];
})

app.use(router.routes()).use(router.allowedMethods());

app.listen(3001, () => {
  console.log('server is running at http://localhost:3001');
})

然后访问http://127.0.0.1:3001/homehttp://127.0.0.1:3001/home/list 就能发现实现了和上面一样的需求。

midway和koa的关系

还记得在初始化midway项目的时候,要选一个模板,我们选的是koa。那midway和koa是什么关系呢?

答:midway使用koa启动的http服务,然后收集Controller和Get装饰器,动态生成koa路由。nest原理和这个差不多,只不过它使用的是express。

下面就带着大家实现Controller和Get装饰器。

简易版midway实现思路

image.png

观察一下上面代码,我们只需要把解析出项目里所有Controller和Get装饰器里配置的url,然后把Controller的url和Get配置的url拼接起来,生成Koa路由url参数,Get装饰器下面的方法就是对应路由的具体实现。

想办法把上面代码转换为下面代码就行了

image.png

这个我们可以借助reflect-metadata这个库来实现,nest和midway都是使用这个库实现的。

reflect-metadata

介绍

reflect-metadata 是 TypeScript 的一个元编程库,通常与装饰器(decorators)一起使用,用于在运行时提供类型信息、类的元数据、属性元数据等。它允许开发者在编写代码时,能够为类、方法、属性等添加一些元数据,以便在运行时能够访问到这些信息。这对于实现依赖注入、自动验证、序列化等高级功能非常有用。

reflect-metadata使用案例

创建项目

找一个空文件夹执行下面命令创建node项目

npm init -y

安装依赖

pnpm i reflect-metadata
pnpm i ts-node typescript -D

创建tsconfig.json文件

在项目根目录下创建tsconfig.json文件,主要是开启装饰器和元数据。内容如下,

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

创建index.ts文件

import 'reflect-metadata';

const metadataKey = 'test';
// 创建一个装饰器
function Test(path: string) {
  return function (target) {
    // 将元数据添加到目标类上
    Reflect.defineMetadata(metadataKey, path, target);
  }
}


@Test('/home')
class HomeController {
  index() {
    return 'hello home';
  }
}

// 获取元数据
const path = Reflect.getMetadata(metadataKey, HomeController);

console.log(path) // /home 
  • 定义了一个Test装饰器,使用 Reflect.defineMetadata 把传过来的path参数保存到目标类的元数据上。

  • 创建一个HomeController类,使用Test装饰器,装饰器的参数是 /home

  • 使用 Reflect.getMetadata 获取类上面的path属性值。

配置package.json

增加启动项目命令

image.png

启动项目

image.png

可以看到我们取到了Test装饰器传的值

方法装饰器

midway项目中,Get装饰器是作用于方法上的,我们来实现一下方法装饰器。

image.png

image.png

这样虽然可以实现获取方法的元数据,但是要提前知道这个类有哪些方法,这样有点麻烦,那有没有其他方法可以获取到这个类下面所有方法的元数据呢,有的,看下面具体实现。

image.png

把类里的方法元数据都挂到类上面就行了

image.png

构建koa路由

有了这些信息我们就能构建koa路由了。

image.png

先获取Test里配置的path,在获取方法里配置的path,然后拼接起来,在处理函数里new一个HomeController,然后调用对应的方法。

image.png

完整代码

import 'reflect-metadata';

// 创建一个装饰器
function Test(path: string) {
  return function (target) {
    // 将元数据添加到目标类上
    Reflect.defineMetadata('test', path, target);
  }
}

function Get(path: string) {
  return function (target, key) {
    // 获取类里其他方法的元数据
    const funcMetadata = Reflect.getMetadata('get', target) || [];
    // 拼接元数据
    funcMetadata.push({
      funcName: key,
      path
    });
    // 将元数据添加到目标类的方法上
    Reflect.defineMetadata('get', funcMetadata, target);
  }
}


@Test('/home')
class HomeController {
  @Get('/index')
  index() {
    return 'hello home';
  }

  @Get('/list')
  list() {
    return 'hello home';
  }
}

const controllerPath = Reflect.getMetadata('test', HomeController);
const methodPaths = Reflect.getMetadata('get', HomeController.prototype);

const routes = [];
methodPaths.forEach(item => {
  const { funcName, path } = item
  routes.push({
    path: `${controllerPath}${path}`,
    handle: (ctx) => {
      const instance = new HomeController();
      const data = instance[funcName]();
      ctx.body = data;
    }
  })
});

console.log(routes)

实现简易midway框架

根据上面代码,下面来实现一个简易版midway框架。

项目文件夹结构

image.png

  • bootstrap.ts 项目启动文件
  • src/controller 存放controller文件
  • src/decorator 存放装饰器
  • src/utils 存放工具方法

实现Controler装饰器

export const ControllerKey = 'decorator:controller';

export const Controller = (path: string) => {
  return (target) => {
    Reflect.defineMetadata(ControllerKey, path, target);
  };
}

这个代码上面实现过,就不详细解释了。

实现Get装饰器

export const GetKey = 'decorator:get';
export const Get = (path: string) => {
  return (target: any, key: string) => {
    const funcMetadata = Reflect.getMetadata(GetKey, target) || [];
    funcMetadata.push({
      funcName: key,
      path
    });
    Reflect.defineMetadata(GetKey, funcMetadata, target);
  };
}

这个代码在上面也实现过,就不详细解释了。

实现HomeController

import { Controller } from '../decorator/controller';
import { Get } from '../decorator/get';

@Controller('/home')
export default class HomeController {
  @Get('/')
  index() {
    return 'Hello World';
  }
}

生成路由,创建koa服务

import 'reflect-metadata'
import HomeController from './src/controller/home'
import { ControllerKey } from './src/decorator/controller';
import { GetKey } from './src/decorator/get';
import * as Koa from 'koa';
import * as Router from 'koa-router';

const controllerPath = Reflect.getMetadata(ControllerKey, HomeController);
const methodPaths = Reflect.getMetadata(GetKey, HomeController.prototype);

const routes = [];
methodPaths.forEach(item => {
  const { funcName, path } = item;
  routes.push({
    path: `${controllerPath}${path === '/' ? '' : path}`,
    type: 'get',
    handle: (ctx) => {
      const instance = new HomeController();
      const data = instance[funcName]();
      ctx.body = data;
    }
  })
});

const app = new Koa();
const router = new Router();
routes.forEach(route => {
  // router.get('/home/list', async (ctx) => {
  //    const data = list();
  //    ctx.body = data;
  // })
  router[route.type](route.path, route.handle);
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('server is running at http://localhost:3000');
});

启动服务测试

image.png

访问 http://localhost:3000/home ,可以看到输入hello world。

image.png

实现Query装饰器

midway中可以在类方法里通过Query装饰器获取到url参数,我们来实现一下Query装饰器。

image.png

export const QueryKey = 'decorator:query';
export function Query(name?: string) {
  // parameterIndex表示第几个参数
  return function (target: any, propertyKey: string, parameterIndex: number) {
    // 获取现有的参数元数据,如果没有则初始化为空数组
    const existingMetadata: string[] = Reflect.getMetadata(QueryKey, target, propertyKey) || [];
    // 将新元数据添加到指定位置
    existingMetadata[parameterIndex] = name;
    // 保存新的元数据
    Reflect.defineMetadata(QueryKey, existingMetadata, target, propertyKey);
  };
}

改造路由实现方法

image.png

image.png

多个参数,age是number类型

image.png

image.png

Controller里支持多个方法

image.png

image.png

多个Controller

新加一个ApiController

import { Controller } from '../decorator/controller';
import { Get } from '../decorator/get';

@Controller('/api')
export default class ApiController {
  @Get('/user')
  user() {
    return {
      name: 'zhangsan',
      age: 18,
    };
  }
}

改造bootstrap.ts里的方法

import 'reflect-metadata'
import HomeController from './src/controller/home'
import { ControllerKey } from './src/decorator/controller';
import { GetKey } from './src/decorator/get';
import * as Koa from 'koa';
import * as Router from 'koa-router';
import { QueryKey } from './src/decorator/query';
import ApiController from './src/controller/api';

const routes = [];

// 通过类动态创建路由
const createRoutesByClass = (clz) => {
  const controllerPath = Reflect.getMetadata(ControllerKey, clz);
  const methodPaths = Reflect.getMetadata(GetKey, clz.prototype);

  methodPaths.forEach(item => {
    const { funcName, path } = item;
    routes.push({
      path: `${controllerPath}${path === '/' ? '' : path}`,
      type: 'get',
      handle: (ctx) => {
        const instance = new clz();
        const paramKeys = Reflect.getMetadata(QueryKey, clz.prototype, funcName) || [];
        // 获取方法参数类型
        const paramTypes = Reflect.getMetadata('design:paramtypes', clz.prototype, funcName);
        // 从ctx.query中获取参数
        const params = paramKeys.map((item: string, index) => {
          const type = paramTypes[index].name;
          // 如果类型是Number,则转换为Number类型
          if (type === "Number") {
            return Number(ctx.query[item]);
          }
          return ctx.query[item];
        })
        // 按照顺序把参数传给方法
        const data = instance[funcName](...params);
        ctx.body = data;
      }
    })
  });
}

[HomeController, ApiController].forEach(clz => {
  createRoutesByClass(clz);
})


const app = new Koa();
const router = new Router();

routes.forEach(route => {
  // router.get('/home/list', async (ctx) => {
  //    const data = list();
  //    ctx.body = data;
  // })
  router[route.type](route.path, route.handle);
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('server is running at http://localhost:3000');
});

image.png

动态扫描Controller

这样每加一个Controller都需要自己手动引入一下,这样太麻烦了。我们来实现一下动态扫描Controller,不用自己手动添加。

使用glob库,获取某个文件夹下有哪些文件。

pnpm i glob

image.png

再加一个UserController测试一下

image.png

image.png

image.png

整理一下代码,把createRoutesByClass方法抽到utils文件中

import { ControllerKey } from '../decorator/controller';
import { GetKey } from '../decorator/get';
import { QueryKey } from '../decorator/query';

// 通过类动态创建路由
export const createRoutesByClass = (clz: any) => {
  const routes = [];
  const controllerPath = Reflect.getMetadata(ControllerKey, clz);
  const methodPaths = Reflect.getMetadata(GetKey, clz.prototype);

  methodPaths.forEach(item => {
    const { funcName, path } = item;
    routes.push({
      path: `${controllerPath}${path === '/' ? '' : path}`,
      type: 'get',
      handle: (ctx) => {
        const instance = new clz();
        const paramKeys = Reflect.getMetadata(QueryKey, clz.prototype, funcName) || [];
        // 获取方法参数类型
        const paramTypes = Reflect.getMetadata('design:paramtypes', clz.prototype, funcName);
        // 从ctx.query中获取参数
        const params = paramKeys.map((item: string, index) => {
          const type = paramTypes[index].name;
          // 如果类型是Number,则转换为Number类型
          if (type === "Number") {
            return Number(ctx.query[item]);
          }
          return ctx.query[item];
        })
        // 按照顺序把参数传给方法
        const data = instance[funcName](...params);
        ctx.body = data;
      }
    })
  });
  return routes;
}

bootstrap.ts里的代码

import 'reflect-metadata'
import * as Koa from 'koa';
import * as Router from 'koa-router';
import * as glob from 'glob';
import { createRoutesByClass } from './src/utils/utils';

// 获取src目录下所有ts文件
const tsFiles = glob.sync('./src/**/*.ts');

const allRoutes = [];
tsFiles.map(filePath => {
  // 获取类
  const clz = require(`./${filePath}`).default;
  if (!clz) return;
  const routes = createRoutesByClass(clz);
  allRoutes.push(...routes);
});

const app = new Koa();
const router = new Router();

allRoutes.forEach(route => {
  router[route.type](route.path, route.handle);
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, () => {
  console.log('server is running at http://localhost:3000');
});


依赖注入,实现Inject装饰器

在生成的midway demo项目中,看到这样的写法。

image.png

这里导入了userService,但是没有实例化,下面可以直接用userService里的方法。这种写法是一种设计模式,叫做依赖注入。

依赖注入(Dependency Injection,简称 DI)是一种设计模式,旨在减少代码之间的耦合度。通过依赖注入,我们可以将对象的创建和管理交给外部的框架或容器,而不是在对象内部直接创建所依赖的组件。这种做法有助于增强代码的可测试性、可维护性和扩展性。

我们下面来实现一下Inject装饰器

export const InjectKey = 'decorator:inject';

export const Inject = () => {
  return (target: any, propertyKey: string) => {
    // 获取属性的类型,比如一个类属性Name:string,那么type就是string
    const type = Reflect.getMetadata('design:type', target, propertyKey);

    // 获取其他属性的元数据
    const props = Reflect.getMetadata(InjectKey, target) || [];

    // 合并其他属性元数据
    Reflect.defineMetadata(InjectKey, [
      ...props,
      {
        propertyKey,
        type
      }
    ], target);
  };
}

改造createRoutesByClass方法,实例化注入的类,然后赋值给当前属性。

image.png

改造UserController引入UserServcie

import { Controller } from '../decorator/controller';
import { Get } from '../decorator/get';
import { Inject } from '../decorator/inject';
import UserService from '../service/user';

@Controller('/user')
export default class UserController {
  @Inject()
  userService: UserService;
  @Get('/list')
  user() {
    return this.userService.getUserList();
  }
}

UserService实现

export default class UserService {
  getUserList() {
    return [{
      name: 'zhangsan',
      age: 19,
    }];
  }
}

启动项目,测试一下

image.png

实现注入请求上下文

midway中还支持注入请求上下文,不过这个属性名只能是ctx,其他名称不行。

image.png

midway官方文档里有描述

image.png

改造createRoutesByClass方法,判断当属性名为ctx的时候,把请求上下文赋值给这个属性。

image.png

改在UserController类,引入ctx,把传入的query参数,返回回去

image.png

启动项目测试一下

image.png

midway中支持自定义ctx的属性名,这个很简单,我就不在这里实现了,感兴趣的自己实现一下。

image.png

实现嵌套依赖注入

虽然上面实现了依赖注入,但是有嵌套的情况就会有问题了。比如在UserService里再引入其他Service就不行了,因为我们直接new的UserService,没有检查UserService类里依赖注入的属性,所以我们需要对外提供一个newClass函数,来保证每次实例化的时候,都检查一遍,当前类里有没有依赖注入的属性。

// 实例化类,实现注入属性
export const newClass = (clz, ctx) => {
  const instance = new clz();
   // 获取类里注入的属性
   const props = Reflect.getMetadata(InjectKey, clz.prototype) || [];

   props.forEach(prop => {
     const { propertyKey, type } = prop;
     if (propertyKey === 'ctx') {
       instance[propertyKey] = ctx;
     } else {
       // 不用直接用new,需要使用当前方法实例化类
       instance[propertyKey] = newClass(type, ctx);
     }
   });
  
  return instance;
}

把所有实例化对象的地方,全部改造成使用这个方法去实例化

image.png

新加一个TestService

export default class TestService {
  getName() {
    return 'test';
  }
}

改造UserServcie

import { Inject } from '../decorator/inject';
import TestService from './test';

export default class UserService {
  @Inject()
  testService: TestService;
  getUserList() {
    return this.testService.getName();
  }
}

image.png

完善依赖注入,实现Provider装饰器

midway中Inject的类,必须加上Provider装饰器,不然会报错。

我把UserService里的Provider装饰器去掉,然后发现会报错。

image.png

image.png

下面我们来实现一下Provider装饰器

export const ProviderKey = 'decorator:provider';

export const Provider = () => {
  return (target) => {
    // 保存类名
    const className = target.name;
    Reflect.defineMetadata(ProviderKey, className, target);
  };
}

在bootstrap中获取所有的使用Provider装饰器的类名

image.png

在实现newClass的时候,检查要实例化的类是不是在providerClassNames数组中,如果不在,说明注入的类没有加Provider装饰器,模仿midway抛出一个异常。

image.png

image.png

给UserService加上Provider装饰器

image.png

因为UserService使用了TestService,而TestService没有使用Provider,所以上面报错了。给Test Service加上Provider就好了。

实现中间件装饰器

中间件在后端接口开发中,很常用,比如在所有请求前,校验token,如果校验不通过,返回403等。

midway中一个中间件例子,统计当前接口执行时间

image.png

根据上面例子,我们来实现一下中间件装饰器,代码如下

export const MiddlewareKey = 'decorator:middleware';
export function Middleware() {
  return function (target: any) {
    Reflect.defineMetadata(MiddlewareKey, {}, target);
  };
}

改造bootstrap文件,获取中间件类,然后注册为koa中间件

image.png

image.png

改造一下handle方法,因为类里的方法可能是异步的,所以我们再调用类里的方法的时候,前面加上await。

image.png

把midway demo里的记录接口时间的中间件代码复制过来

import { Context } from 'koa';
import { Middleware } from '../decorator/middleware';

@Middleware()
export default class ReportMiddleware {
  resolve() {
    return async (ctx: Context, next: any) => {
      // 控制器前执行的逻辑
      const startTime = Date.now();
      // 执行下一个 Web 中间件,最后执行到控制器
      // 这里可以拿到下一个中间件或者控制器的返回值
      const result = await next();
      // 控制器之后执行的逻辑
      console.log(
        `Report in "src/middleware/report.middleware.ts", rt = ${
          Date.now() - startTime
        }ms`
      );
      // 返回给上一个中间件的结果
      return result;
    };
  }
}

改造UserController,2秒后才返回结果

import { Context } from 'koa';
import { Controller } from '../decorator/controller';
import { Get } from '../decorator/get';
import { Inject } from '../decorator/inject';
import UserService from '../service/user';

@Controller('/user')
export default class UserController {
  @Inject()
  ctx: Context;
  @Inject()
  userService: UserService;
  @Get('/list')
  async user() {
    const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
    await delay(2000);
    return 'hello';
  }
}

访问接口测试一下,中间件里的方法执行了,并且输出的时间也是对的。

image.png

下面我们在中间件中实现一下如果前端传过来的参数没有token字段,返回给前端401。

改造一下中间件

import { Context } from 'koa';
import { Middleware } from '../decorator/middleware';

@Middleware()
export default class ReportMiddleware {
  resolve() {
    return async (ctx: Context, next: any) => {
      if (!ctx.query.token) {
        ctx.status = 401;
        ctx.body = 'token is required';
        return;
      }

      // 执行下一个中间件或路由处理程序
      return await next();
    };
  }
}

image.png

image.png

再实现往ctx里面注入参数,然后在Controller里面使用这个参数。

image.png

image.png

image.png

最后

到此一个非常简易的midway框架实现了,大家看到这里应该也了解了依赖注入的实现原理,其实主要依靠reflect-metadata库来实现的。

文中代码已经上传到github,感兴趣的同学可以基于这个实现midway其他功能。

github.com/dbfu/mini-m…

最后祝大家新年快乐,万事如意,心想事成。