基于 Koa2 的 elpis-core 引擎内核的实现

243 阅读5分钟

前言

学习声明:本文知识体系来源于哲玄前端(抖音ID:44622831736)大前端全栈实践课程,结合个人学习实践进行整理。本文档主要记录全栈开发框架elpis-core内核引擎的实现。


核心设计理念

框架通过loader方法体系实现模块自动装配,其核心遵循Convention Over Configuration(约定优于配置)设计范式。通过一系列的loader来加载约定目录下文件,然后引入elpis-core,调用start方法就能启动项目elpis-core通过预定义模块加载规则与目录结构,开发者仅需按规范编写业务代码,即可免除传统框架繁杂的配置流程,显著降低学习曲线与心智负担。

核心内容

内核引擎设计

image.png

声明:设计图来源哲玄前端(抖音ID:44622831736)大前端全栈实践课程

elpis-core 项目结构

elpis
├─ 📁app
│  ├─ 📁controller // 存放业务处理文件,进行业务逻辑的处理
│  ├─ 📁extend // 存放拓展文件,比如:日志文件...
│  ├─ 📁middleware // 中间件逻辑处理,挂载到koa实例进行一系列的处理
│  ├─ 📁public
│  │  ├─ 📁output
│  │  └─ 📁static
│  ├─ 📁router // 路由文件
│  ├─ 📁router-schema // 对 router 规则校验的文件 
│  ├─ 📁service // 服务层的文件,主要用于服务端的交互
│  └─ 📄middleware.js // 全局的中间件
├─ 📁config // 环境配置
├─ 📁elpis-core
│  ├─ 📁loader
│  │  ├─ 📄config.js
│  │  ├─ 📄controller.js 
│  │  ├─ 📄extend.js
│  │  ├─ 📄middleware.js
│  │  ├─ 📄router-schema.js
│  │  ├─ 📄router.js
│  │  └─ 📄service.js
│  ├─ 📄env.js
│  ├─ 📄index.js
├─ 📄index.js

调用start方法 启动 elpis-core

// 文件路径:elpis/index.js
// 引入 elpis-core
const ElpisCore = require('./elpis-core');

/**
  * 启动项目
  * @param {object} options 应用配置
*/
ElpisCore.start(options);
// 文件路径:elpis/app/elpis-core/index.js
/**
   * 声明 start 启动项目方法
   * @param {*} options 项目配置
   *  options = {
   *    name // 项目名称
   *    homePage // 项目首页
   * }
  */
  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(`-- [start] env: ${app.env.get()}`);

    // 加载 config
    configLoader(app);
    console.log("-- [start] load config done --");
    // console.log(app.config);

    // 加载extend
    extendLoader(app);
    console.log("-- [start] load extend done --");
    // console.log(app.routerSchema);

    // 加载 routerSchema
    routerSchemaLoader(app);
    console.log("-- [start] load routerSchema done --");

    // 加载 middleware
    middlewareLoader(app);
    console.log("-- [start] load middleware done --");
    // console.log(app.middlewares);

    // 注册全局中间件 用户定义的中间件放在 app.middleware.js
    try {
      require(path.resolve(app.businessPath, `.${sep}middleware.js`))(app);
      // console.log('-- [start] load global middleware done --');
    } catch (e) {
      console.log(
        `[exception] Connot find global middleware, The middleware path is app.middleware.js`
      );
    }

    // 加载 service
    serviceLoader(app);
    console.log("-- [start]load service done --");
    // console.log(app.service);

    // 加载 controller
    controllerLoader(app);
    console.log("-- [start] load controlller done --");
    // console.log(app.controller);

    // 注册 router
    routerLoader(app);
    // console.log('load router done');

    // console.dir(app);

    // 启动服务
    try {
      // 从环境中获取端口,否则默认8080
      const port = process.env.PORT || 8080;
      // 从环境中获取ip 域名,否则默认 0.0.0.0
      const host = process.env.IP || "0.0.0.0";
      // 启动服务
      app.listen(port, host);
      console.log(`Server running on port: ${port}`);
    } catch (error) {
      console.error(error);
    }
  }

elpis-core 引擎内核实现

核心加载器模块,包含各模块的自动加载逻辑的实现。包括以下模块

middleware

加载所有的中间件文件 通过Koa洋葱圈模型对复杂业务逻辑的天然适配性,进行一系列功能的处理(如统一错误处理、鉴权拦截、参数检验...)
tips: 洋葱圈模型设计如上设计图
下面是具体代码实现:

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

/**
 * middleware loader
 * @params {object} app koa实例
 * 加载所有 middleware , 使其可通过 "app.middlewares.${目录}.${文件}" 访问
 * 	例子:
 * 		app/middleware
 *			| -- custom-floder
 *			|    -- custom-middleware.js
 * 
 * 	=> 访问:app.middlewares.customFloder.customMiddleware		
 */

module.exports = (app) => {
  // 1、获取 app/middleware 目录  middleware/**/**.js

  // 获取 middleware 文件路径
  const middlewarePath = path.resolve(app.businessPath, `.${sep}/middleware`);
  // 获取 middleware 下面的所有文件
  // const fileList = ["app/middleware/custom-floder/custom-middleware.js", "app/middleware/custom-floder/custom-middleware1.js"]
  const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`));

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

  // 遍历文件列表,将文件处理成可以 打点 调用的格式
  fileList.forEach(file => {
    // 提取文件名称,具体的文件的路径
    let name = path.resolve(file);
    // 截取文件名称
    name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'));
    // 将文件名的 "-" 修改为驼峰格式 custom-middleware --> customMiddleware
    name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase());
    // 处理格式
    let tempMiddleware = middlewares;
    const names = name.split(sep);
    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) { // 如果是最后一个,则表示是文件
        // 如果是文件,则引入middlewareLoader处理文件
        tempMiddleware[names[i]] = require(path.resolve(file))(app);
      } else { // 如果不是最后一个,表示是文件夹
        // 如果对应文件夹的 names[i] 为空
        if (!tempMiddleware[names[i]]) {
          tempMiddleware[names[i]] = {};
        };
        // 赋值临时路径
        tempMiddleware = tempMiddleware[names[i]];
      };
    };

  });

  // 3、将 middlewares 挂载到内存 app 上
  app.middlewares = middlewares;
};
controller

加载处理业务层的文件,使其可通过 "app.controller.${目录}.${文件}" 访问。
下面是代码实现:

    ...
    // 省略引入包的代码(与middleware一样)
    ...
    // 省略获取fileList代码(与middleware一样)

    const controller = {};
    // 遍历文件列表,将文件处理成可以 打点 调用的格式
    fileList.forEach(file => {
    ...
    // 省略处理 name 的代码(与middleware一样)

    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) { // 如果是最后一个,则表示是文件
        // 如果是文件,则引入controllerLoader处理文件
        // controller文件是 class, 所以要实例化
        const ControllerModule = require(path.resolve(file))(app);
        tempController[names[i]] = new ControllerModule();
      } else { // 如果不是最后一个,表示是文件夹
        // 如果对应文件夹的 names[i] 为空
        if (!tempController[names[i]]) {
          tempController[names[i]] = {};
        };
        // 赋值临时路径
        tempController = tempController[names[i]];
      };
    };
    });

    // 3、将 controller 挂载到内存 app 上
    app.controller = controller;
service

加载服务层的文件,主要用于封装核心业务逻辑,与controller层解耦,也用于数据处理第三方服务调用等等。实现与 controller 类似,只需把controller代码的controller部分改为service即可

extend

主要加载拓展层的代码,例如:日志、其他服务...,为Koa添加额外的功能,该loader与其他loader不同的是,该loader直接挂载到Koa实例上,通过 app(koa 实例).xxx调用,而其他loader(例:controller)挂载到app.controller下通过app.controller.xxxx调用
关键代码实现如下:

...
// 其他代码跟上面的一样
for (const key in app) {
  if (key === name) {
    console.log(`[extend load error] name: ${name} is already in app`);
    return;
  };
}
// 3、将 extend 挂载到内存 app 上
app[name] = require(path.resolve(file))(app);
router-schema

该loader主要通过json-schema配合 ajvAPI 进行检验的(headers、query、params、body等等)
具体代码实现:

const path = require("path");
const { sep } = path;
const glob = require("glob");
/**
 * router-scheme loader
 * @param {object} app koa实例
 * 
 * 通过 "json-schema & ajv" 对 API 规则进行约束,配合 api-params-verify 中间件使用
 * ajv: 检验 params 与 json-schema 的描述是否合法 
 * 
 * 文件: app/router-schema/**.js
 * 
 * 输出:
 * app.rpuSchema = {
 * 	`${api1}`: `${jsonSchema}`
 * 	`${api2}`: `${jsonSchema}`
 * 	`${api3}`: `${jsonSchema}`
 * }
 * 
 */
module.exports = (app) => {
  // 1、获取 app/router-schema 目录  router-schema/**.js

  // 获取 router-schema 文件路径
  const routerSchemaPath = path.resolve(app.businessPath, `.${sep}/router-schema`);
  // 获取 router-schema 下面的所有文件
  // const fileList = ["app/router-schema/router-shcema.js", "app/router-schema/custom-shcema1.js"]
  const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**.js`));
  // 2、注册所有routerSchema, 使得可以通过 "app.routerSchema" 访问
  let routerSchema = {};
  fileList.forEach(file => {
    routerSchema = {
      ...routerSchema,
      ...require(path.resolve(file))
    }
  });
  app.routerSchema = routerSchema;

}

json-schema 配合 ajv 的具体应用
json-schema官网: json-schema.org/
ajv官网: ajv.js.org/

// 数据结构:
{
  "productId": 1,
  "productName": "A green door",
  "price": 12.50,
  "tags": [ "home", "green" ]
}

// json-schema描述上面的数据结构
{
   // schema 版本
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/product.schema.json",
  "title": "Product",
  "description": "A product from Acme's catalog",
  // 数据结构类型
  "type": "object",
  // 属性
  "properties": {
     // 属性的类型
    "productId": {
      "description": "The unique identifier for a product",
      "type": "integer"
    },
    "productName": {
      "description": "Name of the product",
      "type": "string"
    },
    "price": {
      "description": "The price of the product",
      "type": "number",
      "exclusiveMinimum": 0
    }
  },
  // 哪些属性是必须的
  "required": [ "productId", "productName", "price" ]
}
// app/router/project.js 下定义了一个 /api/project/list 接口
module.exports = (app, router) => {
	const { project: projectController } = app.controller;
	// 获取项目列表
	router.get('/api/project/list', projectController.getList.bind(projectController));
}

// app/router-schema/project.js 文件描述这个接口的query参数
/**
 * 检验 router API 是否合法
 * 
 */
module.exports = {
    "/api/project/list": {
        get: {
            query: {
                type: 'object',
                properties: {
                    proj_key: {
                        type: 'string'
                    }
                },
                required: ["proj_key"]
            }
        }
    }
}

// 在 app/middleware 定义一个api-params-verify.js中间件-->app/middleware/api-params-verify.js
const Ajv = require("ajv");
const ajv = new Ajv();
/**
 * API 参数检验
 */
module.exports = (app) => {
  // 添加 $schema 版本,声明 ajv 使用json-schema哪个版本来检验,这个字段不是必须的,但最好在实际检验中指定它。
  const $schema = "http://json-schema.org/draft-07/schema#";
  return async (ctx, next) => {
    // 只处理 API 请求做签名校验
    if (ctx.path.indexOf("/api") < 0) {
      return await next();
    }

    // 获取请求参数
    const { query } = ctx.request;
    const schema = app.routerSchema[path]?.[method.toLowerCase()];

    if (!schema) {
      return await next();
    }

    let valid = true;
    // ajv 检验器
    let validate;

    // 检验query
    if (valid && query && schema.query) {
      schema.query.$schema = $schema;
      validate = ajv.compile(schema.query);
      valid = validate(query);
    }
    if (!valid) {
      ctx.status = 200;
      ctx.body = {
        success: false,
        message: `request validate fail: ${ajv.errorsText(validate.errors)}`,
        code: 442,
      };
      return;
    }

    await next();
  };
};

config

该 loader 主要放置默认的环境配置与不同环境的配置,可根据运行的环境(process.env_ENV)来获取对应的环境配置
config.default.js(默认)、config.local.js(本地,不提交至远程仓库)、config.beta.js(开发)、config.prod.js(生产) 实现如下:

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

/**
 * confid loader
 * @param {object} 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) => {
  // 1、获取 config 目录
  const configPath = path.resolve(app.baseDir, `.${sep}config`);
  // 2、获取默认文件(config.default.js)
  let defaultConfig = {};
  try {
    defaultConfig = require(path.resolve(configPath, `.${sep}config.default.js`));
  } catch (e) {
    console.error("[exception] Connot find the config.default.js file");
  };
  // 3、获取环境配置(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`));
    }

    // 简化
    // envConfig = require(path.resolve(configPath, `.${sep}config.${app.env.get()}.js`));
  } catch (e) {
    // console.error(`[exception] Connot find the config.${app.env.get()}.js file`);
    console.error(`[exception] Connot find the config env file`);
  };
  // 4、覆盖默认目录
  app.config = Object.assign({}, defaultConfig, envConfig);
}
router

该 loader 加载router下面的路由,将router下的路由统一收拢,然后注册到Koa的路由下,同时做路由兜底,临时重定向至用户设置的homePage页面。
关键代码实现如下:

const KoaRouter = require("koa-router");
const path = require("path");
const { sep } = path;
const glob = require("glob");

/**
 * router loader 目标: 将 router 目录下的路由注册到koa的路由下
 * @param {object} app koa实例
 * 
 * 解析 app/router/ 下的所有文件,加载到KoaRouter 下
 * 
 */

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

loader 执行顺序遵循的原则

注:注意loader加载顺序
调用start方法 启动 elpis-core 目录处有加载loader顺序,总体遵循无依赖先加载,有依赖被依赖者先加载,依赖者后加载原则,routerLoader在最后加载,因为它依赖其他loader处理完之后才通过routerLoader 去处理一系列的功能

总结

elpis-core通过创新的约定式架构,在保持灵活性的同时大幅降低配置复杂度。开发者只需专注业务逻辑实现,框架自动完成技术基础设施的装配工作,是追求高效开发的理想选择。

学习声明:本文知识体系来源于哲玄前端(抖音ID:44622831736)大前端全栈实践课程,结合个人学习实践进行整理,如有偏差,还望各位大佬指正。