手把手带你实现装饰器路由

avatar
公众号「 微医大前端技术 」

徐帅武,微医云服务团队前端工程师。一个爱折腾、爱做菜的前端程序猿

前言

很多小伙伴使用 KoaEgg 之类框架写接口时一定碰到过下面这种令人头大的写法,每次我们定义一个路由写完 Controller 方法还要去 router 文件中再次定义一遍,非常的繁琐麻烦。

这种写法固然非常诱人,但是为了一个写法去切换框架的代价是非常大的,那么我们在 KoaEgg 中可以使用这种方法吗?或者说可以保持原有写法的同时渐进增强的使用装饰器来定义路由吗?答案是肯定的,装饰器路由写法的实现其实非常简单,各位看官且往下看。

装饰器用法

装饰器其实是一种语法糖,定义为一个普通的函数,调用时写成 @ + 函数名。它可以放在类和类方法的定义前面。类与类方法的参数有所不同。

类的装饰器

类的装饰器可以用来装饰整个类:

@testable
class MyTestableClass {
  // ...
}

function testable(target) {
  target.isTestable = true;
}

MyTestableClass.isTestable // true

// 此处代码来自阮一峰 ES6 入门教程

传入的 target 参数就是类本身,如果装饰器函数有返回值就用返回值替换这个类。基本行为就是下面这样:

@decorator
class A {}

// 等同于

class A {}
A = decorator(A) || A;

类方法装饰器

类方法装饰器其实和 Object.defineProperty 方法非常像,三个参数分别是:

  • target: 类对象其实就是 "类" 的 prototype 对象,它上面有个 constructor 属性指向类本身
  • name:装饰属性的名称
  • descriptor:属性的描述符,这个和 Object.defineProperty 方法是一致的

属性描述符的具体属性和描述可以看这里 descriptor

function foo(target, name, descriptor){
}

装饰器需要传参时我们可以创建一个高阶函数,这个函数返回一个装饰器函数就可以实现传参的目的了。

function foo (url) {
  return function (target, name, descriptor) {
    console.log(url)
  }
}
class Bar {
  @foo('/test')
  baz () {
  }
}

Reflect

Reflect(反射)是 ES6 为了操作对象而提供的新 API,这里我们要用到 MetaData 相关的 API 来为对象绑定一些路由数据,用来生成最终的路由文件。这个 API 目前还没有进入正式版本需要引入 reflect-metadata 这个库来支持相关 API,详情参见这里 reflect-metadata,我们主要使用到下面两个 API:

// 设置元数据
Reflect.defineMetadata(metadataKey, metadataValue, target);
// 获取设置的值
let result = Reflect.getMetadata(metadataKey, target);

实现装饰器路由

实现思路

最终目标就是要生成 KoaEgg 所需要的 router 配置,这里我们拿 Koa 举例,需要的就是类似于下面这样一份配置,所以我们的目标就是拿到创建下面这样一份文件,所以思路就是在 装饰器函数中可以拿到类方法 ,通过 reflect-metadata 可以在每个方法上写入 路径、请求类型 等元数据,所以只需要统一对外提供一个注册的方法就可以把使用装饰器设置的路径和函数注册在 router 对象上,这样就完成了路由自动注册的过程。

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

router.get('/user/info', UserInfoController);
router.get('/user/list', UserListController);
router.post('/user/create', UserCreateController);

app.use(router.routes())

Controller

一般来说某个 Class 对应的接口都会有一个统一的前缀,所以我们定义一个 Controller 方法用来存储公共路径。

/**
 * Controller 装饰器
 * 用来装饰 Controller 类
 *
 * @param {string} [baseUrl=''] 类的公共前缀
 * @returns
 * @memberof Decorator
 */
Controller (baseUrl = '') {
  return (target) => {
    Reflect.defineMetadata(BASE_URL, baseUrl, target)
  }
}

基础 HTTP 方法

因为 koa-router 中注册 Get、Post 之类的方法参数都相同,所以装饰器可以注册一个通用的方法来生成各个方法的装饰器,代码如下:

/**
 * 用来生成各种方法装饰器的工具函数
 *
 * @param {*} method
 * @memberof Decorator
 */
createMethodDecorator (method) {
  return (url) => {
    return (target, name, decorator) => {
      // target 为装饰方法所在的类
      // 因为类方法的装饰器会比类的装饰器先执行, 在这个阶段拿不到 Controller 类的公共前缀
      // 所以要存下 target 后面再根据所存的信息生成 router
      this.controllerList.add(target)
      // decorator.value 为装饰的函数本身
      Reflect.defineMetadata(METHOD, method, decorator.value)
      // 没有指定请求的 url 就是用函数名作为 url
      Reflect.defineMetadata(METHOD_URL, url || name, decorator.value)
    }
  }
}

生成 router 文件

有了路由信息后就需要将所有的路由信息注册到 router 对象上来完成路由的注册,我们遍历存储起来的所有 controller 类,然后获取到方法上面对应的路由信息来进行注册:

/**
 * 注册路由
 *
 * @param {*} router Koa Router 对象
 * @memberof Decorator
 */
registerRouter (router) {
  for (const controller of this.controllerList) {
    // 获取类构造函数,就是类装饰器中的 target 参数
    const controllerCtor = controller.constructor
    const baseUrl = Reflect.getMetadata(BASE_URL, controllerCtor) || ''

    // 获取类对象上的所有属性
    const allProps = Object.getOwnPropertyNames(controller)
    for (const props of allProps) {
      const handle = controller[props]
      // 遍历所有属性中是函数 且存在路由信息的函数
      if (typeof handle !== 'function') { continue }
      const method = Reflect.getMetadata(METHOD, handle)
      const url = Reflect.getMetadata(METHOD_URL, handle)
      if (method && url && router[method]) {
        // 因为是 demo 暂时不校验和转换各个 url 的格式
        // 实际使用中需要将三个路径拼接为合法的 url 格式
        const completeUrl = this.prefix + baseUrl + url
        // 把接口路径和函数注册到 router 对象上
        router[method](completeUrl, handle)
      }
    }
  }
}

加载所有 Controller

最后我们还要将所有的 Controller 文件加载进来,为了避免手写,我们创建一个 load 函数来自动加载所有的 Controller 文件。

这里我们用到了 requireContext 函数,使用 webpack 打包的话这个方法为 require.context() 是默认可用的。如果没有使用 webpack 的话就需要手动引入 require-context 这个 npm 包来使用。

requireContext 这个方法有三个参数分别为:搜索的目录、是否搜索子文件夹、匹配文件的正则表达式。使用这个方法可以获取所有符合条件的模块。

import requireContext from 'require-context'

export const load = function (path) {
  const ctx = requireContext(path, true, /\.js$/)
  ctx.keys().forEach(key => ctx(key))
}

// 使用:传入 controller 函数所在文件夹
load(path.resolve(__dirname, './controller'))

延伸

装饰器的功能非常强大,除了上面的自动注册路由外还可以做更多的事情,比如路由的鉴权、中间件、依赖注入、参数校验、日志等等。

总结

综上我们实现了一个基于 Koa Router 装饰器路由,Express,Egg 之类的其他框架的实现也是一个道理,不同框架根据路由注册方法的区别对 registerRouter 函数略加改造即可完成。对应已经存在已久的老项目也可使用这种方式对新的路由进行装饰器的写法,自己定制 registerRouter 的实现达到渐进增强的效果。

本文实现的代码在这里 【Koa-Decorator-Demo】