Koa+TypeScript从0到1实现简易CMS框架(二):路由自动加载与全局异常处理

2,241 阅读4分钟

目录

项目地址:koa-typescript-cms

前言

koa本身是没有路由的,需借助第三方库koa-router实现路由功能,但是路由的拆分,导致app.ts里需要引入许多路由文件,为了方便,我们可以做一个简单的路由自动加载功能来简化我们的代码量;全局异常处理是每个cms框架中比不可少的部分,我们可以通过koa的中间件机制来实现此功能。

主要工具库

  • koa web框架
  • koa-bodyparser 处理koa post请求
  • koa-router koa路由
  • sequelize、sequelize-typescript、mysql2 ORM框架与Mysql
  • validator、class-validator 参数校验
  • jsonwebtoken jwt
  • bcryptjs 加密工具
  • reflect-metadata 给装饰器添加各种信息
  • nodemon 监听文件改变自动重启服务
  • lodash 非常好用的工具函数库

项目目录

├── dist                                        // ts编译后的文件
├── src                                         // 源码目录
│   ├── components                              // 组件
│   │   ├── app                                 // 项目业务代码
│   │   │   ├── api                             // api层
│   │   │   ├── service                         // service层
│   │   │   ├── model                           // model层
│   │   │   ├── validators                      // 参数校验类
│   │   │   ├── lib                             // interface与enum
│   │   ├── core                                // 项目核心代码
│   │   ├── middlewares                         // 中间件
│   │   ├── config                              // 全局配置文件
│   │   ├── app.ts                              // 项目入口文件
├── tests                                       // 单元测试
├── package.json                                // package.json                                
├── tsconfig.json                               // ts配置文件

路由自动加载

思路:(此功能借鉴lin-cms开源的lin-cms-koa-core)

  1. 获取api文件夹下的所有文件
  2. 判断文件的后缀名是否为.ts,如果是,使用CommonJS规范加载文件
  3. 判断文件导出的内容是否为Router类型,如果是,则加载路由

由于我们需要很多功能到要在服务执行后就加载,所以创建一个专门加载功能的类InitManager
InitManager类中创建类方法initLoadRouters,此方法专门作为加载路由的功能模块。
先创建一个辅助函数getFiles,此函数利用nodefs文件功能模块,来获取某文件夹下后的所有文件名,并返回一个字符串数组:

/**
 * 获取文件夹下所有文件名
 *
 * @export
 * @param {string} dir
 * @returns
 */
export function getFiles(dir: string): string[] {
  let res: string[] = [];
  const files = fs.readdirSync(dir);
  for (const file of files) {
    const name = dir + "/" + file;
    if (fs.statSync(name).isDirectory()) {
      const tmp = getFiles(name);
      res = res.concat(tmp);
    } else {
      res.push(name);
    }
  }
  return res;
}

接下来编写路由自动加载功能:

  /**
   * 路由自动加载
   *
   * @static
   * @memberof InitManager
   */
  static initLoadRouters() {
    const mainRouter = new Router();
    const path: string = `${process.cwd()}/src/app/api`;
    const files: string[] = getFiles(path);
    for (let file of files) {
      // 获取文件后缀名
      const extention: string = file.substring(
        file.lastIndexOf("."),
        file.length
      );
      if (extention === ".ts") {
        // 加载api文件夹下所有文件
        // 并检测文件是否是koa的路由
        // 如果是路由便将路由加载
        const mod: Router = require(file);
        if (mod instanceof Router) {
          // consola.info(`loading a router instance from file: ${file}`);
          get(mod, "stack", []).forEach((ly: Router.Layer) => {
            consola.info(`loading a route: ${get(ly, "path")}`);
          });
          mainRouter.use(mod.routes()).use(mod.allowedMethods());
        }
      }
    }
  }

InitManager中创建另一个类方法initCore,此方法需传入一个koa实例,统一加载InitManager类中的其他功能模块。

  /**
   * 入口方法
   *
   * @static
   * @param {Koa} app
   * @memberof InitManager
   */
  static initCore(app: Koa) {
    InitManager.app = app;
    InitManager.initLoadRouters();
  }

需要注意的是,路由文件导出的时候不能再以ES的规范导出了,必须以CommonJS的规范进行导出。

api/v1/book.ts文件源码:

import Router from 'koa-router'

const router: Router = new Router();
router.prefix('/v1/book')
router.get('/', async (ctx) => {
    ctx.body = 'Hello Book';
});

// 注意这里
module.exports = router 

最后在app.ts中加载,代码:

import InitManager from './core/init'

InitManager.initCore(app)

此为还需要全局加载配置文件,与加载路由大同小异,代码一并附上

app/core/init.ts全部代码:

import Koa from "koa";
import Router from "koa-router";
import consola from "consola";
import { get } from "lodash";
[
import { getFiles } from "./utils";
import { config, configInterface } from "../config/config";
declare global {
  namespace NodeJS {
    interface Global {
      config?: configInterface;
    }
  }
}
class InitManager {
  static app: Koa<Koa.DefaultState, Koa.DefaultContext>;

  /**
   * 入口方法
   *
   * @static
   * @param {Koa} app
   * @memberof InitManager
   */
  static initCore(app: Koa) {
    InitManager.app = app;
    InitManager.initLoadRouters();
    InitManager.loadConfig();
  }

  /**
   * 路由自动加载
   *
   * @static
   * @memberof InitManager
   */
  static initLoadRouters() {
    const mainRouter = new Router();
    const path: string = `${process.cwd()}/src/app/api`;
    const files: string[] = getFiles(path);
    for (let file of files) {
      // 获取文件后缀名
      const extention: string = file.substring(
        file.lastIndexOf("."),
        file.length
      );
      if (extention === ".ts") {
        // 加载api文件夹下所有文件
        // 并检测文件是否是koa的路由
        // 如果是路由便将路由加载
        const mod: Router = require(file);
        if (mod instanceof Router) {
          // consola.info(`loading](https://note.youdao.com/) a router instance from file: ${file}`);
          get(mod, "stack", []).forEach((ly: Router.Layer) => {
            consola.info(`loading a route: ${get(ly, "path")}`);
          });
          mainRouter.use(mod.routes()).use(mod.allowedMethods());
        }
      }
    }
  }

  /**
   * 载入配置文件
   *
   * @static
   * @memberof InitManager
   */
  static loadConfig() {
    global.config = config;
  }
}
export default InitManager;

全局异常处理

此功能需依赖koa的中间件机制进行开发
异常分为已知异常与未知异常,需针对其进行不同处理

常见的已知异常:路由参数错误、从数据库查询查询到空数据……
常见的未知错误:不正确的代码导致的依赖库报错……

已知异常我们需要向用户抛出,以json的格式返回到客户端。
而未知异常一般只有在开发环境才会让它抛出,并且只有开发人员可以看到。

已知异常向用户抛出时,需携带错误信息、错误代码、请求路径等信息。
我们需要针对已知异常封装一个类,用来标识错误为已知异常。
app/core目录下创建文件exception.ts,此文件里有一个基类HttpException,此类继承JavaScript的内置对象Error,之后所有的已知异常类都将继承HttpException
代码:

/**
 * HttpException 类构造函数的参数接口
 */
export interface Exception {
  code?: number;
  msg?: any;
  errorCode?: number;
}
export class HttpException extends Error {
  /**
   * http 状态码
   */
  public code: number = 500;

  /**
   * 返回的信息内容
   */
  public msg: any = "服务器未知错误";

  /**
   * 特定的错误码
   */
  public errorCode: number = 999;

  public fields: string[] = ["msg", "errorCode"];

  /**
   * 构造函数
   * @param ex 可选参数,通过{}的形式传入
   */
  constructor(ex?: Exception) {
    super();
    if (ex && ex.code) {
      assert(isInteger(ex.code));
      this.code = ex.code;
    }
    if (ex && ex.msg) {
      this.msg = ex.msg;
    }
    if (ex && ex.errorCode) {
      assert(isInteger(ex.errorCode));
      this.errorCode = ex.errorCode;
    }
  }
}

针对以上的情况进行编码app/middlewares/exception.ts全部代码:

import { BaseContext, Next } from "koa";
import { HttpException, Exception } from "../core/exception";
interface CatchError extends Exception {
  request?: string;
}
const catchError = async (ctx: BaseContext, next: Next) => {
  try {
    await next();
  } catch (error) {
    const isHttpException = error instanceof HttpException
    const isDev = global.config?.environment === "dev"
    
    if (isDev && !isHttpException) {
      throw error;  
    }
    if (isHttpException) {
      const errorObj: CatchError = {
        msg: error.msg,
        errorCode: error.errorCode,
        request: `${ctx.method} ${ctx.path}`
      };
      ctx.body = errorObj;
      ctx.status = error.code;
    } else {
      const errorOjb: CatchError = {
        msg: "出现异常",
        errorCode: 999,
        request: `${ctx.method} ${ctx.path}`
      };
      ctx.body = errorOjb;
      ctx.status = 500;
    }
  }
};

export default catchError;

最后,app.ts里使用中间件,app.ts代码:

import Koa from 'koa';
import InitManager from './core/init'
import catchError from './middlewares/exception';

const app = new Koa()
app.use(catchError)
InitManager.initCore(app)

app.listen(3001);

console.log('Server running on port 3001');

下一篇:Koa+TypeScript从0到1实现简易CMS框架(三):用户模型、参数校验与用户注册接口