elpis 学习笔记之核心引擎(elpis-core)

118 阅读5分钟

elpis 学习笔记之核心引擎(elpis-core)

本文是 抖音“哲玄前端”《大前端全栈实践》的学习笔记

引擎内核设计

elpis 是一个基于 MVC 框架的 SSR 应用,其核心是 elpis-core。用户通过浏览器发送请求,首先请求会先通过层层中间件,处理相应的逻辑,如参数校验、签名验证等等。通过 router 模块,解析请求,然后再通过 controller 模块,调用对应的控制器方法,执行相应的业务逻辑,与service 模块,进行数据交互,最后再生成相应的响应,返回浏览器。

elpis-core

elpis-coreelpis 的内核引擎,它提供了核心的模块加载、核心模块初始化、核心模块配置、启动 koa 服务等核心功能。它要做的是将我们的文件夹结构,按照一定的规则,进行加载,初始化,解析成我们运行时需要的模块。

ELPIS-CORE.png

elpis-core 基于 Koa 实现,采用了经典的洋葱圈模型。这个模型的特点是:

  • 每个中间件都有两次处理机会(进入时和返回时)
  • 请求从外层向内层传递
  • 响应从内层向外层返回
  • 可以在任意层终止请求流程

这里有一个比较经典的关于洋葱圈模型的图:

3662101030-b6f43e0ab542c63b_fix732.webp

在洋葱圈模型中我们每层都代表一个中间件,可以实现不同的功能,如参数校验、签名验证、路由解析等等。每次请求从外层到内层,响应时从内层到外层。 可以借助一段代码来理解洋葱圈模型。

import Koa from 'koa';

const app = new Koa();

// middleware 1
app.use((ctx, next) => {
  console.log(1);
  next();
  console.log(2);
});

// middleware 2
app.use((ctx, next) => {
  console.log(3);
  next();
  console.log(4);
});

// middleware 3
app.use((ctx, next) => {
  console.log(5);
  next();
  console.log(6);
});

app.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

这里的输出是 1 3 5 6 4 2。 以 next() 为界限,当执行到 next() 时,会进入下一个中间件,直到没有中间件为止。然后再执行 next() 后的代码,直至执行完所有中间件。

这样每个中间件都有两次处理逻辑的时机,这样我们可以很好的处理请求的流程,在正确的时机执行我们需要的业务逻辑,也可以及时终止请求,比如在请求开始时,我们可以进行参数校验,如果校验失败,我们可以直接返回错误信息,而不需要继续执行后面的中间件。

elpis-core 的洋葱圈模型体现:

无标题-2025-03-03-1040.png

elpis-core 的实现

目录结构

elpis
├─ app
│  ├─ controller
│  │  ├─ base.js
│  │  └─ **/*.js
│  ├─ extend
│  │  └─  **/*.js
│  ├─ middleware
│  │  └─ **/*.js
│  ├─ middleware.js
│  ├─ public
│  │  ├─ output
│  │  │  ├─ entry.*.tpl
│  │  │  └─ entry.*.tpl
│  │  └─ static
│  │     ├─ logo.png
│  │     └─ normalize.css
│  ├─ router
│  │  └─ **/*.js
│  ├─ router-schema
│  │  └─ **/*.js
│  ├─ service
│  │  └─ **/*.js
├─ config
│  ├─ config.beta.js
│  ├─ config.default.js
│  └─ config.prod.js
├─ elpis-core
│  ├─ env.js
│  ├─ index.js
│  └─ loader
│     ├─ config.js
│     ├─ controller.js
│     ├─ extend.js
│     ├─ middleware.js
│     ├─ router-schema.js
│     ├─ router.js
│     └─ service.js
├─ index.js
└─ package.json

模块加载

elpis-core 通过多个专用加载器实现了模块的自动化加载:

加载器功能描述挂载位置
configLoader加载环境配置app.config
serviceLoader加载数据服务层app.service
middlewareLoader加载中间件app.middlewares
routerLoader加载路由配置app.router
controllerLoader加载控制器app.controller
extendLoader加载扩展功能app.extend
routerSchemaLoader加载路由参数校验规则app.routerSchema

关键实现细节

  1. 路径处理兼容性:使用 path.sep 处理不同操作系统的路径分隔符差异
  2. 环境配置覆盖:默认配置与环境配置智能合并
  3. 自动驼峰转换:将文件名中的连字符自动转为驼峰命名
  4. 模块隔离:每个模块都有独立的作用域和初始化过程
  5. 错误边界处理:完善的错误捕获和日志记录机制

入口文件

elpis-coreindex.js 文件是整个项目的入口文件,它负责完成整个项目的初始化,包括加载配置文件、加载中间件、加载控制器、加载服务、加载路由、启动 Koa 服务等。

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


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

// 引入loader
const configLoader = require('./loader/config');
const serviceLoader = require('./loader/service');
const middlewareLoader = require('./loader/middleware');
const routerSchemaLoader = require('./loader/router-schema');
const controllerLoader = require('./loader/controller');
const extendLoader = require('./loader/extend');
const routerLoader = require('./loader/router');


module.exports = {
 /**
  * 启动项目
  * @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 --`);

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

   // 加载 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 --`);

   // 加载 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 (error) {
     // 找不到文件 
     if (error?.code === 'MODULE_NOT_FOUND') {
       console.log('[exception] there is no global middleware file.');
     } else {
       // 其他异常
       console.log('[exception] failed to load global middleware:', error);
     }
   }

   // 注册路由
   routerLoader(app);
   console.log(`-- [start] load router done --`);

   // start server
   try {
     const port = process.env.PORT || 8080;
     const host = process.env.IP || '0.0.0.0';
     app.listen(port, host, () => {
       console.log(`Server started at ${host}:${port}`);
     })
   } catch (error) {
     console.log('error', error);
   }
 },
}

configLoader

configLoader 用于加载配置文件,它通过读取相应的配置文件,并把配置文件内容赋值给 app.config 属性。

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

/**
 * config loader
 * @param {*} 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) {
    // 找不到文件 
    if (error?.code === 'MODULE_NOT_FOUND') {
      console.log('[exception] there is no default.config file');
    } else {
      // 其他异常
      console.log('[exception] failed to load default.config:', error);
    }
    
  }
  // 获取 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) {
     // 找不到文件 
     if (error?.code === 'MODULE_NOT_FOUND') {
      console.log('[exception] there is no env config file');
    } else {
      // 其他异常 
      console.log('[exception] failed to load env config:', error);
    }
  }

  // 覆盖并加载 config 配置
  app.config = Object.assign({}, defaultConfig, envConfig);
};

routerLoader

routerLoader 用于加载路由,它通过读取相应的路由文件,挂载项目的所有路由到 Koaapp 实例上。

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

/**
 * router loader
 * @param {object} 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(`${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()).use(router.allowedMethods());
};

serviceLoader

serviceLoader 用于加载服务,它通过读取相应的服务文件,并把配置文件内容赋值给 app.service 属性。而各个服务文件执行相应的数据逻辑处理,通过controller返回到相应地请求中。

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

/**
 * service loader
 * 加载所有 service, 可以通过 'app.service.${目录}.${文件}' 访问
 * @param {object} app koa 实例
 * @return {*} void
 * @example:
 * 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(`${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('.'),
    );

    // 把 '-' 统一改为驼峰式, custom-module/custom-service.js => customModule/customService
    name = name.replace(/[_-][a-z]/gi, (s) =>
      s.substring(1).toLocaleUpperCase(),
    );

    // 挂载 service 到内存 app 对象中
    const tempService = service;
    const names = name.split(sep); // [...module, customService]
    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) {
        const ServiceModule = require(path.resolve(file))(app);
        tempService[names[i]] = new ServiceModule();
      } else {
        if (!tempService[names[i]]) {
          tempService[names[i]] = {};
        }
        tempService = tempService[names[i]];
      }
    }
  });

  // 挂载中间件到app上
  app.service = service;
};

controllerLoader

controllerLoader 用于加载控制器,它通过读取相应的控制器文件,并把配置文件内容赋值给 app.controller 属性。而各个控制器文件执行相应业务处理,请求 service 获取数据。

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

/**
 * controller loader
 * 加载所有 controller, 可以通过 'app.controller.${目录}.${文件}' 访问
 * @param {object} app koa 实例
 * @return {*} void
 * @example:
 * app/controller
 *   |
 *   | -- custom-module
 *          |
 *          | -- custom-controller.js
 *  => app.controller.customModule.customCon
 */
module.exports = (app) => {
  // 读取所有中间件 app/controller/**/**.js
  const controllerPath = path.resolve(app.businessPath, `.${sep}controller`);
  const fileList = glob.sync(`${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('.'),
    );

    // 把 '-' 统一改为驼峰式, custom-module/custom-controller.js => customModule/customCon
    name = name.replace(/[_-][a-z]/gi, (s) =>
      s.substring(1).toLocaleUpperCase(),
    );

    // 挂载 controller 到内存 app 对象中
    const tempController = controller;
    const names = name.split(sep); // [...module, customController]
    for (let i = 0, len = names.length; i < len; i++) {
      if (i === len - 1) {
        const ControllerModule = require(path.resolve(file))(app);
        tempController[names[i]] = new ControllerModule();
      } else {
        if (!tempController[names[i]]) {
          tempController[names[i]] = {};
        }
        tempController = tempController[names[i]];
      }
    }
  });
  // 挂载中间件到app上
  app.controller = controller;
};

middlewareLoader

middlewareLoader 用于加载中间件,它通过读取相应的中间件文件,并把配置文件内容赋值给 app.middlewares 属性。

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

/**
 * middleware loader
 * 加载所有middleware,可以通过 'app.middleware.${目录}.${文件}' 访问
 * @param {object} app koa 实例
 * @return {*} void
 * @example:
 * app/middleware
 *   |
 *   | -- custom-module
 *          |
 *          | -- custom-middleware.js
 *  => app.middleware.customModule.customMiddleware
 */
module.exports = (app) => {
  // 读取所有中间件 app/middleware/**/**.js
  const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`);
  const fileList = glob.sync(`${middlewarePath}${sep}**${sep}**.js`);

  // 遍历所有文件目录,把内容加载到 app.middleware 上
  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('.'),
    );

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

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

  // 挂载中间件到app上
  app.middlewares = middlewares;
};

值得注意点是这里的中间件只是将中间件的内容挂到 app.middlewares 上,并没有直接执行。如果需要挂载中间件,需要在全局的中间件文件中手动执行。

// 引入异常捕获中间件
app.use(app.middlewares.errorHandler);

routerSchemaLoader

routerSchemaLoader 用于加载路由的json-schema, 它通过读取相应的json-schema文件,并把配置文件内容赋值给 app.routerSchema 属性。routerSchema 的主要作用是校验路由参数的正确性。至于json-schema 的具体使用,可以参考 json-schema。它主要是一种用于描述和验证 JSON 数据结构的工具,它本身也是一个 JSON 格式。

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

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

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

extendLoader

extendLoader 用于加载扩展模块,它通过读取相应的扩展模块文件,并把配置文件内容赋值给 app.extend 属性。我理解 extend 是项目的额外扩展,如日志、缓存、数据库操作等等。

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

/**
 * extend loader
 * 加载所有 extend, 可以通过 'app.extend.${目录}.${文件}' 访问
 * @param {object} app koa 实例
 * @return {*} void
 * @example:
 * app/extend
 *   |
 *   | -- custom-module
 *          |
 *          | -- custom-extend.js
 *  => app.extend.customModule.customExtend
 */
module.exports = (app) => {
  // 读取所有中间件 app/extend/**/**.js
  const extendPath = path.resolve(app.businessPath, `.${sep}extend`);
  const fileList = glob.sync(`${extendPath}${sep}**${sep}**.js`);

  // 遍历所有文件目录,把内容加载到 app.extend 上
  fileList.forEach((file) => {
    // 提取文件名称
    let name = path.resolve(file);

    // 截取路径 app/extend/custom-module/custom-extend.js => custom-module/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).toLocaleUpperCase(),
    );

    // 过滤 app 已经存在的 key
    for (const key in app) {
      if (key === name) {
        console.log(`[extend load error] name:${name} is already in app`);
        return;
      }
    }

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

总结

通过分析 elpis-core 的实现,学习到了以下几点:

1.模块化设计:清晰的职责划分使得系统易于维护和扩展,每个模块都有明确的边界和接口。

2.约定优于配置:通过目录结构和命名约定自动加载模块,减少了样板代码。

3.中间件机制:洋葱模型提供了极大的灵活性,可以方便地插入各种横切关注点。

4.工程化实践:完善的错误处理、日志记录和环境隔离体现了良好的工程实践。