从零开发ts装饰器管理koa路由
从零开发ts装饰器管理koa路由
前言
两年前刚学ts,当时搭了个简单的koa的demo,介绍了如何用装饰器管理koa的路由:TS装饰器初体验,用装饰器管理koa接口
但是当时还只是demo学习,并没有真正在公司的项目中使用起来,后面博主搭建开发公司真正的koa项目中,一开始并没有使用到装饰器这个语法来管理路由,还是传统的函数方式,随着模块、接口的累加,越来越觉得传统的路由开发方式不满足于复杂业务的开发,最后结合之前写的demo重新设计了一套装饰器管理路由的模块,将接口全部修改为装饰器管理。
下面我将对比两种路由开发方式,介绍如何使用装饰器来更好的管理koa的路由。
至于如何搭建ts+koa项目,可以参考网上的其他教程,这里不展开了。
koa入口文件:
创建koa实例,使用了基础的中间件,并监听端口。
传统路由模式
文件结构
先看一下文件结构:
src/server.ts
为入口文件
路由文件
这里创建了一个 standard-router
文件夹代表传统路由,在test.ts中编写传统路由,通常一个文件代表一个模块src/standard-router/test.ts
:
import Router from 'koa-router'
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
const router = new Router({
prefix: '/standard-test'
})
router.allowedMethods()
router.get(
'/name',
commonMiddleware,
validateParams('get', testSchema),
async (ctx, next) => {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
)
router.post(
'/name',
commonMiddleware,
validateParams('post', testSchema),
async (ctx, next) => {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
)
export default router
这就是test.ts的全部内容,也是传统路由的写法,在真正的接口处理方法前可以插入中间件,比如日志打印、参数校验等等。
相关中间件介绍
下面介绍一下引入的几个方法,首先是最上面的 commonMiddleware
,这里我用来表示基本每个路由都会使用的中间件:
// src/middlewares/common.ts
import { RouterCtx, MiddleNext } from '../utils/types'
async function commonMiddleware(ctx: RouterCtx, next: MiddleNext ) {
console.log('common middleware')
await next()
}
export default commonMiddleware
然后是 validateParams
,这是一个参数校验的中间件,是一个工厂函数,传入请求方法和 Joi
校验规则:
// src/middlewares/validateParams.ts
import { RouterCtx, MiddleNext } from '../utils/types'
import Joi from 'joi'
import { ErrorModel } from '../utils/ResModel'
import { paramsErrorInfo } from '../utils/ErrorInfo'
function genValidateParams(method: string, schema: Joi.Schema) {
async function validateParams(ctx: RouterCtx, next: MiddleNext ) {
let data: any
if (method === 'get') {
data = ctx.request.query
} else {
data = ctx.request.body
}
const { error } = schema.validate(data)
if (error) {
ctx.body = new ErrorModel({
...paramsErrorInfo,
message: error.message || paramsErrorInfo.message
})
return
}
await next()
}
return validateParams
}
export default genValidateParams
关于在koa中如何使用Joi进行参数校验,博主在之前的文章已经进行了介绍:koa中使用joi进行参数校验
统一引入
这样一个文件也就是一个模块就需要创建一个 koa-router
的实例,需要将这个路由实例挂载至koa的实例上,传统的做法是在入口文件将每一个文件引入,再使用 app.use()
进行挂载。这里可以对整个文件夹进行统一引入,封装挂载的方法。
在standard-router
文件夹下的 index.ts
中进行封装:
// src/standard-router/index.ts
import fs from 'fs'
import Koa from 'koa'
import Router from 'koa-router'
type RouterFile = {
default: Router<any, {}>
}
const useRoutes = (app: Koa) => {
fs.readdirSync(__dirname).forEach(file => {
if (file.indexOf('index') === 0) return
import(`./${file}`)
.then((res: RouterFile) => {
const router = res.default
app.use(router.routes())
})
.catch(e => {
console.error(e)
})
})
}
export default useRoutes
然后在入口文件 server.ts
中引入并调用,传入koa实例:
...
...
import useRoutes from './standard-router'
...
...
useRoutes(app)
app.listen(5200)
分析传统写法的短板
上面写了一个很简单的test.ts
的路由模块,可能你会觉得很清晰阿,想要什么功能都能实现,这是肯定的,这可是官方的写法,但是实现和开发成本又是另外一回事,特别是当业务复杂起来后,在真正的业务上,一个模块不可能只有两个简单的接口,根据上面的test.ts
可以分析传统写法的短板,重点是我引入的两个中间件上:
- 多个模块需要创建多个路由实例
- 无法对项目的全部路由统一添加前缀
- 多个中间件之间无法进行数据传递,无法感知其他中间件的使用,完全隔离
- 无法对一个模块的接口统一添加中间件
1、2点非常明显。
第三点对应的是上面的 validateParams
中间件,需要传入请求方法和joi校验规则,因为不同的请求方法,koa在拿参数的方式不同,这里就体现了在使用 validateParams
中间件时,无法感知当前接口是什么请求方法,需要手动传入。
第四点对应的是上面的 commonMiddleware
,当每个接口都需要使用到时,传统写法只能一个个接口进行添加,无法对整个模块进行使用。
真正驱动我设计装饰器去管理路由是第四点,真实业务有很多场景需要对整个模块进行统一添加中间件,比如:登录校验、权限校验,每一个接口都加一遍的操作鸭子都忍不了。
在替换为装饰器管理后,上面列举的传统路由的不足之处全部解决,并且发现新的优点:
- 写法更清晰,更容易维护
- 更灵活、更加容易拓展功能,复杂功能实现起来更简单
- 更能体现ts类型校验的优势
装饰器语法介绍
ts装饰器官方文档
先看官方的解释:
装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。 装饰器使用
@expression
这种形式,expression
求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。
注意这里的运行时被调用,这里指的是文件运行,而不是被附加的方法或者类调用时才调用,也就是引入文件就会执行相关的装饰器方法。
根据上面的描述知道,装饰器是一个函数,定义在不同的变量上会有不同的效果,主要是传入的参数不同。
上面说到每个路由文件是一个单独模块,里面每一个接口都属于这个模块,刚好对应到类和类的方法这两者的关系,所以设计的装饰器我只用到了类装饰器和方法装饰器,下面也只介绍这两种装饰器。
在编写装饰器语法的时候,需要在项目的 tsconfig.json
中添加配置:
{
"compilerOptions": {
...
"experimentalDecorators": true
...
}
}
类装饰器
类装饰器只有一个参数,那就是类本身,也就是构造函数,ts的class编译后就是es5的构造函数。
看一个简单的例子:
function classDecorator(target: new (...args: any[]) => any) {
target.prototype.getName = function () {
return this.age
}
}
@classDecorator
class Test {
name = '超人鸭'
age = 18
getName() {
return this.name
}
}
const test = new Test()
console.log(test.getName()) // 18
这里通过装饰器改变了 getName
这个方法,class的方法编译后全部在构造函数的 prototype
上,对这个不熟的可以复习一下es5的原型和原型链,然后看一下ts文件编译后的js代码。
方法装饰器
方法装饰器有三个参数,分别是构造函数的prototype
,方法的名称,方法在 prototype
的属性描述符也就是使用 Object.getOwnPropertyDescriptor()
获取属性描述对象
function fnDecorator(target: any, key: string, desc: any) {
console.log(key)
console.log(desc)
console.log(Object.getOwnPropertyDescriptor(target, key))
}
class Test {
name = '超人鸭'
age = 18
@fnDecorator
getName() {
return this.name
}
}
打印结果:
装饰器执行顺序与工厂模式
每一个方法或者每一个类都可以添加多个装饰器,同个方法或同个类上的装饰器的执行顺序为由近至远,越靠近被附加方法的装饰器先执行,也就是从下往上的。
一个类上同时有类装饰器与方法装饰器的情况,先执行方法装饰器,再执行类装饰器。
对于装饰器,传入的参数是固定的,如果想对其实现一些不同的功能,可以通过工厂模式,也就是一个函数里面再返回装饰器函数。
下面是为了体现执行顺序和工厂模式的例子:
function classDecorator(str: string) {
return function (target: new (...args: any[]) => any) {
console.log(str)
}
}
function fnDecorator(str: string) {
return function (target: any, key: string, desc: any) {
console.log(str)
}
}
@classDecorator('class 2')
@classDecorator('class 1')
class Test {
@fnDecorator('fn 2')
@fnDecorator('fn 1')
getName() {
return '超人鸭'
}
}
// 打印顺序为:fn 1 、 fn 2 、 class 1 、 class 2
reflect-metadata
上面介绍了装饰器的用法,如果单纯依靠装饰器的语法特点,还不足以对类与方法做更多操作,还需要其他功能来辅助操作。
reflect-metadata
意思为元数据,可以为对象或对象的属性定义元数据,先来看下这个库如何使用
使用前需要先安装:
npm install reflect-metadata --save
使用这个库的时候只需引入即可
首先定义元数据:
import 'reflect-metadata'
const obj = {
name: '超人鸭'
}
Reflect.defineMetadata('objMetadata', 'object metadata', obj)
Reflect.defineMetadata('propertyMetadata', 'property metadata', obj, 'name')
上面的代码就是为对象和对象上的一个属性添加了元数据,下面看一下定义的语法:
如果是对象:
Reflect.defineMetadata(metadataKey, metadataValue, target)
如果是对象上的属性:
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey)
第一个参数为定义元数据的key;第二个参数为定义元数据的value;第三个参数为定义的对象;如果是定义在属性上面,就需要第四个参数,为属性名称。
下面看一下如何定义完数据后如何取数据:
import 'reflect-metadata'
const obj = {
name: '超人鸭'
}
Reflect.defineMetadata('objMetadata', 'object metadata', obj)
Reflect.defineMetadata('propertyMetadata', 'property metadata', obj, 'name')
const objMetadata = Reflect.getMetadata('objMetadata', obj)
const propertyMetadata = Reflect.getMetadata('propertyMetadata', obj, 'name')
console.log(objMetadata, propertyMetadata) // object metadata property metadata
语法为:
如果是对象:
Reflect.getMetadata('metadataKey', 'target')
如果是对象上的属性:
Reflect.getMetadata('metadataKey', 'target', 'propertyKey')
原理:'reflect-metadata'
是在内部定义了一个 weakmap
将对象和定义的值做了映射
大致过程如下:
const obj = {
name: '超人鸭'
}
Reflect.defineMetadata('objMetadata', 'object metadata', obj)
const weakmap = new WeakMap()
const metadata = new Map()
metadata.set('objMetadata', 'object metadata')
weakmap.set(obj, metadata)
如果是定义在对象的属性上面,那就再多一层map做映射:
const obj = {
name: '超人鸭'
}
Reflect.defineMetadata('propertyMetadata', 'property metadata', obj, 'name')
const weakmap = new WeakMap()
const metadata = new Map()
const metadataMap = new Map()
metadata.set('objMetadata', 'object metadata')
metadataMap.set('name', metadata)
weakmap.set(obj, metadataMap)
这便是 reflect-metadata
的作用和用法,将它和装饰器语法再结合类与方法的关系,就可以实现管理整个模块路由的功能。
装饰器管理koa路由
思路
先回顾一下传统写法:
import Router from 'koa-router'
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
const router = new Router({
prefix: '/standard-test'
})
router.allowedMethods()
router.get(
'/name',
commonMiddleware,
validateParams('get', testSchema),
async (ctx, next) => {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
)
router.post(
'/name',
commonMiddleware,
validateParams('post', testSchema),
async (ctx, next) => {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
)
export default router
如何将这个路由模块改为装饰器模式呢。
koa的接口开发可以理解为就是路由定义,定义好这个路由的请求方法、请求路径、执行的中间件、接口主逻辑,改造成装饰器的最后也是要实现这个功能,路由定义。
每一个路由文件代表一个模块,文件下的每一个接口都属于这个模块,对应类与类的方法这个关系。
然后这个方法我们只处理接口的主逻辑,请求方法、请求路径、中间件我们都放到装饰器去处理,这些信息可以通过元数据定义到方法上。
然后利用装饰器的执行顺序,先方法然后再是类,我们可以在类的装饰器取到所有经过装饰器处理的方法,统一进行路由注册。
大概是这个思路,下面我将一步步实现。
将传统写法改造成class
创建同级的装饰器路由文件夹,在
test.ts
中编写改造的路由:
// src/decorator-router/test.ts
import Router from 'koa-router'
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
import { RouterCtx } from '../utils/types'
class TestRouteModule {
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
async postName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
}
现在只是定义了class,还没有任何功能。
请求方法装饰器
下面开发一个装饰器,实现请求方法和请求路径的定义,实现效果为:
@get(path)
@post(path)
不同的方法对应不同的装饰器,传入请求路径,使用工厂模式。
先创建文件:
在
request.ts
中编写:
// src/decorator/request.ts
function get(path: string){
// 往方法上存上路径与请求方法
return function (target: any, key: string) {
Reflect.defineMetadata('path', path, target, key)
Reflect.defineMetadata('method', 'get', target, key)
}
}
function post(path: string){
return function (target: any, key: string) {
Reflect.defineMetadata('path', path, target, key)
Reflect.defineMetadata('method', 'post', target, key)
}
}
将请求方法、请求路径定义到方法的元数据上,上面的代码可以再做一层封装,将 get、post
当成参数传入,相当于再包一层工厂函数:
// src/decorator/request.ts
import 'reflect-metadata'
function genRequestDecorator(type: string) {
return function (path: string) {
return function (target: any, key: string) {
Reflect.defineMetadata('path', path, target, key)
Reflect.defineMetadata('method', type, target, key)
}
}
}
export const get = genRequestDecorator('get')
export const post = genRequestDecorator('post')
外部的使用方式没变
在 decorator/index.ts
中统一导出:
// src/decorator/index.ts
export * from './request'
在 decorator-router/test.ts
中引入并使用:
...
import { get, post } from '../decorator/index'
class TestRouteModule {
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
到这里也只是把请求方法、请求路径定义到方法的元数据上,还没有将它们取出来并注册路由。
类装饰器完成路由注册
根据装饰器的特点,先执行方法装饰器,再执行类装饰器,我们在上面已经引入了方法装饰器,在执行类装饰器的时候,相关的信息已经添加至方法的元数据。然后类的装饰器的参数就是构造函数,类上的方法在存在构造函数的 prototype
上,所以我们在类装饰器中,通过参数同样可以取得定义在方法上的元数据,包括请求方法、请求路径,还有方法本身。
一个最基本的路由定义为:
router[method](path, handler)
这三个信息都可以拿到,所以在这里就可以完成路由注册。
在这之前,回忆一下传统路由的写法,每一个文件都需要创建一个新的路由实例,但是最后被 koa
实例use
之后这些不同的实例并无区别,都是对请求路径进行判断处理。
在使用装饰器之后,我们完全可以只创建一个路由实例,不同的模块唯一的区别只是请求路径前缀不同而已,注册过程并没有区别。
首先在一个文件上创建路由实例并导出:
routerInstance.ts
:
import Router from 'koa-router'
const router = new Router()
router.allowedMethods()
export default router
接下来开发类装饰器:
在 decorator
下创建 controller.ts
文件:
import 'reflect-metadata'
import router from '../routerInstance'
export function controller(root: string) {
return function (target: new (...args: any[]) => any) {
const handlerKeys = Object.getOwnPropertyNames(target.prototype).filter(
key => key !== 'constructor'
)
handlerKeys.forEach(key => {
const path: string = Reflect.getMetadata('path', target.prototype, key)
const method: string = Reflect.getMetadata(
'method',
target.prototype,
key
)
const handler = target.prototype[key]
if (path && method) {
const fullPath = root === '/' ? path : `${root}${path}`
router[method](fullPath, handler)
}
})
}
}
这个类装饰器允许传入一个参数,代表模块路由的前缀。
通过 Object.getOwnPropertyNames
取得类上的所有方法,因为经过编译之后,类上的方法在构造函数的 prototype
上的属性描述是不可枚举的,没有办法通过 for in
来获取,这个可能不同的 typescript
版本表现会有不同。
然后通过 Reflect.getMetadata
取得之前在方法装饰器上定义的信息,最后完成路由注册。
同样的,在decorator/index.ts
导出:
// src/decorator/index.ts
export * from './request'
export * from './controller'
然后回到路由文件decorator-router/test.ts
中引入并使用
...
import { get, post, controller } from '../decorator/index'
@controller('/decorator-test')
class TestRouteModule {
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
引入文件使装饰器执行
上面在装饰器中完成了路由注册,但是这些装饰器还没执行,现在需要让它们执行起来,我们只需要将路由文件引入就可以,在 decorator-router/index.ts
中统一导入
// src/decorator-router/index.ts
import fs from 'fs'
fs.readdirSync(__dirname).forEach(file => {
if (file.indexOf('index') === 0) return
import(`./${file}`)
})
然后在入口文件中引入此文件还有路由实例:src/server.ts
import Koa from 'koa'
import json from 'koa-json'
import koaBody from 'koa-body'
import logger from 'koa-logger'
import useRoutes from './standard-router'
import './decorator-router/index' // 引入装饰器路由文件,使装饰器运行
import router from './routerInstance' // 引入路由实例
const app = new Koa()
// middlewares
app.use(
koaBody({
multipart: true
})
)
app.use(json())
app.use(logger())
useRoutes(app)
app.use(router.routes()) // 挂载路由实例
app.listen(5200)
引入后就完成了路由注册,这里我们可以测试一下:
vscode可以安装一个插件:
可以用它来发送http请求,具体使用方法参考网上的教程
下面是结果:
接口正常响应。
开发中间件装饰器
回到我们的路由文件 decorator-router/test.ts
:
import Router from 'koa-router'
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
import { RouterCtx } from '../utils/types'
import { get, post, controller } from '../decorator/index'
@controller('/decorator-test')
class TestRouteModule {
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
到这里只是完成了基础的路由逻辑,并没有包含中间件,中间件说白了就是在路由主逻辑前执行的一个函数,同样的,我们可以将这个中间件函数定义在方法的元数据中,然后在最后的类装饰器将方法取出来,插入至路由注册中。
通用中间件装饰器
创建使用中间件装饰器:
// src/decorator/use.ts
import 'reflect-metadata'
import { RouterCtx, MiddleNext } from '../utils/types'
export function use(
middleware: (ctx: RouterCtx, next: MiddleNext) => Promise<any>,
position: 'last' | number = 'last'
) {
return function (target: any, key: string) {
const middlewares = Reflect.getMetadata('middlewares', target, key) || []
if (position === 'last') {
middlewares.push(middleware)
} else {
middlewares.splice(position, 0, middleware)
}
Reflect.defineMetadata('middlewares', middlewares, target, key)
}
}
传入一个中间件函数,通常通过装饰器挂载的顺序来决定中间件执行的顺序,但还是拓展了第二个参数,支持添加至任意位置来控制中间件执行的顺序。
定义 middlewares
元数据代表中间件函数
然后改造类装饰器 controller
,添加中间件注册逻辑:
// // src/decorator/controller.ts
import 'reflect-metadata'
import router from '../routerInstance'
export function controller(root: string) {
return function (target: new (...args: any[]) => any) {
const handlerKeys = Object.getOwnPropertyNames(target.prototype).filter(
key => key !== 'constructor'
)
handlerKeys.forEach(key => {
const path: string = Reflect.getMetadata('path', target.prototype, key)
const method: string = Reflect.getMetadata(
'method',
target.prototype,
key
)
const handler = target.prototype[key]
const middlewares =
Reflect.getMetadata('middlewares', target.prototype, key) || [] // 取出中间件
if (path && method) {
const fullPath = root === '/' ? path : `${root}${path}`
router[method](fullPath, ...middlewares, handler) // 注册进去
}
})
}
}
同样在 decorator/index.ts
中导出 use
装饰器,路由文件引入并使用:
// decorator-router/test.ts
import commonMiddleware from '../middlewares/common'
import validateParams from '../middlewares/validateParams'
import { testSchema } from '../validator/test'
import { RouterCtx } from '../utils/types'
import { get, post, controller, use } from '../decorator/index'
@controller('/decorator-test')
class TestRouteModule {
@use(validateParams('get', testSchema))
@use(commonMiddleware)
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@use(validateParams('post', testSchema))
@use(commonMiddleware)
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
参数校验中间件装饰器
我封装的参数校验中间件需要传入请求方法和 Joi
校验规则两个参数,前面分析传统写法的一个短板就是不同的中间件之间,数据无法传递,现在使用了装饰器写法,并将相关信息定义在方法的元数据上,那么同个方法的中间件通过获取元数据就可以达到数据传递的目的,那么在参数校验中间件上就能实现拿到接口的请求方法。
而参数校验基本每个接口都需要用到,所以它值得我去开发一个装饰器:
// src/decorator/validate.ts
import 'reflect-metadata'
import Joi from 'joi'
import genValidateParams from '../middlewares/validateParams'
export function validate(schema: Joi.Schema) {
return function (target: any, key: string) {
const method = Reflect.getMetadata('method', target, key)
const validateParamsMiddleware = genValidateParams(method, schema)
const middlewares = Reflect.getMetadata('middlewares', target, key) || []
middlewares.push(validateParamsMiddleware)
Reflect.defineMetadata('middlewares', middlewares, target, key)
}
}
在这里就可以取到在 request
装饰器定义的请求方法元数据,同样是操作 middlewares
这个元数据
同样在 index.ts
导出
回到路由文件,进行改造:
import commonMiddleware from '../middlewares/common'
import { testSchema } from '../validator/test'
import { RouterCtx } from '../utils/types'
import { get, post, controller, use, validate } from '../decorator/index'
@controller('/decorator-test')
class TestRouteModule {
@validate(testSchema)
@use(commonMiddleware)
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@validate(testSchema)
@use(commonMiddleware)
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
更加清晰且少传一个手写的参数
对模块接口统一添加中间件装饰器
上面的代码中,我写了一个 commonMiddleware
来表示每个接口都需要添加的中间件,目前是每个接口都添加了一遍。
如果想要为模块的每一个接口都统一添加,就需要拿到类上的每一个方法,那么这个装饰器就应该添加类上面,同样的,操作中间件还是操作 middlewares
这个元数据
新建装饰器文件:
// src/decorator/unifyUse.ts
import 'reflect-metadata'
import { RouterCtx, MiddleNext } from '../utils/types'
/**
* 对同一个路由模块统一添加中间件
* @param middleware 中间件函数
* @param excludes 排除的路由
* @param inLast 是否添加在最后,默认塞在最前面
*/
export function unifyUse<T extends string>(
middleware: (ctx: RouterCtx, next: MiddleNext) => Promise<any>,
excludes: Array<T> = [],
inLast = false
) {
return function (target: new (...args: any[]) => any) {
const handlerKeys = Object.getOwnPropertyNames(target.prototype).filter(
key => key !== 'constructor'
)
handlerKeys.forEach(key => {
if (!excludes.includes(key as T)) {
const middlewares =
Reflect.getMetadata('middlewares', target.prototype, key) || []
if (inLast) {
middlewares.push(middleware)
} else {
middlewares.unshift(middleware)
}
Reflect.defineMetadata(
'middlewares',
middlewares,
target.prototype,
key
)
}
})
}
}
首先第一个参数传入需要统一添加的中间件函数。
第二个参数传入不需要添加这个中间件的方法名称集合,可能会有些接口是特殊处理的,需要在统一添加的时候排除掉。
第三个参数可以指定统一添加的中间件在最后执行,默认添加在第一位,通常需要统一添加的中间件都是最先执行,比如登录校验、日志打印等。
回到这个函数的类型定义上,可以接收一个泛型,主要是第二个参数:排除的方法名称用到,表示传入的方法名必须符合传入的泛型类型,在引入的地方会传入。
同样的,在 index.ts
中导出
回到路由文件,进行改造:
// src/decorator-router/test.ts
import commonMiddleware from '../middlewares/common'
import { testSchema } from '../validator/test'
import { RouterCtx } from '../utils/types'
import {
get,
post,
controller,
use,
validate,
unifyUse
} from '../decorator/index'
/** 装饰器router clsss */
export type RouterController<T extends string> = {
[key in T]: (ctx: RouterCtx) => Promise<void>
}
type MethodName = 'getName' | 'postName'
@controller('/decorator-test')
@unifyUse<MethodName>(commonMiddleware)
class TestRouteModule implements RouterController<MethodName> {
@validate(testSchema)
@get('/name')
async getName(ctx: RouterCtx) {
const { name } = ctx.request.query
ctx.body = {
name: name
}
}
@validate(testSchema)
@post('/name')
async postName(ctx: RouterCtx) {
const { name } = ctx.request.body
ctx.body = {
name: name
}
}
}
将 unifyUse
附加到类上面,注意执行顺序,controller
装饰器必须在最后执行。
传入了 commonMiddleware
表示该模块的所有接口都引入这个中间件。
同时定义了 MethodName
类型与 RouterController
类型,我们的类去实现 RouterController
这个类型,表示我们的类必须实现 MethodName
所定义的方法名称的方法,然后将 MethodName
传递给 unifyUse
函数,如果此时需要传入第二个参数,表示排除掉传入的方法,那么传入的方法名称就必须在 MethodName
中定义了,效果:
总结
上面已经介绍了几个装饰器,也是我日常开发中使用频率最高的。装饰器写法对比传统写法的好处已经显而易见了,一些基于 koa 封装的框架都是使用装饰器进行管理,这里我选择自己从零开始设计开发装饰器,也是为了更加灵活的使用,满足自己所需要的功能。除了上面所说的几个装饰器,装饰器还可以实现更多复杂的功能,这里就不展开。
如果你有更好的见解和用法,欢迎指教。
作者微信:Promise_fulfilled