node+ts+koa全栈框架学习-1

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 作为中间层的请求处理流程,核心逻辑如下:

  1. 最外层(请求层) :所有客户端请求首先进入 elpis-core 中间层,这是整个处理流程的入口;
  2. 第二层(全局错误处理) :请求进入后先经过全局错误处理层,提前拦截通用异常(如请求格式错误、连接异常,内部错误等),避免无效请求进入内层;
  3. 第三层(API 签名验证) :验证通过后,进入 API 签名校验层,确保请求来源合法、未被篡改,保障接口安全性;
  4. 第四层(API 参数验证) :签名验证通过后,校验请求参数的完整性、格式、取值范围等,过滤不合法参数;
  5. 核心层:经过以上四层校验 / 处理后,请求才会抵达核心业务逻辑处理区域,执行页面渲染、服务端数据读取等核心操作;
  6. 返回流程:核心业务处理完成后,结果会按 "参数验证→签名验证→错误处理→请求层" 的反向顺序返回,最终输出响应给客户端(符合洋葱模型 "先进后出" 的核心特征)。
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 类型设置(如 .csstext/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-bodyparserKoa.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)
模块管理 自动模块加载 启动时自动扫描并注册 controllersservicesmiddlewares 等目录,无需手动 import
环境管理 多环境支持 内置 local / test / prod 环境配置隔离,通过 NODE_ENV 自动切换
语言与类型 全链路 TypeScript 提供完整的类型定义与泛型支持,提升代码健壮性与开发体验
渲染能力 SSR 支持 集成 Nunjucks 模板引擎,支持服务端渲染(适用于传统 Web 应用或 SEO 场景)
中间件体系 可插拔中间件 支持自定义中间件注册与全局/路由级应用,便于横切关注点(如日志、鉴权)统一处理
安全机制 API 签名验证 内置请求签名校验(支持 md5 + 时间戳 ),防止参数篡改与重放攻击
数据校验 JSON Schema 参数验证 基于 AJV 实现对 paramsquerybodyheaders 的全维度校验,保障输入合法性
响应规范 统一响应格式 封装标准化的成功/失败响应结构,确保 API 输出一致性,便于前端统一处理
相关推荐
sure2822 小时前
React Native中自定义TabBar
前端·react native·react.js
bluceli2 小时前
CSS自定义属性与主题切换:构建动态UI的终极方案
前端·css
默默学前端2 小时前
HTML 高频面试题 5 道|吃透基础,面试不慌(附详细解析)
前端·面试·职场和发展·html5
豆芽包2 小时前
前端性能优化-图片懒加载技术
前端·面试
bluceli2 小时前
JavaScript WeakMap与WeakSet:内存优化的秘密武器
前端·javascript
陆枫Larry2 小时前
折叠屏“窗口化”下的全屏背景图错位:一次小程序适配的排障思路与最小改动修复
前端
陆枫Larry2 小时前
Art Direction(艺术导向适配)
前端
Lee川2 小时前
从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战
前端·vue.js
与虾牵手2 小时前
Next.js 14 App Router 踩坑实录:5 个让我加班到凌晨的坑 🕳️
前端·javascript·面试