前言
使用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
安装依赖
pnpm i
启动项目
npm run dev
测试接口
启动完项目后,在浏览器中输入http://127.0.0.1:7001/
分析代码
启动项目后,访问 http://127.0.0.1:7001 地址,相当于调用src/controller/home.controller.ts
里的home方法。
把controller地址改一下,重启一下服务
再次访问 http://127.0.0.1:7001/ 就会报错了,因为那个路径不存在了,访问http://127.0.0.1:7001/home 就行了。
新加一个接口
访问 http://127.0.0.1:7001/home/list
给大家举上面例子,是想告诉大家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/home 和 http://127.0.0.1:3001/home/list 就能发现实现了和上面一样的需求。
midway和koa的关系
还记得在初始化midway项目的时候,要选一个模板,我们选的是koa。那midway和koa是什么关系呢?
答:midway使用koa启动的http服务,然后收集Controller和Get装饰器,动态生成koa路由。nest原理和这个差不多,只不过它使用的是express。
下面就带着大家实现Controller和Get装饰器。
简易版midway实现思路
观察一下上面代码,我们只需要把解析出项目里所有Controller和Get装饰器里配置的url,然后把Controller的url和Get配置的url拼接起来,生成Koa路由url参数,Get装饰器下面的方法就是对应路由的具体实现。
想办法把上面代码转换为下面代码就行了
这个我们可以借助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
增加启动项目命令
启动项目
可以看到我们取到了Test装饰器传的值
方法装饰器
midway项目中,Get装饰器是作用于方法上的,我们来实现一下方法装饰器。
这样虽然可以实现获取方法的元数据,但是要提前知道这个类有哪些方法,这样有点麻烦,那有没有其他方法可以获取到这个类下面所有方法的元数据呢,有的,看下面具体实现。
把类里的方法元数据都挂到类上面就行了
构建koa路由
有了这些信息我们就能构建koa路由了。
先获取Test里配置的path,在获取方法里配置的path,然后拼接起来,在处理函数里new一个HomeController,然后调用对应的方法。
完整代码
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框架。
项目文件夹结构
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');
});
启动服务测试
访问 http://localhost:3000/home ,可以看到输入hello world。
实现Query装饰器
midway中可以在类方法里通过Query装饰器获取到url参数,我们来实现一下Query装饰器。
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);
};
}
改造路由实现方法
多个参数,age是number类型
Controller里支持多个方法
多个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');
});
动态扫描Controller
这样每加一个Controller都需要自己手动引入一下,这样太麻烦了。我们来实现一下动态扫描Controller,不用自己手动添加。
使用glob库,获取某个文件夹下有哪些文件。
pnpm i glob
再加一个UserController测试一下
整理一下代码,把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项目中,看到这样的写法。
这里导入了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方法,实例化注入的类,然后赋值给当前属性。
改造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,
}];
}
}
启动项目,测试一下
实现注入请求上下文
midway中还支持注入请求上下文,不过这个属性名只能是ctx,其他名称不行。
midway官方文档里有描述
改造createRoutesByClass方法,判断当属性名为ctx的时候,把请求上下文赋值给这个属性。
改在UserController类,引入ctx,把传入的query参数,返回回去
启动项目测试一下
midway中支持自定义ctx的属性名,这个很简单,我就不在这里实现了,感兴趣的自己实现一下。
实现嵌套依赖注入
虽然上面实现了依赖注入,但是有嵌套的情况就会有问题了。比如在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;
}
把所有实例化对象的地方,全部改造成使用这个方法去实例化
新加一个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();
}
}
完善依赖注入,实现Provider装饰器
midway中Inject的类,必须加上Provider装饰器,不然会报错。
我把UserService里的Provider装饰器去掉,然后发现会报错。
下面我们来实现一下Provider装饰器
export const ProviderKey = 'decorator:provider';
export const Provider = () => {
return (target) => {
// 保存类名
const className = target.name;
Reflect.defineMetadata(ProviderKey, className, target);
};
}
在bootstrap中获取所有的使用Provider装饰器的类名
在实现newClass的时候,检查要实例化的类是不是在providerClassNames数组中,如果不在,说明注入的类没有加Provider装饰器,模仿midway抛出一个异常。
给UserService加上Provider装饰器
因为UserService使用了TestService,而TestService没有使用Provider,所以上面报错了。给Test Service加上Provider就好了。
实现中间件装饰器
中间件在后端接口开发中,很常用,比如在所有请求前,校验token,如果校验不通过,返回403等。
midway中一个中间件例子,统计当前接口执行时间
根据上面例子,我们来实现一下中间件装饰器,代码如下
export const MiddlewareKey = 'decorator:middleware';
export function Middleware() {
return function (target: any) {
Reflect.defineMetadata(MiddlewareKey, {}, target);
};
}
改造bootstrap文件,获取中间件类,然后注册为koa中间件
改造一下handle方法,因为类里的方法可能是异步的,所以我们再调用类里的方法的时候,前面加上await。
把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';
}
}
访问接口测试一下,中间件里的方法执行了,并且输出的时间也是对的。
下面我们在中间件中实现一下如果前端传过来的参数没有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();
};
}
}
再实现往ctx里面注入参数,然后在Controller里面使用这个参数。
最后
到此一个非常简易的midway框架实现了,大家看到这里应该也了解了依赖注入的实现原理,其实主要依靠reflect-metadata
库来实现的。
文中代码已经上传到github,感兴趣的同学可以基于这个实现midway其他功能。
最后祝大家新年快乐,万事如意,心想事成。