基于 Koa 实现核心 elpis-core

169 阅读5分钟

前言

    本文为个人学习笔记,跟随前端业界著名人士(抖音:哲玄前端)学习全栈,本章记录搭建 elpis-core 内核实现和理解,该内核主要奉行约定优于配置的设计理念,通过编写的loader来加载约定目录下configmiddlewareserviceextendrouterSchemaroutercontroller,可以减少开发人员的学习成本,无需考虑目录结构,只需要把对应的功能写到对应的文件中,引入 elpis-core ,调用start方法就能启动项目,接下来说一下内核的实现和各个目录的功能,以及如何搭建自己的私服的 npm 服务

应用中各目录功能和elpis-core中load实现

  • 应用目录结构图
Elpis
├─ .DS_Store
├─ app
│  ├─ controller 
│  ├─ extend
│  ├─ middleware
│  ├─ middleware.js //全局中间件
│  ├─ public
│  ├─ router
│  ├─ router-schema
│  └─ service
├─ config
├─ elpis-core

config

该目录下放置不同环境的配置文件和默认配置文件 如: config.default.js(默认)config.prod.js(生产)config.local.js(开发)  elpis-core可以根据你运行的环境去加载对应的配置文件,且合并默认配置文件。

  • configLoader 核心代码:
const path = require('path');
const { sep } = path;

/**
 * @description config loader
 * @param {object} app koa 实例
 *
 * 配置区分 本地/测试/生产, 通过 env 环境读取不同文件配置 env.config
 * 通过 env.config 覆盖 default.config 加载 app.config 中
 *
 * 目录下对应的 config 配置
 * 默认配置 config/default.js
 * 本地配置 config/local.js
 * 测试配置 config/beta.js
 * 生产配置 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}default.js`));
  } catch (error) {
    console.log('[-- elips-core/loader/config --][-- 第26行 --] there is no default.config file');
  }

  // 获取 env.config
  let envConfig = {};
  try {
    envConfig = require(path.resolve(configPath, `.${sep}${app.env.get()}.js`));
  } catch (error) {
    console.log('[-- elips-core/loader/config --][-- 第34行 --] there is no env.config file');
  }
  // 覆盖并加载 config 配置
  app.config = Object.assign({}, defaultConfig, envConfig);
};

controller

该目录下放置控制器文件,控制器是处理请求并返回响应的模块,它们会调用服务来获取数据并将其返回给客户端,如接口,页面。
elpis-core中对controller的处理是获取所有controller目录下的控制器,以文件的名字为key挂载到 koa实例的controller 上,在应用中我们可以通过 app.controller.xxx 获取。

  • controllerLoader 核心代码:
const glob = require('glob');
const path = require('path');
const { forEach } = require('lodash');
const { sep } = path;

/**
 * @description controller loader 配置中间件
 * @param {object} app koa 实例
 *
 * 加载所有的 controller, 可通过'app.controlle.${目录}.${文件}'访问
 * 例子:
 * 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 = {};
  forEach(fileList, (file) => {
    // 提取文件名称
    let name = path.resolve(file);

    // 截取路径 app/controller/custom-module/custom-controller => custom-module/custom-controller
    name = name.substring(name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length, name.lastIndexOf('.'));

    // 把'-' 统一改成驼峰式 custom-module/custom-controller.js =>customModule.customController
    name = name.replace(/[/-][a-z]/gi, (s) => s.substring(1).toUpperCase());
    // 挂载 controller 到内存 app 对象中
    let tempController = controller;
    const names = name.split(`${sep}`);
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) {
        const ControllerMoule = require(path.resolve(file))(app);
        tempController[names[i]] = new ControllerMoule();
      } else {
        if (!tempController[names[i]]) {
          tempController[names[i]] = {};
        }
        tempController = tempController[names[i]];
      }
    }
  });
  app.controller = controller;
};

extend

该目录下放置扩展文件,扩展的功能是为 koa 实例扩展额外的功能,如:日志
elpis-core中对extend的处理也是跟 controller 一样收集应用 extend 目录下的所有扩展,不同的是,extend是直接挂载到 koa实例 上, 应用中使用 app.xxx 获取

  • extendLoader 核心代码:
const glob = require('glob');
const path = require('path');
const { forEach, forIn } = require('lodash');
const { sep } = path;

/**
 * @description extend loader 配置中间件
 * @param {object} app koa 实例
 *
 * 加载所有的 extend, 可通过'app.controlle.${目录}.${文件}'访问
 * 例子:
 * app/extend
 *  |
 *  | -- custom-extend
 * ==> 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}**${sep}**.js`));

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

    // 把'-' 统一改成驼峰式 custom-module/custom-extend.js =>customModule.customExtend
    name = name.replace(/[/-][a-z]/gi, (s) => s.substring(1).toUpperCase());

    // 过滤 app 已经存在的 key
    forIn(app, (_value, key) => {
      if (key === name) {
        console.log(`[-- elips-core/loader/extend --][-- 第34行 --] [extend load error] name:${name} is already in app`);
        return;
      }
    });

    // 挂载 extend 到 app 上
    app[name] = require(path.resolve(file))(app);
  });
};

middleware

该目录放置中间件文件,中间件因其洋葱圈模型的特性,可以作用于很多功能,如:请求预处理作用(参数验证、身份认证和权限验证)异常错误处理,兜底所有异常

  • middlewareLoader 核心代码:
const glob = require('glob');
const path = require('path');
const { forEach } = require('lodash');
const { sep } = path;

/**
 * @description middleware loader 配置中间件
 * @param {object} app koa 实例
 *
 * 加载所有的 middleware, 可通过'app.middleware.${目录}.${文件}'访问
 * 例子:
 * app/middleware
 *  |
 *  | -- 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 = {};
  forEach(fileList, (file) => {
    // 提取文件名称
    let name = path.resolve(file);

    // 截取路径 app/middlewares/custom-module/custom-middleware => custom-module/custom-middleware
    name = name.substring(name.lastIndexOf(`middleware${sep}`) + `middleware${sep}`.length, name.lastIndexOf('.'));

    // 把'-' 统一改成驼峰式 custom-module/custom-middleware.js =>customModule.customMiddleware
    name = name.replace(/[/-][a-z]/gi, (s) => s.substring(1).toUpperCase());

    // 挂载 middleware 到内存 app 对象中
    let tempMiddleware = middlewares;
    const names = name.split(`${sep}`);
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) {
        tempMiddleware[names[i]] = require(path.resolve(file))(app);
      } else {
        if (!tempMiddleware[names[i]]) {
          tempMiddleware[names[i]] = {};
        }
        tempMiddleware = tempMiddleware[names[i]];
      }
    }
  });
  app.middlewares = middlewares;
};

service

该目录放置服务文件,服务的功能是封装复杂业务场景下的业务逻辑,从而保持 controller 中的逻辑简洁,且一个 service 可提供给多个 controller 调用,也应分离逻辑和展示,从而便于编写测试用例。如:数据处理,第三方服务调用等
elpis-core中对service的处理跟controller相同,应用中使用 app.service.xxx 获取。
serviceLoader 核心代码:参考controller的实现,把 controller 换成 service

router-schema

该目录放置json-schema文件,该文件的作用主要是对接口数据的描述配合ajv对接口头部、参数等验证。如下方代码就是对接口*/api/project/list*的描述信息。如何写请参考json-schema网网

/**
 * json-schema 描述
 */
module.exports = {
  '/api/project/list': {
    get: {
      query: {
        type: 'object',
        properties: {
          proj_key: {
            type: 'string'
          }
        },
        required: ['proj_key']
      }
    }
  }
};

elpis-core中对router-schema处理跟前几个有所不同,具体看下面代码实现。应用中使用的话是相同的,通过 app.routerSchema.xxx 获取。

  • routerSchemaLoader 核心代码:
const glob = require('glob');
const path = require('path');
const { forEach } = require('lodash');
const { sep } = path;

/**
 * @description 路由schema loader
 * @param {object} app koa 实例
 *
 * 通过 'json-schema' & 'ajv' 对 API 规则进行约束,配置 api-params-verify 中间件的使用
 * app/router-schema/**js
 * 输出:
 * app.routeSchema = {
 *  '${api1}': ${jsonSchema}
 *  '${api2}': ${jsonSchema}
 *  '${api3}': ${jsonSchema}
 *  '${api4}': ${jsonSchema}
 * }
 */
module.exports = (app) => {
  // 读取app/router-schema/**/**.js 下所有文件
  const routerSchemaPath = path.resolve(app.businessPath, `.${sep}router-schema`);
  const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`));

  // 注册所有 routerSchema, 使得可以 'app.roterSchema' 这样访问
  let routerSchema = {};
  forEach(fileList, (file) => {
    routerSchema = {
      ...routerSchema,
      ...require(path.resolve(file))
    };
  });
  app.routerSchema = routerSchema;
};

router

该目录放置路由配置文件,路由的功能定义页面和接口路由,映射对应的controller.
elpis-core中对router处理为收集所有路由注册和一个路由兜底.

  • routerLoader 核心代码:
const KoaRouter = require('koa-router');
const glob = require('glob');
const path = require('path');
const { forEach } = require('lodash');
const { sep } = path;

/**
 * @description router loader
 * @param {object} app koa 实例
 *
 * 解析所有 app/router/ 下所有 js 文件, 加载到 KoaRouter 下
 */
module.exports = (app) => {
  // 找到路由文件路径
  const routerPath = path.resolve(app.businessPath, `.${sep}router`);

  // 实例化 KoaRouter
  const router = KoaRouter();

  // 注册所有路由
  const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
  forEach(fileList, (file) => require(path.resolve(file))(app, router));

  // 路由兜底(健壮性)
  router.get('*', async (ctx, next) => {
    ctx.status = 302; // 临时重定向
    ctx.redirect(`${app?.options?.homePath ?? '/'}`);
  });

  // 路由注册到 app 上
  app.use(router.routes());
  app.use(router.allowedMethods());
};

各个load的加载顺序和存在的问题

这个加载顺序也是其中比较困惑的点,目前使用的顺序是为: config => extend => service => middleware => routerSchema => controller => router,遵循的原则就是被依赖方先加载,就是说后面的没有依赖的放最前面,有依赖前者的就放在前者后面。如:

  • config: 是一个全局的配置,无需依赖任何文件,其他几个都需依赖该文件,放第一个
  • extend:是扩展文件,只依赖配置文件,放第二个
  • controller:依赖多个sevice,放sevice后,
  • router:放最后
  • 其他: 同理

但也有一定的缺点,如复杂的配置文件会影响后面文件的加载。

结尾

以后就是目前对 eplis-core 的理解和应用

注:抖音 “哲玄前端”,《大前端全栈实践课》