Egg.js 内核简易设计与实现

388 阅读5分钟

🤔 前言

以学习 Node 框架 Egg.js 为目的,本文基于 Koa.js 实现简易版的 Egg.js 内核。

✈️ 实现目标

mini-eggjs 内核中有启动文件 ( index.js ) 和多个 loader,每个 loader 各司其职,对应处理 app 目录下的不同文件夹。

image.png

该内核主要奉行约定优于配置的设计理念,对 app 目录下的文件夹命名有一定要求。

下面着重介绍不同 loader 的作用,以及对应的 app 目录下的文件夹命名规范。

🏖️ loader

serviceLoader

serviceLoader 主要处理 app/service/**/*.js 下的各种后端接口服务类,使这些服务可通过 app.service.${目录}.${文件} 访问。

app/service
      |
      | -- custom-module
            |
            | -- custom-service.js

=> app.service.customModule.customService

controllerLoader

controllerLoader 主要处理 app/controller/**/*.js 下的各种对接后端服务类的控制器类,这些控制器类需要处理出符合业务需求的接口数据,并且返回给前端。经 controllerLoader 处理后,可通过 app.controller.${目录}.${文件} 访问。

app/controller
     |
     | -- custom-module
           |
           | -- custom-controller.js
  
=> app.controller.customModule.customController

routerLoader

routerLoader 主要处理 app/router/**/*.js 下的各种接口路由,解析完之后会加载到 koa-router 中。

routerSchemaLoader

routerSchemaLoader 主要处理 app/router-schema/**/*.js 下的所有路由参数规则,参数规则需要符合 json-schema规范,接口访问的时候配合 ajv 校验使用,后面会通过 api-params-verify 中间件使用。

 app/router-schema/**.js

 => app.routerSchema = {
     `${api1}`: `${jsonSchema}`,
     `${api2}`: `${jsonSchema}`,
    }

configLoader

configLoader 主要处理 app/config/**/*.js 下的所有环境配置文件。

  • 默认配置 config/config.default.js
  • 本地配置 config/config.local.js
  • 测试配置 config/config.beta.js
  • 生产配置 config/config.prod.js

extendLoader

extendLoader 主要处理 app/extend/**/*.js 下的所有扩展文件,这些扩展文件是给 app 实例扩展其他功能,可通过 app[${文件名}] 访问。

 app/extend
      |
      | -- custom-extend.js
  => app.extend.customExtend 访问

middlewareLoader

middlewareLoader 主要处理 app/middleware/**/*.js 下的所有用户自定义中间件,可以利用 Koa 的洋葱圈模型,做请求参数的校验、请求签名的验证、请求错误的统一捕获...,可以通过 app.middlewares.${目录}.${文件} 访问。

 app/middlewares
    |
    | -- custom-module
         |
         | -- custom-middleware.js
  
  => app.middlewares.customModule.customMiddleware

🏜️ 本文实现的 mini-eggjs 的目录如下:

mini-eggjs
├─ app
│  ├─ controller
│  │  ├─ base.js
│  │  ├─ project.js
│  ├─ extend
│  │  └─ logger.js
│  ├─ middleware
│  │  ├─ api-params-verify.js
│  │  ├─ api-sign-verify.js
│  │  ├─ error-handler.js
│  │  └─ project-handler.js
│  ├─ middleware.js
│  ├─ router
│  │  ├─ project.js
│  ├─ router-schema
│  │  └─ project.js
│  ├─ service
│  │  ├─ base.js
│  │  └─ project.js
├─ config
│  ├─ config.beta.js
│  ├─ config.default.js
│  └─ config.prod.js
├─ eggjs-core
│  ├─ env.js
│  ├─ index.js
│  └─ loader
│     ├─ config.js
│     ├─ controller.js
│     ├─ extend.js
│     ├─ middleware.js
│     ├─ router-schema.js
│     ├─ router.js
│     └─ service.js
├─ index.js

🌋 具体实现

下面将实现 mini-eggjs 的初始化和多个 loader。

🍉 loader

image.png

serviceLoader

glob 用于获取对应路径下的所有文件;sep 适配不同系统下的分隔符。

以下是 service.js 的具体实现。

const path = require('path');
const glob = require('glob');
const { sep } = path;

/**
 * service loader
 * @param {object} app Koa 实例
 *
 * 加载所有 service,可通过 `app.service.${目录}.${文件}` 访问
 *
 * 例子:
 *
 * app/service
 *  |
 *  | -- custom-module
 *        |
 *        | -- custom-service.js
 *
 *  => app.service.customModule.customService
 */

module.exports = (app) => {
  // 读取 app/service/**/**.js 下的所有文件
  const servicePath = path.resolve(app.businessPath, `.${sep}service`);
  const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`));
  // 遍历所有文件目录,把内容加载到 app.service 下
  const service = {};

  fileList.forEach((file) => {
    // 获取文件路径名称
    let name = path.resolve(file);
    // app/service/custom-module/custom-service.js -> custom-module/custom-service
    name = name.substring(
      name.lastIndexOf(`service${sep}`) + `service${sep}`.length,
      name.lastIndexOf(`.js`),
    );

    // -a -> A 驼峰
    name = name.replace(/[-_][a-zA-Z]/gi, (r) => r.substring(1).toUpperCase());

    // 将所有的 service 挂载到 app.service 上
    let tempService = service;
    const nameList = name.split(sep);
    for (let i = 0; i < nameList.length; i++) {
      if (i === nameList.length - 1) {
        // { customModule: {} } -> { customModule: { customService } }
        const serviceModule = require(path.resolve(file))(app);
        tempService[nameList[i]] = new serviceModule();
      } else {
        // { customModule: {} }
        if (!tempService[nameList[i]]) {
          tempService[nameList[i]] = {};
        }
        // { customModule: {} } -> {} 进入下一层
        tempService = tempService[nameList[i]];
      }
    }
  });

  app.service = service;
};

以下是 app/serivce/*.js 测试文件:

module.exports = (app) => {
  return class ProjectService {
    /**
     * 获取所有模型与项目的结构化数据
     */
    async getModelList() {
      return [{
          id: 1,
          name: '测试'
      }];
    }
  };
};

controllerLoader

以下是 controller.js 的具体实现:

const path = require('path');
const glob = require('glob');
const { sep } = path;
 
/**
 * controller loader
 * @param {object} app Koa 实例
 * 
 * 加载所有 controller,可通过 `app.controller.${目录}.${文件}` 访问
 * 
 * 例子:
 * 
 * app/controller
 *  |
 *  | -- custom-module
 *        |
 *        | -- custom-controller.js
 * 
 *  => app.controller.customModule.customController
 */

module.exports = (app) => {
  // 读取 app/controller/**/**.js 下的所有文件
  const controllerPath = path.resolve(app.businessPath, `.${sep}controller`);
  const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`));

  // 遍历所有文件目录,把内容加载到 app.controller 下
  const controller = {};

  fileList.forEach((file) => {
    // 获取文件路径名称
    let name = path.resolve(file);
    // app/controller/custom-module/custom-controller.js -> custom-module/custom-controller
    name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf(`.js`));

    // -a -> A 驼峰
    name = name.replace(/[-_][a-zA-Z]/gi, (r) => r.substring(1).toUpperCase());

    // 将所有的 controller 挂载到 app.controller 上
    let tempController = controller;
    const nameList = name.split(sep);
    for (let i = 0; i < nameList.length; i++) {
      if (i === nameList.length - 1) {
        // { customModule: {} } -> { customModule: { customController } }
        // controller 是个 class
        const ControllerModule = require(path.resolve(file))(app);
        tempController[nameList[i]] = new ControllerModule();
      }else {
        // { customModule: {} }
        if (!tempController[nameList[i]]) {
          tempController[nameList[i]] = {};
        }
        // { customModule: {} } -> {} 进入下一层
        tempController = tempController[nameList[i]];
      }
    }
  });

  app.controller = controller;
}

下面是 app/controller/*.js 的测试文件:

module.exports = (app) => {
  return class ProjectController {
    /**
     * 获取所有模型与项目的结构化数据
     */
    async getModelList(ctx) {
      const { project: projectService } = app.service;
      const modelList = await projectService.getModelList();
      modelList[0].name = '测试 conroller';
      ctx.status = 200;
      ctx.body = {
        success: true,
        data: modelList,
        metadata: [],
      };
    }
  };
};

routerLoader

下面是 router.js 的具体实现。

const glob = require('glob');
const path = require('path');
const KoaRouter = require('koa-router');
const { sep } = path;
/**
 * router loader
 * @param {*} app koa 实例
 *
 * 解析所有 app/router/ 下的所有 js 文件,加载到 KoaRouter 下
 */

module.exports = (app) => {
  // 找到路由文件路径
  const routerPath = path.resolve(app.businessPath, `.${sep}router`);
  // 实例化 KoaRouter
  const router = new KoaRouter();
  // 注册所有路由
  const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
  fileList.forEach((file) => {
    // module.exports = (app, router) => {
    //  router.get('/', async (ctx) => {})
    //}
    require(path.resolve(file))(app, router);
  });
  // 路由兜底 (健壮性)
  router.get('*', async (ctx, next) => {
    ctx.status = 302; // 临时重定向
    ctx.redirect(`${app?.options?.homePage ?? '/'}`);
  });
  // 路由注册到 app 上
  app.use(router.routes());
  app.use(router.allowedMethods());
};

下面是 app/router/*.js 的测试文件:

module.exports = (app, router) => {
  const { project: projectController } = app.controller;
  router.get('/api/project/model_list', projectController.getModelList.bind(projectController));
};

routerSchemaLoader

下面是 routerSchema.js 的具体实现:

const path = require('path');
const glob = require('glob');
const { sep } = path;

/**
 * router-schema loader
 *
 * @param {object} app Koa 实例
 *
 * 通过 'json-schema' & 'ajv' 对 API 规则进行约束,配合 api-params-verify 中间件使用
 *
 * app/router-schema/**.js
 *
 * 输出:
 * app.routerSchema = {
 *   `${api1}`: `${jsonSchema}`,
 *   `${api2}`: `${jsonSchema}`,
 * }
 */

module.exports = (app) => {
  // 读取 app/router-schema/**/**.js 下的所有文件
  const middlewarePath = path.resolve(app.businessPath, `.${sep}router-schema`);
  const fileList = glob.sync(
    path.resolve(middlewarePath, `.${sep}**${sep}*.js`),
  );

  let routerSchema = {};

  // 将所有 routerSchema 挂载到 app.routerSchema 上
  fileList.forEach((file) => {
    routerSchema = {
      ...routerSchema,
      ...require(path.resolve(file)),
    };
  });

  app.routerSchema = routerSchema;
};

下面是对 app/router-schema/*.js 的测试文件:

module.exports = {
  '/api/project/model_list': {
    get: {},
  },
};

configLoader

下面是对 config.js 的具体实现:

const path = require('path');
const { sep } = path;

/**
 * config loader
 * @param {obj} app koa 实例
 *
 * 配置区分 生产/测试/开发,通过 env 环境读取不同文件配置 env.config
 * 通过 env.config 覆盖 default.config 加载到 app.config 中
 *
 * 根目录下对应的 config 目录配置
 * 默认配置 config/config.default.js
 * 本地配置 config/config.local.js
 * 测试配置 config/config.beta.js
 * 生产配置 config/config.prod.js
 *
 */

module.exports = (app) => {
  // 找到 config/ 目录
  const configPath = path.resolve(app.baseDir, `.${sep}config`);
  // 获取 default.config
  let defaultConfig = {};
  try {
    defaultConfig = require(path.resolve(
      configPath,
      `.${sep}config.default.js`,
    ));
  } catch (error) {
    console.log(
      `[core](config loader): exception there is no default.config file`,
    );
  }
  // 获取 env.config
  let envConfig = {};
  try {
    if (app.env.isLocal()) {
      // 本地环境
      envConfig = require(path.resolve(configPath, `.${sep}config.local.js`));
    } else if (app.env.isBeta()) {
      // 测试环境
      envConfig = require(path.resolve(configPath, `.${sep}config.beta.js`));
    } else if (app.env.isProduction()) {
      // 生产环境
      envConfig = require(path.resolve(configPath, `.${sep}config.prod.js`));
    }
  } catch (error) {
    console.log(
      `[core](config loader): exception there is no env.config file ${error}`,
    );
  }
  // 覆盖并加载 config 配置
  app.config = Object.assign({}, defaultConfig, envConfig);
};

下面是 config.default.js 的测试文件:

module.exports = {};

extendLoader

下面是对 extend.js 的具体实现

const path = require('path');
const glob = require('glob');
const { sep } = path;

/**
 * extend loader
 * @param {object} app Koa 实例
 *
 * 加载所有 extend,可通过 `app.extend.${文件}` 访问
 *
 * 例子:
 *
 * app/extend
 *      |
 *      | -- custom-extend.js
 *
 *  => app.extend.customExtend 访问
 */

module.exports = (app) => {
  // 读取 app/extend/**.js 下的所有文件
  const extendPath = path.resolve(app.businessPath, `.${sep}extend`);
  const fileList = glob.sync(path.resolve(extendPath, `.${sep}**.js`));

  // 遍历所有文件目录,把内容加载到 app[name] 下
  fileList.forEach((file) => {
    // 获取文件路径名称
    let name = path.resolve(file);
    // app/extend/custom-extend.js -> custom-extend
    name = name.substring(
      name.lastIndexOf(`extend${sep}`) + `extend${sep}`.length,
      name.lastIndexOf(`.js`),
    );

    // -a -> A 驼峰
    name = name.replace(/[-_][a-zA-Z]/gi, (r) => r.substring(1).toUpperCase());

    // 过滤 app 已经存在的 key
    for (let key in app) {
      if (key === name) {
        console.log(
          `[core](extend loader): name: ${key} is already in app`,
        );
        return;
      }
    }
    app[name] = require(path.resolve(file))(app);
  });
};

下面是对 app/extend/*.js 的测试文件,在此注入日志系统:

const log4js = require('log4js');

/**
 * 日志工具
 * 外部调用 app.logger.log logger.error
 */
module.exports = (app) => {
  let logger;

  if (app.env.isLocal()) {
    // 打印在控制台即可
    logger = console;
  }else {
    // 将日志输出并落地到磁盘(日志落盘)
    log4js.configure({
      appenders: {
        console: {
          type: 'console',
        },
        // 日志文件切分
        dataFile: {
          type: 'dateFile',
          filename: './logs/application.log',
          pattern: '.yyyy-MM-dd',
        }
      },
      categories: {
        default: {
          appenders: ['console', 'dataFile'],
          level: 'trace'
        }
      }
    });

    logger = log4js.getLogger();
  }

  return logger;
}

middlewareLoader

下面是 middleware.js 的具体实现:

const path = require('path');
const glob = require('glob');
const { sep } = path;
 
/**
 * middleware loader
 * @param {object} app Koa 实例
 * 
 * 加载所有 middleware,可通过 `app.middlewares.${目录}.${文件}` 访问
 * 
 * 例子:
 * 
 * app/middlewares
 *  |
 *  | -- custom-module
 *        |
 *        | -- custom-middleware.js
 * 
 *  => app.middlewares.customModule.customMiddleware
 */

module.exports = (app) => {
  // 读取 app/middleware/**/**.js 下的所有文件
  const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`);
  const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`));

  // 遍历所有文件目录,把内容加载到 app.middlewares 下
  const middlewares = {};

  fileList.forEach((file) => {
    // 获取文件路径名称
    let name = path.resolve(file);
    // app/middleware/custom-module/custom-middleware.js -> custom-module/custom-middleware
    name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf(`.js`));

    // -a -> A 驼峰
    name = name.replace(/[-_][a-zA-Z]/gi, (r) => r.substring(1).toUpperCase());

    // 将所有的 middleware 挂载到 app.middlewares 上
    let tempMiddlewares = middlewares;
    const nameList = name.split(sep);
    for (let i = 0; i < nameList.length; i++) {
      if (i === nameList.length - 1) {
        // { customModule: {} } -> { customModule: { customMiddleware } }
        tempMiddlewares[nameList[i]] = require(path.resolve(file))(app);
      }else {
        // { customModule: {} }
        if (!tempMiddlewares[nameList[i]]) {
          tempMiddlewares[nameList[i]] = {};
        }
        // { customModule: {} } -> {} 进入下一层
        tempMiddlewares = tempMiddlewares[nameList[i]];
      }
    }
  });

  app.middlewares = middlewares;
}

下面是 app/middleware/errror-handler.js 的具体实现,在此自定义中间件对错误进行统一捕获:

/**
 * 运行时异常错误处理,兜底所有异常
 *
 * @param {object} app koa 实例
 */

module.exports = (app) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (err) {
      // 异常处理
      const { status, message, detail } = err;
      app.logger.info(JSON.stringify(err));
      app.logger.error('[-- exception --]', err);
      app.logger.error('[-- exception --]', status, message, detail);

      // 模板不存在
      if (message && message.indexOf('template not found') > -1) {
        // 页面临时重定向 (301 的话,后续如果有对应路径的模板也不会返回而是一直往重定向的路径跳转)
        ctx.status = 302;
        ctx.redirect(`${app.options?.homePage}`);
        return;
      }

      const resBody = {
        success: false,
        code: 50000,
        message: '网络异常 请稍后重试',
      };

      ctx.status = 200;
      ctx.body = resBody;
    }
  };
};

🏆 mini-eggjs 的初始化

下面是 mini-eggjs 初始化文件和环境配置文件:

const Koa = require('koa');
const path = require('path');
const env = require('./env');
const middlewareLoader = require('./loader/middleware');
const routerSchemaLoader = require('./loader/router-schema');
const controllerLoader = require('./loader/controller');
const serviceLoader = require('./loader/service');
const configLoader = require('./loader/config');
const extendLoader = require('./loader/extend');
const routerLoader = require('./loader/router');
const { sep } = path;

module.exports = {
  /**
   * 启动项目
   * @param {*} options 项目配置
   */
  start(options) {
    // koa 实例
    const app = new Koa();

    // 应用配置
    app.options = options;

    // 项目根路径/基础路径
    app.baseDir = process.cwd();

    // 业务文件路径
    app.businessPath = path.resolve(app.baseDir, `.${sep}app`);

    // 环境配置
    app.env = env();
    console.log(`[core](index): start env ${app.env.get()}`);

    // 加载自定义 middleware
    middlewareLoader(app);
    console.log(`[core](index): load middlewareLoader done`);

    // 加载 routerSchema
    routerSchemaLoader(app);
    console.log(`[core](index): load routerSchemaLoader done`);

    // 加载 controller
    controllerLoader(app);
    console.log(`[core](index): load controllerLoader done`);

    // 加载 service
    serviceLoader(app);
    console.log(`[core](index): load serviceLoader done`);

    // 加载 config
    configLoader(app);
    console.log(`[core](index): load configLoader done`);

    // 加载 extend
    extendLoader(app);
    console.log(`[core](index): load extendLoader done`);

    // 注册全局(引用)中间件 app/middleware.js
    try {
      require(`${app.businessPath}${sep}middleware.js`)(app);
      console.log(`[core](index): load global middleware done`);
    } catch (error) {
      console.log(`[core](index): exception there is no global middleware file`);
    }

    // 注册路由 (放最后, 等前面加载完)
    routerLoader(app);
    console.log(`[core](index): load routerLoader done`);

    // 服务启动
    try {
      const port = process.env.PORT || 8080;
      const ip = process.env.IP || '0.0.0.0';
      app.listen(port, ip);
      console.log(`[core](index): Server running on ${ip}:${port}`);
    } catch (error) {
      console.error(e);
    }
    return app;
  },
};

module.exports = (app) => {
  return {
    isLocal() {
      return process.env._ENV === 'local';
    },
    isBeta() {
      return process.env._ENV === 'beta';
    },
    isProduction() {
      return process.env._ENV === 'production';
    },
    get() {
      return process.env._ENV ?? 'local';
    },
  };
};

💬 结尾

以上是本人对 egg.js 的理解,并借此实现了一个简易版的 mini-eggjs 内核,如果有错误的地方还望大佬们多多指点。

明天就是除夕了,祝大家除夕快乐,2025 一路长虹 💰💰💰!!!