利用装饰器改造egg路由

882 阅读4分钟

背景

egg中,通常是这么写路由的

import { Application } from 'egg';

export default (app: Application) => {
  const { controller, router } = app;

  // 摆大巴
  router.get('/', controller.home.index);
  router.get('/user', controller.home.getUser);
  router.get('/users', controller.home.getUsers);
  router.put('/user', controller.home.updateUser);
  router.post('/user', controller.home.addUser);
  router.delete('/user', controller.home.deleteUser);
  // ...
};

由于产品迭代,接口多了,router.ts就乱了,模块区分不显著。写个接口,还要切文件,很不舒服。

改造后

router.ts长这样,Bootstrap方法作为入口,全部的接口去中心化,提高接口开发效率

import { Application } from 'egg'
import { Bootstrap } from './owlet'

export default (app: Application) => {
  Bootstrap(app, { prefix: '/owlet' })
}

controller长这样

import { Context, Controller } from 'egg'
import { BizCode, BizMsg } from '../bizconfig/http.status'
import { Get, Post } from '../owlet/decorator/http'
import { Prefix } from '../owlet/decorator/router'

@Prefix('user')
export default class UserController extends Controller {

  /**
   * 示例:Get
   */
  @Get('/ddd')
  async d() {
    const { ctx } = this
    ctx.body = { code: BizCode.OK, msg: BizMsg.OK, data: 123 }
  }
  
  /**
   * 示例:Post
   */
  @Post('/dto')
  async f() {
    const { ctx } = this
    ctx.body = { code: BizCode.OK, msg: `owlet` }
  }

  /**
   * 示例:路由拦截
   */
  @Get('/p', (ctx: Context) => {
    console.log('忍法!水遁·水阵壁', ctx.request.url)
    return false
  })
  async p() {
    const { ctx } = this
    ctx.body = '你将不会看见返回值'
  }

}

曾经我也忍不住打开npmjs.com,搜索egg-decorator-xxx,试图直接找个轮子,但毕竟不是自己写的,非常不舒服,一种被克制的感觉。

准备工作:创建egg-ts空项目

# 全局安装 egg-init cli
npm i egg-init -g
# 初始化项目,选择 ts - Simple egg && typescript app boilerplate
egg-init owlet
# 装依赖,启项目 http://localhost:7001
cd owlet & yarn & yarn dev

趁着终端跑命令,干掉一些默认文件,app文件夹下创建相关文件,结构如下:

├── controller
│   └── demo.ts
├── owlet
│   ├── decorator.ts
│   └── index.ts
└── router.ts

owlet/index.ts中,暴露一个Bootstrap方法

// owlet/index.ts
import { Application } from "egg";

/**
 * @param {string} prefix 项目全局api前缀
 */
interface IOptions {
  prefix?: string;
}

export const Bootstrap = (app: Application, options?: IOptions) => {
  //TODO
  console.log(app, options);
};

router.ts中引入

// router.ts
import { Application } from "egg";
import { Bootstrap } from "./owlet";

export default (app: Application) => {
  Bootstrap(app, { prefix: "/owlet" });
};

实现Get方法装饰器

有关方法装饰器,此处略....装饰器基础知识 • 官方文档

基本思路:

  • 造个Get装饰器
  • Get装饰器收集控制器方法元信息
  • 注册收集的元信息到egg路由对象中

废话不多说,直接开搞

第一步:造Get方法装饰器

owlet/decorator.ts中写入

/**
 * @param {string} path api 前缀
 */
export const Get = (path: string): MethodDecorator => {
  /**
   * @param {BaseContextClass} target 方法装饰所属类
   * @param {string} 控制器方法名
   */
  return (target: any, handlerName: any) => {
    const proto = target.constructor;
    // 打印看下收集到了什么...有惊喜
    console.log(path, target, proto, handlerName);
  };
};

然后在app/controller/demo.ts中使用装饰器

import { Controller } from "egg";
import { Get } from "../owlet/decorator";

export default class DemoController extends Controller {
  @Get("/hi")
  public async index() {
    const { ctx } = this;
    ctx.body = 123;
  }
}

Get装饰器参数和demo.ts对应关系如下图,画的比较乱,辅助理解:

1647501444789.jpg

此时我看了控制台,打印出了console.log(path,target,proto,handlerName)信息,我陷入了沉思,为什么控制台会打印?

查了下egg文档,有这么一个说法

所有的 Controller 文件都必须放在 app/controller 目录下,可以支持多级目录,访问的时候可以通过目录名级联访问。Controller 支持多种形式进行编写,可以根据不同的项目场景和开发习惯来选择。

于是,我猜egg生命周期中,应该是会自动去递归扫描controller下面的全部文件,然后根据commonjs规范,挨个require(xxx.controler.ts)解析一下

基于此,甭管对不对,那就太舒服了,省去了咱们手动扫描controller文件夹的麻烦!

回到最初的起点,一个基本的路由,长这样:

router.get('/', controller.home.index);

翻译一下

router.get(api前缀, 类.类名.方法名)

这些『元信息』Get装饰器都能拿的到,太好了!赶紧造个容器,收集一下。

第二步:Get方法装饰器收集元信息

新建owlet/hub.ts增加 routeHub用来存储Get装饰器收集到的元信息

type ThttpMethod = "GET" | "POST";

interface IRouteMeta {
  clazz: any;
  clazzName: string;
  httpMethodType: ThttpMethod;
  handlerName: string;
}

interface IRouteHub {
  [key: string]: IRouteMeta;
}

// 收集Get、Post等http请求装饰器
export const routeHub: IRouteHub = {};

然后回到 owlet/decorator.ts,在Get装饰器里引入routeHub对象收集一下

import { routeHub } from "./hub";

/**
 * @param {string} path api 前缀
 */
export const Get = (path: string): MethodDecorator => {
  /**
   * @param {BaseContextClass} target 方法装饰所属类
   * @param {string} 控制器方法名
   */
  return (target: any, handlerName: any) => {
    const proto = target.constructor;
    // 把元信息存到routeHub中,key就是接口 api 前缀
    routeHub[path] = {
      clazz: proto,
      clazzName: proto.name,
      httpMethodType: "GET",
      handlerName,
    };
  };
};

最后,在之前咱们声明的入口Bootstrap函数,打印下routeHub

// owlet/index.ts
import { Application } from "egg";
import { routeHub } from "./hub";

/**
 * @param {string} prefix 项目全局api前缀
 */
interface IOptions {
  prefix?: string;
}

export const Bootstrap = (app: Application, options?: IOptions) => {
  // ...
  console.warn(routeHub)
};

输出

{
  '/index': {
    clazz: [class DemoController extends BaseContextClass],
    clazzName: 'DemoController',
    httpMethodType: 'GET',
    handlerName: 'index'
  }
}

第三步:路由注册元信息

这一步就非常轻松了,在Bootstrap入口方法注册元信息到eggrouter对象中

// owlet/index.ts
import { Application, Context } from "egg";
import { routeHub } from "./hub";

/**
 * @param {string} prefix 项目全局api前缀
 */
interface IOptions {
  prefix?: string;
}

export const Bootstrap = (app: Application, options?: IOptions) => {
  const { router } = app;
  // 1. 项目全局api 前缀
  const globalPrefix = options?.prefix;

  // 2. 注册路由
  Object.keys(routeHub).forEach((apiPrefix: string) => {
    const { clazz, httpMethodType, handlerName } = routeHub[apiPrefix];
    // 拼接完成的 api 前缀
    const fullPrefix = globalPrefix + apiPrefix;
    // 使用 router 对象添加 route
    router[httpMethodType.toLowerCase()](fullPrefix, async (ctx: Context) => {
      // 类的示例构造方法传入ctx对象
      const instance = new clazz(ctx);
      // 类的成员方法同步执行
      await instance[handlerName]();
    });
  });
};

Post装饰器

思路同Get装饰器完全一样,默念口诀:

  • 造个装饰器
  • 收集元信息
  • 批量注册到路由

Prefix(Controller)装饰器

类装饰器实现上有细微不同,思路仍然同上

结语

上述代码纯属虚构,只是简单实现,希望可以帮到你。不足之处,轻喷。

demo代码仓库