从零构建一个 Koa 企业级框架:Elpis 架构设计实战
本文基于 Elpis 项目实战,深入剖析一个企业级 Node.js 框架的架构设计思路与实现细节。
前言
在 Node.js 生态中,Koa 以其轻量、灵活的特性深受开发者喜爱。但 Koa 本身只是一个内核,要支撑企业级应用,我们需要在其之上构建一套完整的框架体系。
本文将从架构设计的角度,带你拆解一个基于 Koa 的企业级框架——Elpis,涵盖:
- Loader 自动加载机制
- 中间件洋葱模型与注册顺序
- MVC 架构与依赖组织
- SSR 服务端渲染
- API 签名验证
- 参数校验与日志系统
- 框架核心与业务代码的解耦
一、整体架构概览
1.1 目录结构
elpis/
├── elpis-core/ # 框架核心(可独立发布)
│ ├── index.js # 框架入口
│ ├── env.js # 环境变量处理
│ └── loader/ # 加载器集合
│ ├── config.js # 配置加载器
│ ├── extend.js # 扩展加载器
│ ├── middleware.js # 中间件加载器
│ ├── controller.js # 控制器加载器
│ ├── service.js # 服务加载器
│ ├── router.js # 路由加载器
│ └── router-schema.js # 路由Schema加载器
├── app/ # 业务代码
│ ├── controller/ # 控制器层
│ ├── service/ # 服务层
│ ├── middleware/ # 中间件
│ ├── extend/ # 扩展
│ ├── router/ # 路由定义
│ ├── router-schema/ # 路由参数Schema
│ └── public/ # 静态资源 & 模板
├── config/ # 配置文件
│ ├── config.default.js
│ └── config.prod.js
└── docs/ # 文档
1.2 核心设计原则
- 约定优于配置:通过目录约定自动加载业务代码
- 核心与业务分离:
elpis-core不反向引用业务代码 - 依赖单向流动:config → extend → schema → service → controller → router
- 可扩展性:通过 extend 机制扩展框架能力
二、Loader 自动加载机制
Loader 是框架的"骨架",它负责按照约定自动扫描并加载业务代码。
2.1 为什么需要 Loader?
在原生 Koa 中,我们需要手动引入每一个中间件、控制器、路由:
// 没有 Loader 时的噩梦
const userController = require('./controller/user');
const projectController = require('./controller/project');
const authMiddleware = require('./middleware/auth');
const loggerMiddleware = require('./middleware/logger');
// ... 每加一个文件都要手动引入
有了 Loader 后,只需在对应目录下创建文件,框架自动识别并加载:
app/controller/
├── user.js ← 自动加载
├── project.js ← 自动加载
└── admin/
└── role.js ← 自动加载(支持嵌套目录)
2.2 Loader 通用模式
所有 Loader 都遵循相同的模式:
// 伪代码
module.exports = (app) => {
// 1. 扫描目录获取所有 .js 文件
const fileList = glob.sync(path.join(targetPath, '**', '*.js'));
fileList.forEach(file => {
// 2. 将文件路径转换为命名空间(支持嵌套目录)
const name = path.relative(targetPath, file)
.replace('.js', '')
.replace(/[_-][a-z]/ig, m => m[1].toUpperCase());
// 3. 加载模块
const module = require(file);
// 4. 实例化(如果是类)并挂载到 app 上
app[namespace][name] = typeof module === 'function'
? new module(app)
: module;
});
};
2.3 加载顺序优化
Loader 的加载顺序不是随意的,必须遵循依赖单向流动原则:
| 顺序 | Loader | 依赖说明 |
|---|---|---|
| 1 | config | 最先,所有模块都可能依赖配置 |
| 2 | extend | 无依赖,扩展 Koa 实例 |
| 3 | routerSchema | 无依赖,纯数据定义 |
| 4 | service | 依赖 config,被 controller 依赖 |
| 5 | controller | 依赖 service,被 router 依赖 |
| 6 | middleware | 可能依赖 schema/service |
| 7 | router | 最后,依赖 controller/middleware |
为什么 routerSchema 要放在 middleware 之前?
虽然当前中间件在运行时才访问 app.routerSchemaList,运行时不会报错,但将被依赖的模块前置是更合理的架构设计,也为未来中间件在加载阶段就引用 schema 留出了余量。
三、中间件体系深度解析
3.1 洋葱模型回顾
Koa 的中间件采用洋葱模型:
请求 → 中间件1 → 中间件2 → 中间件3 → 路由处理
↓ ↓
响应 ← 中间件1 ← 中间件2 ← 中间件3 ← 路由处理
每个中间件都有两次执行机会:
- next() 之前:请求进入时执行(从上到下)
- next() 之后:响应返回时执行(从下到上)
3.2 中间件注册顺序
中间件的注册顺序至关重要,顺序错了会导致功能异常:
const middlewareOrder = [
'static', // 第1层:静态资源优先,命中直接返回
'errorHandler', // 第2层:捕获所有后续中间件的错误
'accessLogger', // 第3层:记录所有请求的访问日志
'apiSignVerify', // 第4层:API签名验证(业务安全)
'validator', // 第5层:参数校验(业务校验)
'requestLogger' // 第6层:开发环境详细日志
];
为什么 errorHandler 要放在前面?
因为洋葱模型的特性,errorHandler 放在越外层,能捕获的错误范围越大。如果放在最后,它前面的中间件抛出的错误就捕获不到了。
3.3 经典中间件实现
错误处理中间件
module.exports = async (ctx, next) => {
try {
await next(); // 进入内层
// 响应阶段:处理 404
if (ctx.status === 404) {
if (!ctx.url.startsWith('/api/') && !ctx.url.startsWith('/static/')) {
const err = new Error('template not found');
err.status = 404;
throw err; // 抛给 catch 统一处理
}
ctx.body = { code: 404, message: 'Not Found' };
}
// 重定向状态码不设置 body,避免覆盖 Location 头
if ([301, 302, 307, 308].includes(ctx.status)) {
return;
}
} catch (error) {
// 统一错误处理
ctx.app.logger.logError(error, ctx);
// template not found → 302 跳转首页
if (error.message?.includes('template not found')) {
ctx.status = 302;
ctx.redirect(ctx.app.options.homepage || '/');
return;
}
ctx.status = error.status || 500;
ctx.body = process.env.NODE_ENV === 'production'
? { code: ctx.status, message: 'Internal Server Error' }
: { code: ctx.status, message: error.message, stack: error.stack };
}
};
API 签名验证中间件
签名验证是接口安全的重要防线,原理如下:
客户端 服务端
| |
| 1. 构造参数 params |
| 2. 加 timestamp + nonce |
| 3. 按 key 字典序排序 |
| 4. 拼接字符串 + secret |
| 5. MD5 加密 → signature |
| |
| 6. 发送请求(带 signature/timestamp/nonce)
| |
| | 7. 接收请求,提取参数
| | 8. 验证 timestamp 是否过期
| | 9. 验证 nonce 是否重复
| | 10. 同样算法计算签名
| | 11. 比对签名是否一致
| |
| ← 12. 返回 200/401 |
核心实现:
function verifySign(params, secret, receivedSign) {
// 1. 过滤签名字段
const filtered = Object.fromEntries(
Object.entries(params).filter(([k]) => k !== 'signature' && k !== 'sign')
);
// 2. 按 key 排序
const sortedKeys = Object.keys(filtered).sort();
// 3. 拼接字符串
const signStr = sortedKeys
.map(key => `${key}=${typeof filtered[key] === 'object'
? JSON.stringify(filtered[key])
: filtered[key]}`)
.join('&') + secret;
// 4. MD5 加密并比对
return crypto.createHash('md5').update(signStr, 'utf8').digest('hex') === receivedSign;
}
四、MVC 架构与依赖组织
4.1 三层架构
Router(路由层)
↓ 调用
Controller(控制层)→ 参数校验、请求响应处理
↓ 调用
Service(服务层)→ 业务逻辑、数据处理
↓ 调用
Model(模型层)→ 数据库操作
4.2 BaseController 基类
基类封装通用能力,子类通过继承获得:
class BaseController {
constructor(app) {
this.app = app; // 持有 app 引用,访问 service/config 等
}
// 页面渲染(SSR)
async render(ctx, template, data = {}) { ... }
// 统一响应格式
success(ctx, data, message) {
ctx.body = { code: 200, message, data };
}
error(ctx, code, message, data) { ... }
unauthorized(ctx, message) { ... }
notFound(ctx, message) { ... }
}
4.3 Controller 与 Service 的关系
- Controller:薄,只做 HTTP 层的事(参数解析、响应格式化、调用 service)
- Service:厚,封装业务逻辑,可被多个 controller 复用
// 好的实践:Controller 很薄
class ProjectController extends BaseController {
async getList(ctx) {
const { page, pageSize } = ctx.query;
const list = await ctx.app.services.project.getList(page, pageSize);
this.success(ctx, list, '获取成功');
}
}
// 坏味道:Controller 里写业务逻辑
class ProjectController extends BaseController {
async getList(ctx) {
// 直接在 controller 里写 SQL、复杂计算...
const rows = await db.query('SELECT * FROM projects WHERE ...');
// ...
}
}
五、SSR 服务端渲染
5.1 什么是 SSR
SSR(Server-Side Rendering)即服务端渲染,是指在服务器端将模板 + 数据渲染成完整的 HTML,再返回给浏览器。
传统 CSR(客户端渲染):
浏览器 → 请求 HTML(空壳) → 下载 JS → 请求数据 → 渲染页面
SSR(服务端渲染):
浏览器 → 请求 → 服务器渲染 HTML → 返回完整页面 → 浏览器直接展示
5.2 ctx.render 的来源
ctx.render 并非 Koa 内置,而是由 koa-nunjucks-2 中间件注入的:
// app/middleware/nunjucks.js
const koaNunjucks = require('koa-nunjucks-2');
module.exports = koaNunjucks({
ext: 'tpl', // 模板扩展名
path: path.join(__dirname, '../public/output'), // 模板目录
nunjucksConfig: {
autoescape: true, // 自动转义(防 XSS)
watch: true, // 开发模式热更新
noCache: process.env.NODE_ENV !== 'production'
}
});
5.3 两种渲染方式对比
项目中存在两种渲染方式,注意区分:
| 方式 | 实现 | 能力 | 适用场景 |
|---|---|---|---|
ctx.render() | koa-nunjucks-2 | 完整模板引擎(循环、条件、继承) | 复杂页面 |
this.render() | BaseController 自定义 | 仅变量替换 {{ key }} | 简单页面/原型 |
建议:统一使用 ctx.render,获得完整的模板引擎能力和安全特性。
5.4 SSR 的优劣势
优势:
- SEO 友好,搜索引擎可直接抓取内容
- 首屏加载快,无需等待 JS 下载执行
- 兼容低版本浏览器和禁用 JS 的场景
劣势:
- 服务器压力大,每次请求都要渲染
- 前后端耦合度高,开发体验略差
六、参数校验(AJV + Schema)
6.1 为什么需要参数校验
- 安全:防止恶意输入导致的注入攻击
- 稳定:提前拦截非法参数,避免业务逻辑出错
- 体验:给前端清晰的错误提示
6.2 Schema 驱动的参数校验
通过 router-schema 定义参数规则,validator 中间件自动校验:
// app/router-schema/project.js
module.exports = {
'/api/projects': {
get: {
query: {
type: 'object',
properties: {
page: { type: 'integer', minimum: 1, default: 1 },
pageSize: { type: 'integer', minimum: 1, maximum: 100, default: 10 }
}
}
}
}
};
校验中间件自动根据路由路径查找 Schema 并执行校验:
// 伪代码
module.exports = async (ctx, next) => {
const schema = findSchema(ctx.path, ctx.method, app.routerSchemaList);
if (!schema) {
await next();
return;
}
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
const valid = validate(ctx.request.body);
if (!valid) {
ctx.status = 400;
ctx.body = { code: 400, message: '参数错误', errors: validate.errors };
return;
}
await next();
};
七、日志系统
7.1 日志分层
一个完善的日志系统应该分层:
| 层级 | 用途 | 示例 |
|---|---|---|
| access | 访问日志 | 记录所有 HTTP 请求的 method、url、status、耗时 |
| error | 错误日志 | 记录异常堆栈,用于排查问题 |
| business | 业务日志 | 记录关键业务操作,用于审计 |
| debug | 调试日志 | 开发环境详细日志,排查问题用 |
7.2 开发/生产环境差异
// app/extend/logger.js
if (process.env.NODE_ENV === 'production') {
// 生产环境:log4js,写入文件,按日期切割
app.logger = {
logAccess: (ctx, duration) => { /* 写入 access.log */ },
logError: (error, ctx) => { /* 写入 error.log */ },
// ...
};
} else {
// 开发环境:console,直接输出到终端
app.logger = {
logAccess: (ctx, duration) => console.log('[Access]', ctx.method, ctx.url, duration + 'ms'),
logError: (error) => console.error('[Error]', error),
// ...
};
}
八、框架核心与业务解耦
8.1 为什么要解耦
早期架构可能是这样的:
elpis-core/
└── index.js → 直接 require('../app/controller/base') ← 反向引用业务代码
问题:
- 核心框架依赖业务代码,无法独立发布
- 换个项目就要改 core 代码
- 职责不清,维护困难
8.2 解耦方案:Loader 模式
通过 Loader 自动扫描业务目录,core 不需要知道业务代码的存在:
elpis-core(框架) app(业务)
│ │
│ 1. 约定目录结构 │
│ 2. 提供 Loader │
│ 3. 自动扫描加载 │
├──────────────────────────►
│ │ 业务代码放在约定目录
│ │
◄──────────────────────────┤
│ 4. 挂载到 app 对象 │
│ │
核心原则:core 只提供机制,不关心业务内容。
九、HTTP 状态码速查
重定向状态码
| 状态码 | 名称 | 含义 | 缓存 | 请求方法 | 典型场景 |
|---|---|---|---|---|---|
| 301 | Moved Permanently | 永久重定向 | ✓ 缓存 | 可能改变 | 网站迁移、域名更换 |
| 302 | Found | 临时重定向 | ✗ 不缓存 | 可能改变 | 临时维护、页面未开发跳转 |
| 303 | See Other | 查看其他 | ✗ 不缓存 | 强制 GET | POST 后重定向到结果页 |
| 307 | Temporary Redirect | 临时重定向 | ✗ 不缓存 | 保持不变 | 临时跳转,保持方法 |
| 308 | Permanent Redirect | 永久重定向 | ✓ 缓存 | 保持不变 | 永久迁移,保持方法 |
十、总结
10.1 核心知识点回顾
- Loader 机制:约定优于配置,自动扫描加载业务代码
- 洋葱模型:中间件执行顺序至关重要,外层捕获内层错误
- 依赖单向流动:config → extend → schema → service → controller → router
- SSR:服务端渲染模板 + 数据 = 完整 HTML
- API 安全:MD5 签名 + 时间戳 + nonce 防篡改防重放
- 参数校验:Schema 驱动,自动校验,统一错误格式
- 框架解耦:core 提供机制,业务遵循约定,核心不反向引用业务
10.2 后续优化方向
- 引入数据库 ORM(如 Sequelize / Prisma)
- 实现 Redis 缓存与 Session
- 加入 JWT 认证中间件
- 实现单元测试与集成测试
- 性能监控与告警
- Docker 容器化部署
关于作者:Elpis 框架学习项目
本文基于实战经验整理,如有错误欢迎指正。