elpis 学习笔记之核心引擎(elpis-core)
本文是 抖音“哲玄前端”《大前端全栈实践》的学习笔记
引擎内核设计
elpis 是一个基于 MVC 框架的 SSR 应用,其核心是 elpis-core。用户通过浏览器发送请求,首先请求会先通过层层中间件,处理相应的逻辑,如参数校验、签名验证等等。通过 router 模块,解析请求,然后再通过 controller 模块,调用对应的控制器方法,执行相应的业务逻辑,与service 模块,进行数据交互,最后再生成相应的响应,返回浏览器。
elpis-core
elpis-core 是 elpis 的内核引擎,它提供了核心的模块加载、核心模块初始化、核心模块配置、启动 koa 服务等核心功能。它要做的是将我们的文件夹结构,按照一定的规则,进行加载,初始化,解析成我们运行时需要的模块。
elpis-core 基于 Koa 实现,采用了经典的洋葱圈模型。这个模型的特点是:
- 每个中间件都有两次处理机会(进入时和返回时)
- 请求从外层向内层传递
- 响应从内层向外层返回
- 可以在任意层终止请求流程
这里有一个比较经典的关于洋葱圈模型的图:
在洋葱圈模型中我们每层都代表一个中间件,可以实现不同的功能,如参数校验、签名验证、路由解析等等。每次请求从外层到内层,响应时从内层到外层。 可以借助一段代码来理解洋葱圈模型。
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 的洋葱圈模型体现:
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 |
关键实现细节
- 路径处理兼容性:使用
path.sep处理不同操作系统的路径分隔符差异 - 环境配置覆盖:默认配置与环境配置智能合并
- 自动驼峰转换:将文件名中的连字符自动转为驼峰命名
- 模块隔离:每个模块都有独立的作用域和初始化过程
- 错误边界处理:完善的错误捕获和日志记录机制
入口文件
elpis-core 的 index.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 用于加载路由,它通过读取相应的路由文件,挂载项目的所有路由到 Koa 的 app 实例上。
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.工程化实践:完善的错误处理、日志记录和环境隔离体现了良好的工程实践。