美文网首页
从零开发ts装饰器管理koa路由

从零开发ts装饰器管理koa路由

作者: 超人鸭 | 来源:发表于2022-05-31 18:18 被阅读0次

    前言

    两年前刚学ts,当时搭了个简单的koa的demo,介绍了如何用装饰器管理koa的路由:TS装饰器初体验,用装饰器管理koa接口
    但是当时还只是demo学习,并没有真正在公司的项目中使用起来,后面博主搭建开发公司真正的koa项目中,一开始并没有使用到装饰器这个语法来管理路由,还是传统的函数方式,随着模块、接口的累加,越来越觉得传统的路由开发方式不满足于复杂业务的开发,最后结合之前写的demo重新设计了一套装饰器管理路由的模块,将接口全部修改为装饰器管理。
    下面我将对比两种路由开发方式,介绍如何使用装饰器来更好的管理koa的路由。

    至于如何搭建ts+koa项目,可以参考网上的其他教程,这里不展开了。

    koa入口文件:


    image.png

    创建koa实例,使用了基础的中间件,并监听端口。

    传统路由模式

    文件结构

    先看一下文件结构:

    image.png
    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. 无法对项目的全部路由统一添加前缀
    3. 多个中间件之间无法进行数据传递,无法感知其他中间件的使用,完全隔离
    4. 无法对一个模块的接口统一添加中间件

    1、2点非常明显。
    第三点对应的是上面的 validateParams 中间件,需要传入请求方法和joi校验规则,因为不同的请求方法,koa在拿参数的方式不同,这里就体现了在使用 validateParams 中间件时,无法感知当前接口是什么请求方法,需要手动传入。
    第四点对应的是上面的 commonMiddleware,当每个接口都需要使用到时,传统写法只能一个个接口进行添加,无法对整个模块进行使用。

    真正驱动我设计装饰器去管理路由是第四点,真实业务有很多场景需要对整个模块进行统一添加中间件,比如:登录校验、权限校验,每一个接口都加一遍的操作鸭子都忍不了。

    在替换为装饰器管理后,上面列举的传统路由的不足之处全部解决,并且发现新的优点:

    1. 写法更清晰,更容易维护
    2. 更灵活、更加容易拓展功能,复杂功能实现起来更简单
    3. 更能体现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
      }
    }
    

    打印结果:


    image.png

    装饰器执行顺序与工厂模式

    每一个方法或者每一个类都可以添加多个装饰器,同个方法或同个类上的装饰器的执行顺序为由近至远,越靠近被附加方法的装饰器先执行,也就是从下往上的。
    一个类上同时有类装饰器与方法装饰器的情况,先执行方法装饰器,再执行类装饰器。

    对于装饰器,传入的参数是固定的,如果想对其实现一些不同的功能,可以通过工厂模式,也就是一个函数里面再返回装饰器函数。

    下面是为了体现执行顺序和工厂模式的例子:

    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

    image.png
    创建同级的装饰器路由文件夹,在 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)
    

    不同的方法对应不同的装饰器,传入请求路径,使用工厂模式。
    先创建文件:

    image.png
    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之后这些不同的实例并无区别,都是对请求路径进行判断处理。

    在使用装饰器之后,我们完全可以只创建一个路由实例,不同的模块唯一的区别只是请求路径前缀不同而已,注册过程并没有区别。

    首先在一个文件上创建路由实例并导出:

    image.png
    routerInstance.ts
    import Router from 'koa-router'
    
    const router = new Router()
    
    router.allowedMethods()
    
    export default router
    

    接下来开发类装饰器:

    image.png

    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可以安装一个插件:

    image.png

    可以用它来发送http请求,具体使用方法参考网上的教程
    下面是结果:

    image.png

    接口正常响应。

    开发中间件装饰器

    回到我们的路由文件 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
        }
      }
    }
    

    到这里只是完成了基础的路由逻辑,并没有包含中间件,中间件说白了就是在路由主逻辑前执行的一个函数,同样的,我们可以将这个中间件函数定义在方法的元数据中,然后在最后的类装饰器将方法取出来,插入至路由注册中。

    通用中间件装饰器

    创建使用中间件装饰器:

    image.png
    // 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 校验规则两个参数,前面分析传统写法的一个短板就是不同的中间件之间,数据无法传递,现在使用了装饰器写法,并将相关信息定义在方法的元数据上,那么同个方法的中间件通过获取元数据就可以达到数据传递的目的,那么在参数校验中间件上就能实现拿到接口的请求方法。
    而参数校验基本每个接口都需要用到,所以它值得我去开发一个装饰器:

    image.png
    // 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 这个元数据
    新建装饰器文件:

    image.png
    // 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 中定义了,效果:

    image.png

    总结

    上面已经介绍了几个装饰器,也是我日常开发中使用频率最高的。装饰器写法对比传统写法的好处已经显而易见了,一些基于 koa 封装的框架都是使用装饰器进行管理,这里我选择自己从零开始设计开发装饰器,也是为了更加灵活的使用,满足自己所需要的功能。除了上面所说的几个装饰器,装饰器还可以实现更多复杂的功能,这里就不展开。

    如果你有更好的见解和用法,欢迎指教。

    作者微信:Promise_fulfilled

    相关文章

      网友评论

          本文标题:从零开发ts装饰器管理koa路由

          本文链接:https://www.haomeiwen.com/subject/gsnwdrtx.html