【深入浅出】基于Koa实现简易Egg.js

212 阅读4分钟

在设计企业级中后台框架时,约定优先于配置和模块化的理念至关重要。良好的设计能够促进多人协作,提高开发一致性,减少重复配置代码,进而提升开发效率,降低人为错误及复杂度。此外,引入自动化机制可以进一步提升开发效率。

一、约定项目结构

│ app
  │ -- controller # 控制器层,处理业务逻辑和请求响应 
  │ -- extend # 自定义公共组件或一些通用方法
  │ -- middleware # 各种中间件
  │ -- router # 路由配置
  │ -- router-schema # API接口的路由和参数规则
  │ -- service # 服务层,负责数据库与外部API的交互
  │ -- router-schema # 页面组件,每个页面一个文件夹,包含该页面的逻辑和UI 
  │ -- middleware.js # 中间件配置文件
│ config
  │ -- config.default.js  默认系统配置
  │ -- config.local.js    开发环境系统配置
  │ -- config.beta.js     测试环境系统配置
  │ -- config.prod.js     生产环境系统配置
│ elpis-core
  │ -- loader 
       │ -- config.js # 通过 env 读取环境配置
       │ -- controller.js # 加载所有在 app/controller 目录下的controller
       │ -- extend.js # 加载所有在 app/extend 目录下的 extend
       │ -- middleware.js # 加载所有在 app/middleware 目录下的 middleware
       │ -- router-schema.js # 加载所有在 app/router-schema 目录下的 router-schema
       │ -- router.js # 解析所有在 app/router/ 目录下所有的js文件,加载到koaRouter中
       │ -- service.js # 加载所有在 app/service 目录下的 service
  │ -- env.js # Koa应用测试环境配置 
  │ -- index.js # Koa应用生产环境配置 
│ 略

二、基于约定项目结构实现自动挂载机制

1、例controller.js

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

/**
 * controller
 * @param {*} 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 下
  let 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(".")
    );
    
    // 把 '_' 统一改为驼峰式,custome_module/custome_controller.js => customeModule/customecontroller
    name = name.replace(/[_-][a-z]/ig, (s) => s.substring(1).toUpperCase());

    // 挂载 controller 到内存 app 对象中
    const names = name.split(sep); // [ customModule(目录), customController(文件) ]

    // 挂载 controller 到内存 app 对象中去
    let tempController = controller;

    for (let i = 0, len = names.length; i < len; ++i) {
      if (i === len - 1) {
        // 文件
        const tempControllerModule = require(path.resolve(file))(app);
        tempController[names[i]] = new tempControllerModule();
      } else {
        // 文件夹
        if (!tempController[names[i]]) {
          tempController[names[i]] = {};
        }
        tempController = tempController[names[i]];
      }
    }
  });

  app.controller = controller;
};

2、例router.js

const koaRouter = require("koa-router");
const glob = require("glob");
const path = require("path");
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) => {
    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());
};


3、挂载loader

const Koa = require("koa");
const path = require("path");
// 兼容不同操作系统上的斜杠
const { sep } = path;

const env = require("./env");

const middleWareLoader = require("./loader/middleware.js");

const routerSchemaLoader = require("./loader/router-schema.js");

const routerLoader = require("./loader/router.js");

const controllerLoader = require("./loader/controller.js");

const serviceLoader = require("./loader/service.js");

const configLoader = require("./loader/config.js");

const extendLoader = require("./loader/extend.js");

module.exports = {
  /**
   * 启动项目
   * @param {*} options
   * @param {string} options.name 项目名称
   * @param {string} options.homePath 项目首页
   *
   */
  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()} --`);

    // 加载 middleware
    middleWareLoader(app);

    console.log("-- [start] load middleware done --");

    // 加载 routerSchema
    routerSchemaLoader(app);

    console.log("-- [start] load routerSchema done --");

    // 加载 controller
    controllerLoader(app);

    console.log("-- [start] load controller done --");

    // 加载 service
    serviceLoader(app);

    console.log("-- [start] load service done --");

    // 加载 config
    configLoader(app);

    console.log("-- [start] load config done --");

    // 加载 extend
    extendLoader(app);

    console.log("-- [start] load extend done --");

    // 注册全局中间件
    try {
      require(`${app.businessPath}${sep}middleware.js`)(app);
      console.log("-- [start] load global middleware done --");
    } catch (e) {
      console.log("[expection] there is no global middleware file");
    }

    // 加载 router
    routerLoader(app);

    console.log("-- [start] load router done --");

    // 启动服务
    try {
      const port = process.env.PORT || 9527;
      const host = process.env.IP || "0.0.0.0";
      app.listen(port, host);
      console.info(`Server running on port: ${port}`);
    } catch (e) {
      console.error(e);
    }
  },
};

4、总结

通过约定目录结构这样的挂载机制,使得开发者可以更加专注于业务逻辑,而无需手动去配置各个模块或组件,提高开发效率,并使项目结构更加清晰。这种约定优于配置的设计理念,有助于开发团队遵循一致的惯例,促进代码的可维护性和可扩展性。

三、洋葱圈模型解析

image.png

图1

洋葱圈模型是 Koa 框架(Egg.js)中间件执行的核心概念,用于处理请求和响应的顺序。它将中间件的执行过程形象地比作洋葱圈,通过不同层级分层处理请求和响应,体现了单向的执行过程。

中间件执行顺序

  1. 请求处理阶段(从外向内)

    • 当请求到达时,中间件的执行是从外向内的。最外层的 middleware1 首先执行,完成后调用 await next() 将控制权传递给下一个中间件 middleware2,依此类推,直到 middleware3。当所有的中间件执行完成后,请求会被发送到路由处理的controller控制器。
  2. 响应处理阶段(从内向外)

    • 当控制器处理完请求并返回结果后,响应的处理则是从内层向外层进行的。相应会首先在最内层的中间件,上层的中间件可以访问到响应数据,进行处理,最终将响应返回给客户端。

原因

这种设计方式的原因在于

  1. 灵活性

    • 在请求处理阶段,外层的中间件可以负责全局的操作,比如api的参数、签名校验等等,如果请求不成立,则可以直接返回响应。
  2. 控制权

    • 在响应处理阶段,开发者可以针对响应数据进行修改,最终返回给客户端,实现更好的控制。

四、总结

工欲善其事,必先利其器。一个良好的架构就如同一台高效的发动机,为系统提供稳定而强大的动力输出。同时,一个优秀的架构应具备应对高负载和灵活扩展的能力,以便为团队创造相对更好的工作条件,使得每个人能够专注于业务,尽可能地避免回头处理基础设施问题。

powered by 抖音“哲玄前端”,《全栈实践课》