1. 背景
本文是一个基于node的全栈学习项目,项目coding也引发思考复习,可能会引用多篇文章进行学习,如有侵权请联系我处理。
2. 简介
日常开发中,单页面内重复编写增删改查方法、多次调用接口请求数据的场景十分常见,这类高重复性工作耗费大量开发精力。elpis 应用框架应运而生,旨在高效解决此类开发痛点。
3. git规范
首先开发前定义好规范,参考常规开发的gitflow协同分支规范
3.1 git分支规范
由于团队成员暂时只有我一个,不采用release分支
1.master分支是固定的,线上分支,永远与线上代码一致
2.develop分支是固定的,又叫研发分支 ,当develop分支的发布线上测试回归完成,才可以合并到master分支
3.feature分支是功能分支,有多个功能分支,就feature-a -b这样如果开发完成,测试没有问题,可以合并到develop分支
4.hotfix分支是修复分支,当遇到线上问题,从hotfix分支拉下来修改完成,可以合并到master分支
开发常规流程 从develop分支拉取代码===>创建feature-xx开发分支 ===>开发测试完成====>合并到develop====>与原先线上功能回归完成====>合并到master
线上问题流程 master分支拉取代码====>新建分支hotfix-xx===>开发测试完成===>合并到master分支====>合并到develop分支
3.2 git提交规范
在团队协作开发软件项目时,遵循 统一的 commit 规范(一般为Angular JS规范) 对于保持代码仓库的整洁 和提高团队成员之间的沟通效率 至关重要。 这里采用的是husky+commitlint
husky 是一个 git hooks 管理工具,它可以简化你在 git仓库 中设置 hook 的过程。它就像是个尽职尽责的保安,可以在 git 的各种操作环节添加自定义指令操作。(完整的 git hooks 说明可以查阅 Git - githooks Documentation (git-scm.com) 亦或是在网上查阅其它开发者分享的翻译介绍版本)
4. 基于node实现服务端内核引擎BFF层
4.1 背景
elpis-core 是 elpis 应用框架的核心,相当于服务端和客户端之间的 "中间桥梁"。它的工作逻辑是:先对收到的请求做一系列校验和预处理,等通过所有校验后,再进入核心区域处理具体业务(比如渲染页面、从服务端读取数据等),最后把处理结果一步步返回出去。所以,我们需要搭配各类处理器,来统一完成这些流程步骤的处理。同时参考egg.js的约定式编程来进行设计架构
4.2 设计调研
4.2.1 调研请求处理或中间件执行模型
在软件工程、Web 开发、系统架构等领域,有多种经典的请求处理或中间件执行模型。它们各自适用于不同场景,设计理念也各不相同。
| 模型名称 | 执行方向 | 是否支持响应阶段逻辑 | 核心机制 | 典型应用场景 | 代表框架/技术 |
|---|---|---|---|---|---|
| 洋葱圈模型 (Onion Model) | 双向 (外 → 内 → 外) | ✅ 是 (通过 await next() 后执行) |
中间件包裹式调用,next() 控制进入下一层,返回后执行后置逻辑 |
- Web 服务中间件 - 日志/计时/错误捕获 - 需要"环绕"逻辑的场景 | Koa.js |
| 管道模型 (Pipeline / Chain) | 单向 (请求 → 响应) | ❌ 否 (通常只处理请求,响应由最终处理器直接返回) | 请求线性流经中间件链,每个中间件决定是否继续传递 | - 简单 API 服务 - 身份验证 → 权限 → 路由 - 高性能网关 | Express.js, Laravel, ASP.NET Core |
| 拦截器模型 (Interceptor Pattern) | 分离式 (请求拦截器 + 响应拦截器) | ✅ 是 (显式定义两个独立拦截器) | 显式注册请求前和响应后处理器,职责完全解耦 | - HTTP 客户端封装 - 统一添加 Token/日志 - API 调用增强 | Axios, Spring Boot RestTemplate, Fetch Interceptors |
| 事件驱动模型 (Event-Driven) | 无固定方向 (基于事件触发) | ⚠️ 间接支持 (通过监听 'response' 等事件) |
请求/响应作为事件发布,监听器异步处理 | - 实时通信(聊天、推送) - 异步任务处理 - 微服务消息总线 | Node.js (原生), Socket.IO, RabbitMQ, Kafka |
| AOP 切面模型 (Aspect-Oriented Programming) | 编织式 (编译期或运行时织入) | ✅ 是 (@Around 可包裹整个方法) | 将横切关注点(日志、事务等)从主业务中分离,动态织入 | - 企业级后台系统 - 数据库事务管理 - 统一权限/审计日志 | Spring AOP, NestJS Interceptors, AspectJ |
| 你想实现...... | 推荐模型 |
|---|---|
| "请求进来打日志,响应出去再打一次日志" | ✅ 洋葱圈模型(Koa) |
| "所有 API 调用自动加 Token" | ✅ 拦截器模型(Axios) |
| "用户登录后发欢迎邮件" | ✅ 事件驱动模型 |
| "数据库操作自动开启/提交事务" | ✅ AOP 切面模型 |
| "简单中间件:鉴权 → 限流 → 路由" | ✅ 管道模型(Express) |
由于我们使用的技术栈是koa 得知这里处理中间件是用的洋葱圈模型
4.2.2 调研软件系统结构
软件架构模式(Software Architectural Pattern)是用来解决"如何组织大型软件系统结构"的通用方案,目的是让系统更易开发、维护、扩展和演化。
4.2.3 调研常见软件架构模式及典型应用场景
| 架构模式 | 核心思想 | 典型应用场景 | 代表技术/框架 |
|---|---|---|---|
| 分层架构(Layered Architecture) | 按职责分层(如表现层、业务层、数据层) | 传统企业应用、CRUD 系统 | Spring Boot, Django, Laravel |
| MVC / MVVM | 分离数据、视图、控制逻辑 | Web 应用(前后端)、桌面 GUI | Rails (MVC), Vue/Angular (MVVM) |
| 微服务架构(Microservices) | 将系统拆分为多个独立服务 | 大型互联网产品、高并发系统 | Netflix, Alibaba, Kubernetes |
| 事件驱动架构(Event-Driven) | 组件通过事件异步通信 | 实时系统、消息通知、IoT | Kafka, RabbitMQ, AWS Lambda |
| 六边形架构 / 端口适配器(Hexagonal) | 业务核心与外部解耦 | 需要多端适配(Web/API/CLI) | DDD(领域驱动设计)项目 |
| Clean Architecture | 依赖只向内,业务逻辑独立于框架 | 长期维护的复杂业务系统 | Robert C. Martin 提出,广泛用于金融、医疗 |
| CQRS(命令查询职责分离) | 读写操作使用不同模型 | 高性能读写分离场景 | 电商库存、金融交易系统 |
我们的场景是通过服务产出常见的后台管理系统,这里采用的是mvc的软件架构模式,下面会有详解
4.2.4 详解洋葱圈模型
这个洋葱模型精准对应 elpis-core 作为中间层的请求处理流程,核心逻辑如下:
- 最外层(请求层) :所有客户端请求首先进入 elpis-core 中间层,这是整个处理流程的入口;
- 第二层(全局错误处理) :请求进入后先经过全局错误处理层,提前拦截通用异常(如请求格式错误、连接异常,内部错误等),避免无效请求进入内层;
- 第三层(API 签名验证) :验证通过后,进入 API 签名校验层,确保请求来源合法、未被篡改,保障接口安全性;
- 第四层(API 参数验证) :签名验证通过后,校验请求参数的完整性、格式、取值范围等,过滤不合法参数;
- 核心层:经过以上四层校验 / 处理后,请求才会抵达核心业务逻辑处理区域,执行页面渲染、服务端数据读取等核心操作;
- 返回流程:核心业务处理完成后,结果会按 "参数验证→签名验证→错误处理→请求层" 的反向顺序返回,最终输出响应给客户端(符合洋葱模型 "先进后出" 的核心特征)。
4.2.5 详解MVC
MVC(Model-View-Controller)是一种软件架构模式(Architectural Pattern) ,主要用于分离关注点(Separation of Concerns) ,特别适用于交互式应用程序 (如 Web 应用、桌面 GUI 应用)。它不属于 前面提到的"中间件执行模型"(如洋葱圈、管道等),而是更高层次的 应用整体结构设计模型。是一种将应用程序分为三个核心组件的架构模式
- Model(模型) :管理数据和业务逻辑
- View(视图) :负责展示 UI(用户界面)
- Controller(控制器) :接收用户输入,协调 Model 和 View
| 组件 | 职责 | 示例 |
|---|---|---|
| Model | - 数据存取(数据库、API) - 业务规则(验证、计算) - 状态管理 | User 类、OrderService、数据库 ORM 模型 |
| View | - 渲染 UI(HTML、模板、前端组件) - 不包含业务逻辑 - 被动显示数据 | Nunjucks/EJS 模板、React/Vue 组件(在传统 MVC 中) |
| Controller | - 接收 HTTP 请求 - 调用 Model 处理数据 - 选择 View 并传入数据 - 返回响应 | Koa/Express 路由处理函数 |
fs: 思考复习
fs: mvc和mvvm有什么区别
- MVC:你(Controller)告诉页面(View)"去显示某某数据"
| 文件 | 职责 | 谁主动? |
|---|---|---|
| Model | 数据和业务逻辑(如数据库操作、计算) | 被动 |
| View | 展示 UI(HTML/模板),不包含逻辑 | 被动 |
| Controller | 主动协调者: - 接收用户输入 - 调用 Model - 选择 View 并传数据 | ✅ 主动 |
- MVVM :你改了数据,页面自动更新(双向绑定)
| 组件 | 职责 | 关键机制 |
|---|---|---|
| Model | 同 MVC:数据和业务逻辑 | - |
| View | UI 层(HTML + 绑定指令),不直接操作数据 | 数据绑定(Data Binding) |
| ViewModel | 暴露可绑定的数据和命令 (如 user.name, save()) 自动同步 View ↔ Model |
双向绑定(Two-way Binding) |
- 主要区别
| 对比项 | MVC | MVVM |
|---|---|---|
| 核心驱动 | Controller 主动控制 | 数据绑定自动同步 |
| 数据流 | 单向(手动更新 View) | 双向(View ↔ ViewModel 自动同步) |
| DOM 操作 | 需要手动渲染/更新 | 框架自动处理 |
| 典型场景 | 后端 Web 应用(SSR) | 前端 SPA 应用 |
| 代表框架 | Rails, Laravel, Spring MVC | Vue.js, Angular |
| 学习成本 | 低(逻辑直观) | 中(需理解响应式原理) |
| 适合团队 | 后端主导项目 | 前端主导的交互密集型应用 |
4.3 详细设计
从我们的技术调研中,做出我们的技术栈选型+整体文件说明+架构整体说明+以及启动流程
4.3.1 核心框架采用
- Koa 2.7.0:轻量级 Node.js Web 框架
- @koa/router:路由管理
- TypeScript 5.9.3:类型安全
4.3.2 模板引擎中间件
- koa-nunjucks-2:服务端渲染(SSR)
- Less:CSS 预处理器
4.3.3 数据库采用
- MySQL:关系型数据库
4.3.4 工具库
- md5:MD5 加密
4.3.5 开发工具
- ESLint:代码质量检查
- Husky + Commitlint:Git 提交规范
- Nodemon:热重载开发
- Webpack:模块打包
4.3.6 设计整体文件结构
csharp
elpis/
├── app/ # 业务代码目录
│ ├── controller/ # 控制器层(处理业务逻辑)
│ │ ├── project.ts # 项目控制器示例
│ │ └── view.ts # 视图控制器
│ ├── service/ # 服务层(数据操作)
│ │ └── project.ts # 项目服务示例
│ ├── router/ # 路由定义
│ │ ├── project.ts # 项目路由
│ │ └── view.ts # 视图路由
│ ├── middleware/ # 自定义中间件
│ │ ├── api-params-verify.ts # API 参数验证
│ │ ├── api-sign-verify.ts # API 签名验证
│ │ └── error-handle.ts # 错误处理
│ ├── views/ # 模板文件
│ │ ├── entry.error.html # 错误页面
│ │ └── entry.page.html # 通用页面
│ ├── public/ # 静态资源
│ └── middleware.ts # 全局中间件配置
├── config/ # 配置文件
│ ├── config.local.ts # 本地环境配置
│ ├── config.beta.ts # 测试环境配置
│ ├── config.prod.ts # 生产环境配置
│ └── config.default.ts # 默认配置
├── constants/ # 常量定义
│ ├── routerPath.ts # 路由路径常量
│ └── responseCode.ts # 响应码常量
├── elpis-core/ # 核心启动器
│ ├── index.ts # 启动入口
│ ├── env.ts # 环境变量
│ └── loader/ # 加载器
│ ├── config.ts # 配置加载器
│ ├── controller.ts # 控制器加载器
│ ├── service.ts # 服务加载器
│ ├── router.ts # 路由加载器
│ ├── middleware.ts # 中间件加载器
│ ├── tool.ts # 工具函数
│ └── ...
├── elpis-base/ # 基础类
│ ├── controller.ts # 控制器基类
│ ├── service.ts # 服务基类
│ └── baseFn.ts # 基础函数(success/fail)
├── elpis-types/ # 类型定义
│ ├── core.ts # 核心类型
│ └── common.ts # 通用类型
├── index.ts # 项目入口
└── package.json # 依赖配置
4.3.7 设计启动流程
scss
index.ts
↓
ElpisCore.start()
↓
1. mountBaseContent() - 初始化环境和路径
↓
2. mountLoader() - 加载各模块
├─ configLoader() - 加载配置
├─ middlewareLoader() - 加载中间件
├─ routerSchemaLoader()- 加载路由 Schema
├─ controllerLoader() - 加载控制器
├─ serviceLoader() - 加载服务
├─ extendLoader() - 加载扩展
├─ globalMiddleware() - 注册全局中间件
└─ routeLoader() - 注册路由
↓
3. startServer() - 启动 HTTP 服务
4.3.8 核心类图
arduino
Koa App (ElpisApp)
├── controllers - 控制器实例映射
├── services - 服务实例映射
├── middlewares - 中间件函数映射
├── router - KoaRouter 实例
├── tools - 工具函数集合
├── config - 配置对象
├── logger - 日志实例
└── ElpisEnv - 环境工具
4.3.9 整体架构说明
- ✅ 自动模块加载:控制器、服务、中间件等自动扫描加载
- ✅ 多环境支持:本地/测试/生产环境隔离
- ✅ TypeScript:完整的类型定义
- ✅ SSR 渲染:支持 Nunjucks 模板引擎
- ✅ 中间件系统:可扩展的中间件机制
- ✅ API 签名验证:内置请求签名和安全验证
- ✅ 参数校验:基于 JSON Schema 的参数验证
- ✅ 统一响应格式:标准化的成功/失败响应封装
4.4 具体实现
上面是我们的设计的文件架构,中间件架构,根据设计得出具体实现
4.4.1 最外层入口 elpis/index.ts
- 路径:elpis/index.ts 引入我们实现的elpis-core可以添加额外的参数,来进行启动
ts
// 引入 elpis - core
import ElpisCore from "./elpis-core/index";
// 启动项目
ElpisCore.start({
name: "elpis",
errorPathName: "error",
});
4.4.2 实现elpis-core入口 引入koa实例 挂载对应内容
- 路径:elpis/elpis-core/index.ts
- 先引入koa,得到koa实例 开启一个服务
ts
const ElpisCore = {
/**
* @description 创建koa实例 启动项目
* @param {Object} options 配置项
*/
async start(options: ElpisStartOptions) {
console.log("\n\n开始启动服务...\n\n");
// 启动实例
const app = new Koa() as ElpisApp;
// 加载基础内容
mountBaseContent(app, options);
// 加载内容
await mountLoader(app);
// 启动服务
startServer(app);
},
};
export default ElpisCore;
4.4.3 实现基础内容挂载
为了方便开发 我们这里挂载项目的基础路径,运用路径,和环境变量
- 路径:elpis/elpis-core/index.ts
ts
/**
* @description 负责加载环境变量、基础信息
* @param app
* @param options
*/
function mountBaseContent(app: ElpisApp, options: ElpisStartOptions) {
app.ElpisEnv = env();
app.basePath = process.cwd();
app.businessPath = path.resolve(app.basePath, `.${sep}app`);
app.options = options;
console.log("应用根目录是=====>>app.basePath", app.basePath);
console.log("应用路径是=====>>app.businessPath", app.businessPath);
console.log("环境变量是=====>>app.env", app.ElpisEnv.getEnv());
}
fs:思考复习
fs:process是什么有什么用:
process 是 Node.js 运行时提供的一个内置对象,用于获取进程信息、控制程序行为、处理系统信号、管理环境变量等。
fs: 核心作用 & 常见用途
ts
/**
* @description process.env.NODE_ENV 获取环境变量(如 NODE_ENV)
* 实际开发中常用 `.env` 文件 + `dotenv` 库加载配置。
*/
console.log(process.env.NODE_ENV); // 'development' / 'production'
/**
* @description process.argv 读取命令行参数
* 比如运行了一个脚本 node app.js --port=3000
* 输出
* [
* Node.js 可执行文件的绝对路径(就是你用node版本的文件),
* 当前正在执行的 JavaScript 文件的绝对路径,
* 用户传入的自定义命令行参数
* ]
*/
console.log(process.argv); // 输出: ['/usr/bin/node', '/path/to/app.js', '--port=3000']
/**
* @description 退出进程 process.exit();
* 主动退出进程(process.exit()) 虽然不常用,但在特定场景下是必要且关键的操作。
* 正常退出(状态码 0) process.exit(0);
* 异常退出(状态码 1) process.exit(1) 会立即终止进程,未完成的异步操作(如写文件、发请求)会被丢弃!
*/
// 示例:比如你写了一个脚本 数据迁移脚本 migrate.js
async function runMigration() {
try {
await db.connect();
await migrateData();
console.log('✅ 迁移成功!');
process.exit(0); // 成功退出
} catch (err) {
console.error('❌ 迁移失败:', err.message);
process.exit(1); // 失败退出(非零状态码)
}
}
// 为什么需要?
// 如果不调用 exit(),Node.js 会因事件循环中仍有未关闭的连接(如数据库)而一直挂起不退出。
/**
* @description 监听系统信号(优雅关闭)
*/
// 监听 Ctrl+C(SIGINT)或 kill 命令(SIGTERM)
process.on('SIGINT', () => {
console.log('收到中断信号,正在优雅关闭...');
// 关闭服务器、断开数据库...
server.close(() => {
process.exit(0);
});
});
process.on('SIGTERM', () => {
// Kubernetes 或 PM2 发送的终止信号
// 同样执行优雅关闭逻辑
});
/**
* @description 获取进程信息
*/
console.log(process.pid); // 当前进程 ID
console.log(process.version); // Node.js 版本(如 v18.17.0)
console.log(process.platform); // 操作系统('linux', 'darwin', 'win32')
console.log(process.cwd()); // 当前工作目录
console.log(process.memoryUsage()); // 内存使用情况(heapUsed, rss 等)
/**
* @description 标准输入/输出流(stdin/stdout/stderr)
*/
// 向控制台输出(等同于 console.log) console.log 本质是 process.stdout.write 的封装。
process.stdout.write('Hello\n');
// 从命令行读取输入
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
console.log('你输入了:', chunk.trim());
if (chunk.trim() === 'exit') process.exit(0);
});
process.stdin.resume(); // 必须调用才能开始监听
// 总之 process 是你和操作系统之间的桥梁
4.4.4 实现加载工具函数、路由、中间件、控制器、服务、配置等内容
- 路径:elpis/elpis-core/loader
- 根据我们的设计我们采用是约定式编程,且有自动模块加载功能,我们将模块分为下面几类,然后做出自动加载模块的功能,就是当服务启动时,可以自动加载app/xxx下面的内容做到该注册的注册,该使用的可以直接放在app实例中,让app.xxx这样来使用做到便捷开发,让app这个实例贯穿我们整个服务。
4.4.4.1 tool工具函数
- 路径:elpis/elpis-core/loader/tool.ts
- 工具函数,挂载到app上,使别的模块可以app.tool.xxx直接使用我们写好的工具函数。 例子:因为我们要做自动加载模块的功能,可以封装出这么一个自动加载文件模块的功能函数。
ts
/**
* 自动加载指定目录下的模块,并按路径结构构建嵌套对象
* @param {string} basePath - 基础目录路径(如 app.businessPath)
* @param {string} subDirName - 子目录名(如 'middleware')
* @param {object} app - 应用实例(传递给每个模块)
* @param {string[]} optionExtra - 其他参数 extensions要加载的文件扩展名(默认 ['.js', '.ts'])
* @returns {object} 嵌套的对象结构
*/
async function autoLoadModules(
basePath: string,
subDirName: string,
app: any,
optionExtra?: string[],
): Promise<Record<string, any>> {
// 构建完整目录路径
const targetDir = path.resolve(basePath, subDirName);
// 获取所有匹配的文件
const files = getFilesToPath(basePath, subDirName);
// 最终结果对象
const result: Record<string, any> = {};
// 遍历每个文件
for (const file of files) {
// 1. 获取相对于 targetDir 的路径(如 'user/auth/login.js')
let relativePath = path.relative(targetDir, file);
// 2. 转换下划线/短横线为驼峰(如 user_profile → userProfile)
relativePath = relativePath.replace(/[_-]([a-z])/g, (match, letter) =>
letter.toUpperCase(),
);
// 3. 将路径分隔符替换为 '.'
let dotPath = relativePath.replace(new RegExp(`\\${path.sep}`, "g"), ".");
// 4. 移除扩展名
dotPath = removeScriptExt(dotPath);
// 5. 分割为键数组
const keys = dotPath.split(".");
// 6. 加载模块(传入 app)
let module: any;
try {
const moduleResult = await diyRequire(file, app);
if (moduleResult.success) {
module = moduleResult.module;
} else {
module = {};
console.log(
`[autoLoadModules] Failed to load ${file}:`,
moduleResult.error,
);
}
} catch (err) {
module = {};
console.log(`[autoLoadModules] Failed to load ${file}:`, err);
}
// 7. 构建嵌套对象:{ a: { b: { c: module } } }
const nested = keys.reduceRight((acc, key) => ({ [key]: acc }), module);
// 8. 合并到 result(支持多文件共享顶层命名空间)
const topKey = keys[0];
if (result[topKey] === undefined) {
result[topKey] = nested[topKey];
} else {
// 如果已存在,需要深合并(简单递归合并)
deepMerge(result[topKey], nested[topKey]);
}
}
return result;
}
export default (app: ElpisApp): Tools => {
return {
autoLoadModules,
};
};
然后在index.ts中挂载到app上
ts
/**
* @description 负责加载工具函数、路由、中间件、控制器、服务、配置等内容
* @param app
*/
async function mountLoader(app: ElpisApp) {
// 加载工具函数
app.tools = tool(app);
}
4.4.4.2 configLoader
- 路径:elpis/elpis-core/loader/config.ts
- 配置区分 本地/测试/生产,通过env环境读取不同文件配置 env.config,功能就是当你在启动这个服务的时候,会去读取app/config 里面的文件 将里面的配置自动加载到app.config中
ts
const configLoader = async (app: ElpisApp): Promise<void> => {
// 找到 config/ 目录
const configPath = path.resolve(app.basePath || "", `.${sep}config`);
// 获取 default.config
let defaultConfig = {};
try {
const module = await import(
path.resolve(configPath, `.${sep}config.default.ts`)
);
defaultConfig = module.default || module;
} catch {
console.log(`[exception] there is no default.config file `);
}
// 获取 env.config
let envConfig = {};
try {
if (app.ElpisEnv.isLocal()) {
// 本地环境
const module = await import(
path.resolve(configPath, `.${sep}config.local.ts`)
);
envConfig = module.default || module;
} else if (app.ElpisEnv.isBeta()) {
// 测试环境
const module = await import(
path.resolve(configPath, `.${sep}config.beta.ts`)
);
envConfig = module.default || module;
} else if (app.ElpisEnv.isProduction()) {
// 生产环境
const module = await import(
path.resolve(configPath, `.${sep}config.prod.ts`)
);
envConfig = module.default || module;
}
} catch (error) {
console.log(`[exception] elpisEnv mounted error `, error);
}
// 覆盖并加载 config 配置
app.config = Object.assign({}, defaultConfig, envConfig);
console.log(`\n全局配置加载完成=====>>config 挂载app值\n`, app.config);
};
export default configLoader;
4.4.4.3 middlewareLoader
- 路径:elpis/elpis-core/loader/middleware.ts
- 这个就是配置中间件加载,当我们启动服务的时候,会自动去读app/middleware 里面所有的文件,来加载到app上,这样可以在加载中间件的时候可以直接用app.middleware.xxx来进行加载中间件
ts
/**
* @description middleware 加载器
* @param {object} app koa 实例
* 加载所有 middleware ,可通过'app.middleware.目录.文件'来进行访问
* 例子
* app/middleware
* | -- custom-module
* |-- custom-middleware.js
* ====> app.middleware.customModule.customMiddleware
*/
async function middlewareLoader(app: ElpisApp) {
const middlewares = app.tools.autoLoadModules(
app.businessPath,
"middleware",
app,
);
app.middlewares = (await middlewares) ?? {};
console.log(
`\n全局middleware加载完成=====>>middleware 挂载app值\n`,
app.middlewares,
);
}
export default middlewareLoader;
4.4.4.4 routerSchemaLoader
- 路径:elpis/elpis-core/loader/router-schema.ts
- 这个是配置router-schema作用是通过 "json-schema & ajv" 对api规则进行约束,作用就是对api的参数进行约束,举个例子 开发了一个接口,接口路径是api/project/list,然后你需要限定这个接口必须传参数project_key,那么你就可以通过配置来进行约束。这里是为了以后中间件使用的时候好拿到对应的规则,那么这里处理成这种形式。
ts
/**
* router schema loader
* @param {object} app koa实例
* 通过 "json-schema & ajv" 对api规则进行约束,配合api-params-verify 中间件使用
*
* app/router-schema/**.js
输出
app.routerSchema = {
'${api1}': ${jsonSchema1},
'${api2}': ${jsonSchema2},
}
*/
function mergeRouterSchemas(
routerSchemaArray: Array<Record<string, Record<string, any>>>,
): Record<string, Record<string, any>> {
return routerSchemaArray.reduce((acc, curr) => {
Object.keys(curr).forEach((key) => {
if (!acc[key]) {
acc[key] = {};
}
Object.assign(acc[key], curr[key]);
});
return acc;
}, {});
}
async function routerSchemaLoader(app: ElpisApp) {
const routerSchema = await app.tools.autoLoadModules(
app.businessPath,
"router-schema",
app,
);
// 将各个文件的routerSchema进行合并
const allRouterSchema = mergeRouterSchemas(Object.values(routerSchema));
app.routerSchema = allRouterSchema;
console.log(
`\n全局routerSchema加载完成=====>>routerSchema 挂载app值\n`,
app.routerSchema,
);
}
export default routerSchemaLoader;
4.4.4.5 controllerLoader
- 路径:elpis/elpis-core/loader/controller.ts
- 这个同理只不过是mvc架构中的c层 负责处理具体业务逻辑的地方。这里就会自动加载app/controller下面所有的文件
ts
async function controllerLoader(app: ElpisApp) {
const controller = await app.tools.autoLoadModules(
app.businessPath,
"controller",
app,
);
app.controller = controller ?? {};
console.log(
`\n全局controller加载完成=====>>controller 挂载app值\n`,
app.controller,
);
}
export default controllerLoader;
4.4.4.6 serviceLoader
- 路径:elpis/elpis-core/loader/service.ts
- 这个同理只不过是mvc架构中的m层 负责数据存取(数据库、API)。这里就会自动加载app/service下面所有的文件
ts
async function serviceLoader(app: ElpisApp) {
const service = await app.tools.autoLoadModules(
app.businessPath,
"service",
app,
);
app.service = service;
console.log(`\n全局service加载完成=====>>service 挂载app值\n`, app.service);
}
export default serviceLoader;
4.4.4.7 extendLoader
- 路径:elpis/elpis-core/loader/extend.ts
- 这里是加载app/extend 下面所有文件的地方,extend主要是用来加载一下扩展的函数,比如日志之类的,因为都是函数所以这里我们处理直接挂载到app上面 不同于其他loader 直接可以app.xxx使用
ts
async function extendLoader(app: ElpisApp) {
const extend = await app.tools.autoLoadModules(
app.businessPath,
"extend",
app,
);
// 将所有文件的导出内容挂载到 app.extend 上
app.extend = extend ?? {};
// 同时这里将app.extend 里面的值都挂载到app上,方便直接调用 app.${文件名} 访问
Object.keys(app.extend).forEach((key) => {
app[key] = app.extend[key];
});
console.log(`\n全局extend加载完成=====>>extend 挂载app值\n`, app.extend);
}
export default extendLoader;
4.4.4.8 globalMiddleware
- 路径:elpis/elpis-core/loader/global-middleware.ts
- 这里是加载全局中间件的,加载其他中间件都将在这个文件运行
ts
/**
* 加载全局中间件
* @param app koa 实例
*/
const globalMiddleware = async (app: ElpisApp) => {
const middleware = await import(`${app.businessPath}${sep}middleware.ts`);
middleware.default(app);
console.log(`\n全局中间件加载完成=====>>globMiddleware\n`);
};
export default globalMiddleware;
4.4.4.9 routerLoader
- 路径:elpis/elpis-core/loader/router.ts
- 这里就是加载路由,将所有app/router下面的文件 都注册到koa-router中
ts
/**
* router loader
* @param {object} app koa app
*
* 解析所有app/router/ 下所有js文件,并加载到KoaRouter下
*/
async function routerLoader(app: ElpisApp) {
// 实例化 KoaRouter
const router = new KoaRouter();
// 找到路由文件路径 注册所有路由
const filesList = app.tools.getFilesToPath(app.businessPath, "router");
// 等待所有路由文件加载完成
await Promise.all(
filesList.map(async (filePath) => {
await app.tools.diyRequire(filePath, app, router);
}),
);
// 兜底路由
router.get("*", async (ctx: Ctx) => {
ctx.status = 302;
ctx.redirect(`${app?.options?.homePath ?? "/"}`);
});
// 路由注册到 app 上
app.use(router.routes()).use(router.allowedMethods());
app.router = router;
console.log(`\n全局router注册完成=====>>router\n`, app.router);
}
export default routerLoader;
fs:思考复习
fs:app.use的本质是什么,为什么不使用app.use来直接实现路由
app.use(fn) 将一个中间件函数 fn 添加到 Koa 应用的中间件栈(Middleware Stack)中,当 HTTP 请求到来时,Koa 会按注册顺序依次执行这些中间件。
fs:中间件是什么
广义的中间件就是指位于底层系统和上层应用之间的胶水层 。而这里的中间件是koa通过基于 async/await + next() 的洋葱圈模型来实现的。 koa中中间件是一个 异步函数,接收两个参数:
ctx(Context):Koa 封装的请求/响应上下文对象next(Function):调用下一个中间件的函数
fs:为什么要设计成async这种形式
不用 async 会有什么问题?(以 Express 为例)
- 问题 1:无法优雅实现"响应阶段逻辑"
js
// Express 中间件(回调风格) 在 Express 中,中间件是单向的:
app.use((req, res, next) => {
console.log('请求进入');
next();
// ❌ 这里不能写"响应返回后"的逻辑!
// 因为 next() 是同步调用,执行完就结束了
});
// 想记录响应时间?只能 hack:
const start = Date.now(); res.on('finish', () => { console.log('耗时:', Date.now() - start); });
- 问题 2 : 异步操作导致流程断裂
js
// 如果中间件中有异步操作(如查数据库),Express 容易出错:
app.use((req, res, next) => {
db.query('SELECT ...', (err, data) => {
if (err) return next(err); // 必须手动传错误
req.user = data;
next(); // 必须记得调用 next()
});
// ❌ 如果忘记调用 next(),请求就卡死了!
});
为什么 async/await 能完美解决这些问题?
- 核心优势 1:天然支持"双向执行"(洋葱圈模型)
js
app.use(async (ctx, next) => {
const start = Date.now(); // ← 请求阶段
await next(); // ← 等待内层所有中间件执行完
const ms = Date.now() - start; // ← 响应阶段(可访问 start!)
console.log(`${ctx.method} ${ms}ms`);
});
// `await next()` 让代码"暂停"并等待内层完成,之后继续执行
// 这正是洋葱圈模型的灵魂所在!
- 异步操作像同步代码一样写
js
app.use(async (ctx, next) => {
// 查用户(异步)
ctx.state.user = await User.findById(ctx.cookies.get('uid'));
// 鉴权(同步逻辑)
if (!ctx.state.user) {
ctx.throw(401, 'Unauthorized');
}
// 继续
await next();
// **无需回调嵌套,无需手动调用 `next()` 处理异步**
// 错误会自动抛出,被外层 `try/catch` 或错误中间件捕获。
});
总结:为什么 Koa 设计成 async 形式?
| 原因 | 说明 |
|---|---|
| 1. 实现洋葱圈模型 | await next() 是双向执行的唯一简洁方式 |
| 2. 解决回调地狱 | 异步代码写起来像同步,逻辑线性 |
| 3. 简化错误处理 | Promise 错误自动冒泡,无需手动传递 |
| 4. 提升开发体验 | 代码更少、更直观、更少 bug |
| 5. 拥抱现代 JS | 利用语言原生能力,而非库 hack |
fs:koa-router是什么
koa-router 是一个为 Koa 框架提供 RESTful 路由功能的中间件,支持 GET/POST/PUT/DELETE 等方法、参数解析、嵌套路由、命名路由等高级特性。
fs:为什么需要它?
Koa 本身不包含路由功能 (这是 Koa "轻量"哲学的一部分)。
如果你不用 koa-router,只能这样写:
js
// ❌ 原生 Koa:手动判断路径(非常原始!)
app.use(async (ctx, next) => {
if (ctx.path === "/users" && ctx.method === "GET") {
ctx.body = "List users";
} else if (ctx.path === "/users" && ctx.method === "POST") {
ctx.body = "Create user";
} else {
await next();
}
});
而用 koa-router 后:
js
// ✅ 使用 koa-router:清晰、可维护
const Router = require('koa-router');
const router = new Router();
router.get('/users', async (ctx) => {
ctx.body = 'List users';
});
router.post('/users', async (ctx) => {
ctx.body = 'Create user';
});
app.use(router.routes());
fs:为什么使用json-schema和ajv json-schema规范是什么?
Json-schema描述 JSON 数据结构的标准化契约 为什么使用他是为了减少前后端交互时风险例如
- 接口契约模糊,前后端扯皮
- 数据污染(非法值入库)
- 安全漏洞(如注入、越权字段)
- 测试覆盖不全
AJV是 JavaScript 生态中最流行、最快的 JSON Schema 验证器。根据 JSON Schema 规则,校验任意 JavaScript 对象(或 JSON)
4.4.4.10 最终将这些文件都进行引入到入口
这样就实现了自动化模块加载和多环境支持的功能
ts
/**
* @description 负责加载工具函数、路由、中间件、控制器、服务、配置等内容
* @param app
*/
async function mountLoader(app: ElpisApp) {
// 加载config
await configLoader(app);
// 加载工具函数
app.tools = tool(app);
// 加载中间件
await middlewareLoader(app);
// 加载 routerSchema
await routerSchemaLoader(app);
// 加载 控制器
await controllerLoader(app);
// 加载service
await serviceLoader(app);
// 加载扩展
await extendLoader(app);
// 注册全局中间件
await globalMiddleware(app);
// 加载路由
await routeLoader(app);
}
4.4.5 启动服务
ts
/**
* @description 启动服务
* @param app
*/
function startServer(app: ElpisApp) {
const port = parseInt(process.env.PORT || "9000", 10);
const host = process.env.IP || "0.0.0.0";
// 验证端口是否有效
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error("Invalid port number");
}
app.listen(port, host, () => {
console.log(
`\n\n服务开启成功 当前环境${app.ElpisEnv.getEnv()} http://${host}:${port}`,
);
// 如果是 0.0.0.0,说明外部设备可通过局域网 IP 访问
if (host === "0.0.0.0") {
console.log(
`> 其他设备在同一 WiFi 下可访问: http://${getLocalIP()}:${port}`,
);
}
});
}
4.4.6 中间件处理
- 路径 elpis/app/middleware.ts
- 我们已经实现了自动模块加载,现在引入必要的中间件
4.4.6.1 引入koa-static
js
app.use(koaStatic(path.resolve(app.businessPath, `.${sep}public`)));
koa-static 是一个 Koa 中间件,它能将指定目录下的静态文件通过 HTTP 直接暴露给客户端,无需手动编写路由。
🔍 为什么需要它?
在 Web 开发中,前端构建后的资源(如 index.html, app.js, style.css)需要被浏览器访问。
如果没有 koa-static,你得这样写:
js
// ❌ 手动读取文件(繁琐且不安全!)
app.use(async (ctx, next) => {
if (ctx.path === '/index.html') {
ctx.type = 'html';
ctx.body = fs.createReadStream('./public/index.html');
} else if (ctx.path === '/app.js') {
ctx.type = 'js';
ctx.body = fs.createReadStream('./public/app.js');
} else {
await next();
}
});
// 而用 `koa-static` 后:
// ✅ 一行代码搞定
app.use(require('koa-static')('./public'));
- 文件路径映射
- MIME 类型设置(如
.css→text/css) - 缓存头(
Cache-Control) - 404 错误
- 目录浏览(可选)
4.4.6.2 引入koa-nunjucks-2
因为我们需要支持ssr,服务端渲染这里引入nunjucks中间件,这个中间件会在ctx这个上下文中添加render方法,当需要返回模板页的时候可以直接调用ctx.render('页面名称')
js
/**
* @description 渲染模版引擎中间件
* @param app
*/
function TplRenderMiddleware(app: ElpisApp) {
// 模板渲染引擎配置
const nunjucksOptions = {
// 指定模板文件的默认扩展名(extension)
// 当调用 ctx.render('home') 时,会自动查找 'home.html' 文件
// 注意:这里使用了 '.html' 作为后缀,但更推荐使用 '.njk'(Nunjucks 社区标准)
ext: "html",
// 指定模板文件所在的根目录
// path.resolve(app.basePath, `. $ {sep}app $ {sep}public`) 会拼接出类似:
// /your-project/app/public (Unix/Linux/macOS)
// C:\your-project\app\public (Windows)
// ⚠️ 通常模板应放在 `views`
path: path.resolve(app.businessPath, `.${sep}views`),
// 传递给 Nunjucks 引擎的底层配置
nunjucksConfig: {
// 开发环境下设为 true,禁用模板缓存(修改模板后无需重启服务)
// 生产环境建议设为 false 以提升性能
noCache: true,
// 自动移除模板标签后的第一个换行符(使生成的 HTML 更紧凑)
trimBlocks: true,
// (可选)建议补充以下安全配置:
// autoescape: true, // ✅ 默认应开启,防止 XSS 攻击(自动转义 HTML)
},
};
app.use(koaNunjucks(nunjucksOptions));
}
4.4.6.3 引入koa-bodyparser
koa-bodyparser 是 Koa.js 生态中最常用的请求体(request body)解析中间件 ,它的核心作用是:将 HTTP 请求中携带的原始数据(如 JSON、表单、文本等)自动解析成 JavaScript 对象,并挂载到 ctx.request.body 上,供后续路由或中间件使用。 这里补充一下一般前后端交互如何拿参
| 参数类型 | 来源示例 | 获取方式 | 所需中间件 / 依赖 | 前提条件 | 注意事项 |
|---|---|---|---|---|---|
| 路径参数 params | /users/123 (URL 路径中的动态段) | ctx.params.id | ✅ 必须使用 @koa/router | - 路由路径中定义了占位符(如 :id) - 使用 router.get('/path/:id', ...) 注册路由 | - 若未使用 koa-router,ctx.params 永远为 {} - 参数值始终为字符串 |
| 查询参数 query | ?q=node&page=2 (URL 问号后) | ctx.query.q 或 ctx.request.query | ❌ 无需任何中间件 (Koa 原生支持) | - 请求 URL 包含查询字符串 | - 所有值均为 字符串类型(如 '2') - 如需数字,需手动转换:Number(ctx.query.page) |
| 请求体 body | POST JSON: { "name": "Alice" } 或表单: name=Alice | ctx.request.body.name | ✅ 必须注册 koa-bodyparser js app.use(bodyParser()); | - 客户端设置正确 Content-Type: • application/json • application/x-www-form-urlencoded • text/plain | - 不支持文件上传(multipart/form-data) - 未注册中间件时,ctx.request.body 为 undefined - 默认限制 body 大小(可配置) |
| 请求头 headers | Authorization: Bearer xxx Content-Type: application/json | ctx.headers.authorization 或推荐: ctx.get('authorization') | ❌ 无需任何中间件 (Koa 原生支持) | - 客户端在请求头中携带字段 | - Header key 自动转为小写 - 推荐用 ctx.get('key') 避免大小写问题 - 敏感信息(如 token)通常放 header |
4.4.6.4 引入自定义中间件errorHandle
因为我们使用的是洋葱模型,将这个错误兜底放在第二层 运行时的异常错误处理,兜底所有异常
js
/**
* 运行时的异常错误处理,兜底所有异常
* @param {ElpisApp} app koa 实例
* @returns
*/
const ErrorHandleMiddleWare = (app: ElpisApp) => {
return async (ctx: Ctx, next: () => Promise<void>) => {
try {
await next();
} catch (err: any) {
// 异常处理
const { status, message, detail } = err;
app.logger.info(JSON.stringify(err));
app.logger.error("[-- exception --]", err);
app.logger.error("[-- exception --]", status, message, detail);
if (message && message.indexOf("template not found") > -1) {
// 页面不存在,进行临时重定向
ctx.status = 302;
ctx.redirect(`${app.options?.errorPathName}`);
return;
}
const resBody: BaseResponse = {
code: RESPONSE_CODE.LOGICAL_ERROR,
message: "服务器开小差了~请稍后再试",
};
// 返回给前端
fail(ctx, resBody);
}
};
};
4.4.6.5 引入自定义中间件签名验证中间件
对请求的api进行验签,防止请求被篡改(Integrity) 防止重放攻击(Replay Attack 这里采用的是对称签名,利用前后端维护一个key然后通过md5进行验签,前端同时加上一个时间戳,如果时间超过了固定的时间也不生效,这个是为了防止爬虫使用我们接口,当然由于前后端都有key,这个也是相当于防君子不防小人了,相当于增加逆向的成本而已。
js
/**
* API 签名验证中间件
* @param app koa 实例
* @returns
*/
const ApiSignVerifyMiddleWare = (app: ElpisApp) => {
return async (ctx: Ctx, next: () => Promise<void>) => {
const { path, method } = ctx;
// 是否是请求接口的路径
const isApiRequest = app.tools.getRequestPathType(ctx, app) === "api";
// 只对 API 请求进行签名验证
if (!isApiRequest) {
await next();
return;
}
// 进行签名验证
const { headers } = ctx.request;
const { s_sign: sSgin, s_t: st } = headers;
// 计算签名
const signature = md5(`${signKey}_${st}`);
// 有效时间
const validTime = 1000 * 60 * 3;
// 打上日志
app.logger.info(`[${method} ${path}] signature: ${signature}`);
if (
!sSgin ||
!st ||
String(sSgin).toLowerCase() !== signature ||
Date.now() - Number(st) > validTime
) {
const responseData = {
code: RESPONSE_CODE.SIGNATURE_INVALID,
message: "签名验证失败",
};
fail(ctx, responseData);
return;
}
await next();
};
};
4.4.6.6 引入自定义中间件参数校验
利用json-schema和ajv 来校验前端传过来的参数是否合法,由于有动态路由比如api/list/:id 这种路径的话 需要使用path-to-regexp来正则匹配
js
/**
* API 参数验证中间件
* @param app koa 实例
* @returns
*/
const ApiParamsVerifyMiddleware = (app: ElpisApp) => {
return async (ctx: Ctx, next: () => Promise<void>) => {
const { path, method } = ctx;
// 是否是请求接口的路径
const isApiRequest = app.tools.getRequestPathType(ctx, app) === "api";
// 只对 API 请求进行参数验证
if (!isApiRequest) {
await next();
return;
}
// 日志记录请求参数
apiParamsLogger(app, ctx);
// 得到当前请求的接口对应的参数验证规则 使用这个函数是为了匹配动态路由/:id 这种接口
const matchingSchema = app.tools.findMatchingSchema(
path,
method.toLowerCase(),
app.routerSchema,
);
// 如果没有定义验证规则,直接放行
if (!matchingSchema) {
return await next();
}
// 是否校验通过
const isValidate = isValidateFn(
matchingSchema.schema,
ctx,
matchingSchema.params,
);
// 没校验通过 不扭转到下个中间件
if (!isValidate) {
return;
}
// 所有验证通过,继续执行后续中间件
await next();
};
};
4.4.7 引入所有中间件
- 路径elpis/app/middleware.ts
ts
const globMiddleware = (app: ElpisApp) => {
// 配置静态根目录
app.use(koaStatic(path.resolve(app.businessPath, `.${sep}public`)));
// 模版渲染引擎
TplRenderMiddleware(app);
// 引入 ctx.body 解析中间件
BodyParserMiddleware(app);
// 引入全局错误处理中间件
app.use(app.middlewares?.errorHandle);
// 引入 API 签名验证中间件
app.use(app.middlewares?.apiSignVerify);
// 引入 API 参数验证中间件
app.use(app.middlewares?.apiParamsVerify);
};
4.5 总结
Elpis 是一个基于 Node.js + Koa + TypeScript 构建的现代化服务器引擎,旨在提供高可维护性、强类型安全与开箱即用的企业级能力。框架采用 洋葱模型中间件架构 与 MVC 分层设计,兼顾开发效率与系统扩展性。
| 类别 | 能力 | 说明 |
|---|---|---|
| 架构设计 | 洋葱模型 + MVC | 基于 Koa 的中间件执行流,逻辑分层清晰(Controller / Service / Model) |
| 模块管理 | 自动模块加载 | 启动时自动扫描并注册 controllers、services、middlewares 等目录,无需手动 import |
| 环境管理 | 多环境支持 | 内置 local / test / prod 环境配置隔离,通过 NODE_ENV 自动切换 |
| 语言与类型 | 全链路 TypeScript | 提供完整的类型定义与泛型支持,提升代码健壮性与开发体验 |
| 渲染能力 | SSR 支持 | 集成 Nunjucks 模板引擎,支持服务端渲染(适用于传统 Web 应用或 SEO 场景) |
| 中间件体系 | 可插拔中间件 | 支持自定义中间件注册与全局/路由级应用,便于横切关注点(如日志、鉴权)统一处理 |
| 安全机制 | API 签名验证 | 内置请求签名校验(支持 md5 + 时间戳 ),防止参数篡改与重放攻击 |
| 数据校验 | JSON Schema 参数验证 | 基于 AJV 实现对 params、query、body、headers 的全维度校验,保障输入合法性 |
| 响应规范 | 统一响应格式 | 封装标准化的成功/失败响应结构,确保 API 输出一致性,便于前端统一处理 |