背景:从“能用”到“优雅”的架构跃迁
你是否曾有过这样的经历?当你用 Koa 或 Express 快速搭建起一个 Node.js 后端,初期一切顺利。但随着业务膨胀——鉴权、日志、监控、多环境配置、团队协作规范纷至沓来,你的 app.js 逐渐变成一个上千行的“意大利面条式”代码,维护成本指数级上升。
这正揭示了从“框架”到“企业级框架” 的核心命题:如何在高复杂度的业务场景下,依然保持代码的清晰、可维护与高性能。Egg.js 正是阿里为回答这一问题而生的“约束性艺术”。它并非简单的 Koa 封装,而是基于约定优于配置的理念,为你提供了一套开箱即用的最佳实践架构。
本文将带你进行一次 “深潜式”的源码级旅程,层层拆解一个 HTTP 请求在 Egg.js 内部的神秘冒险。你将彻底搞懂:
- 洋葱模型 如何让中间件拥有“预知未来”的能力?
- 控制器、服务层 的边界究竟在哪?为何你的业务逻辑总在“流浪”?
- Egg.js 相较于原生 Koa,多出的那些“重量”,究竟换来了什么至关重要的东西?
一、 一个请求的完整生命周期
当一个 HTTP 请求叩响 Egg.js 应用的大门,它将经历一场精密编排的管道旅行。下图清晰勾勒了其完整生命周期:
二、各层职责与交互流程
1. 中间件 (Middleware)
可以做的事情:
-
请求预处理:日志、鉴权、CORS、限流
-
错误捕获和统一处理
-
响应后处理:添加头信息、性能统计
执行特点:
-
洋葱模型:请求进入时顺序执行,响应返回时逆序执行
-
全局中间件在
config/config.default.js配置 -
路由级中间件在
router.js中配置
代码示例:
// app/middleware/auth.js
module.exports = () => {
return async function auth(ctx, next) {
const token = ctx.get('authorization');
console.log('1. 进入鉴权中间件');
// 鉴权逻辑
const user = await ctx.service.user.verifyToken(token);
if (!user) {
ctx.status = 401;
ctx.body = { error: 'Unauthorized' };
return; // 中断后续执行
}
ctx.state.user = user; // 传递数据到下游
await next(); // 进入下一层(控制器)
console.log('4. 响应离开鉴权中间件');
};
};
// app/router.js
module.exports = app => {
const { router, controller, middleware } = app;
const auth = middleware.auth();
router.get('/api/user/:id', auth, controller.user.getInfo);
};
2. 路由 (Router) - 请求分发
职责:
- 将 URL 映射到对应的控制器方法
- 支持 RESTful 风格路由
- 支持参数校验和中间件挂载
使用场景:
// app/router.js
module.exports = app => {
const { router, controller, middleware } = app;
const { user, post } = controller;
// 基础路由
router.get('/', controller.home.index);
// 带参数路由
router.get('/user/:id', user.getInfo); // :id 会自动注入到 ctx.params
// RESTful 资源路由
router.resources('users', '/users', user);
// 自动映射: GET /users, GET /users/:id, POST /users, PUT /users/:id, DELETE /users/:id
// 路由级中间件
const auth = middleware.auth();
router.get('/admin', auth, controller.admin.dashboard);
// 路由前缀
const apiRouter = router.namespace('/api/v1');
apiRouter.get('/posts', post.list);
};
执行流程:
- 请求到达,Egg 按注册顺序匹配路由
- 匹配成功后,执行该路由绑定的中间件链
- 最后执行控制器方法
3. 控制器 (Controller) - 常用于一些非空检验
职责:
- 接收请求数据:
ctx.query,ctx.request.body,ctx.params - 参数校验:合法性检查、格式验证
- 调用服务层:将业务逻辑委托给 Service
- 返回响应:设置
ctx.body,ctx.status
使用场景:
// app/controller/user.js
const { Controller } = require('egg');
class UserController extends Controller {
async getInfo() {
const { ctx } = this;
console.log('2. 进入控制器');
// 1. 参数校验
const { id } = ctx.params;
if (!id || isNaN(id)) {
ctx.status = 422;
ctx.body = { error: 'Invalid user ID' };
return;
}
// 2. 调用服务层
const user = await ctx.service.user.findById(id);
if (!user) {
ctx.status = 404;
ctx.body = { error: 'User not found' };
return;
}
// 3. 返回响应
ctx.status = 200;
ctx.body = {
code: 0,
data: user,
};
console.log('3. 控制器处理完成');
}
async create() {
const { ctx } = this;
// 使用内置参数校验规则
ctx.validate({
name: { type: 'string', required: true },
email: { type: 'email', required: true },
});
const result = await ctx.service.user.create(ctx.request.body);
ctx.status = 201;
ctx.body = result;
}
}
module.exports = UserController;
最佳实践:
- 控制器只处理 HTTP 层逻辑,不直接操作数据库
- 保持控制器轻量,业务逻辑全部下放 Service
- 统一响应格式,可以使用
ctx.helper.success()封装
4. 服务层 (Service) - "业务大脑",业务逻辑都写在这!
职责:
-
核心业务逻辑:数据处理、业务规则、算法
-
跨领域调用:调用其他 Service、外部 API
-
事务管理:保证数据一致性
-
可复用性:供多个控制器调用
使用场景:
// app/service/user.js
const { Service } = require('egg');
class UserService extends Service {
async findById(id) {
console.log('3. 进入服务层');
// 1. 缓存查询
const cacheKey = `user:${id}`;
const cached = await this.app.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. 数据库查询
const user = await this.ctx.model.User.findOne({
where: { id },
attributes: ['id', 'name', 'email', 'avatar'],
include: [{
model: this.ctx.model.Post,
as: 'posts',
limit: 10,
}],
});
if (!user) return null;
// 3. 数据加工
const formattedUser = {
...user.toJSON(),
avatar: this.ctx.helper.cdnUrl(user.avatar),
isVip: user.level > 2,
};
// 4. 写入缓存
await this.app.redis.setex(cacheKey, 3600, JSON.stringify(formattedUser));
console.log('4. 服务层处理完成');
return formattedUser;
}
async create(userData) {
const { ctx, app } = this;
// 事务处理
const transaction = await app.model.transaction();
try {
// 1. 创建用户
const user = await ctx.model.User.create(userData, { transaction });
// 2. 创建关联数据
await ctx.model.Profile.create({ userId: user.id }, { transaction });
// 3. 发送消息队列
await app.rabbitmq.send('user.created', { userId: user.id });
await transaction.commit();
return user;
} catch (err) {
await transaction.rollback();
throw err;
}
}
}
module.exports = UserService;
最佳实践:
- Service 无状态,不依赖具体请求(除了
ctx) - 复杂业务拆分为多个小 Service
- 一个 Service 专注一个领域(用户、订单、支付)
三、完整数据流示例
请求:GET /api/user/123
// 1. 中间件:日志记录
// app/middleware/logger.js
async function logger(ctx, next) {
const start = Date.now();
await next();
const cost = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${cost}ms`);
}
// 2. 路由:匹配 /api/user/:id
// app/router.js
router.get('/api/user/:id', controller.user.detail);
// 3. 控制器:参数处理
// app/controller/user.js
async detail() {
const { ctx } = this;
const { id } = ctx.params;
// 调用服务层
const data = await ctx.service.user.getDetail(id);
ctx.body = data;
}
// 4. 服务层:业务逻辑
// app/service/user.js
async getDetail(id) {
// 查询数据库
const user = await this.ctx.model.User.findByPk(id);
// 调用其他服务
const stats = await this.ctx.service.userStats.get(id);
return { ...user.toJSON(), stats };
}
// 5. 模型层:数据访问
// app/model/user.js
module.exports = app => {
const { STRING, INTEGER } = app.Sequelize;
const User = app.model.define('user', {
id: { type: INTEGER, primaryKey: true },
name: STRING,
email: STRING,
});
return User;
};
四、各层使用场景对比
| 层级 | 适合做什么 | 不适合做什么 | 关键原则 |
|---|---|---|---|
| 中间件 | 日志、鉴权、CORS、限流、错误捕获 | 业务逻辑、数据库操作 | 处理横切关注点 |
| 路由 | URL 映射、参数提取、中间件挂载 | 复杂逻辑、数据加工 | 保持简洁 |
| 控制器 | 参数校验、调用 Service、返回响应 | 直接操作数据库、复杂业务 | HTTP 层网关 |
| 服务层 | 业务规则、数据处理、跨服务调用 | HTTP 状态码、请求响应 | 业务复用单元 |
聊完Egg.js,聊一下Koa的洋葱模型:
五、洋葱模型
我来用最直观的方式 给你讲透洋葱模型,这是理解 Egg.js 中间件的核心。
5.1、可视化洋葱模型
关键理解:await next() 就是切到下一层 ,等所有内层执行完再返回到当前层继续执行 。
5.2、代码演示:6行代码看透洋葱模型
// app/middleware/test.js
module.exports = () => {
return async function onionTest(ctx, next) {
console.log('【外层-请求前】1. 进入第一个中间件');
await next(); // 等待内层所有中间件执行完
console.log('【外层-响应后】6. 回到第一个中间件,准备返回');
};
};
// app/middleware/test2.js
module.exports = () => {
return async function onionTest2(ctx, next) {
console.log('【中层-请求前】2. 进入第二个中间件');
await next(); // 等待更内层
console.log('【中层-响应后】5. 回到第二个中间件');
};
};
// app/middleware/test3.js
module.exports = () => {
return async function onionTest3(ctx, next) {
console.log('【内层-请求前】3. 进入第三个中间件');
await next(); // 等待控制器
console.log('【内层-响应后】4. 回到第三个中间件');
};
};
// app/controller/home.js
async index() {
console.log('【控制器】4. 执行业务逻辑,设置响应');
this.ctx.body = 'Hello World';
}
访问页面后控制台输出:
【外层-请求前】1. 进入第一个中间件
【中层-请求前】2. 进入第二个中间件
【内层-请求前】3. 进入第三个中间件
【控制器】4. 执行业务逻辑,设置响应
【内层-响应后】4. 回到第三个中间件
【中层-响应后】5. 回到第二个中间件
【外层-响应后】6. 回到第一个中间件,准备返回
5.3、为什么叫"洋葱"?
因为中间件的执行轨迹 像切开的洋葱圈:
-
从外向内:逐层进入
-
到达核心:执行业务逻辑
-
从内向外:逐层退出
每个中间件都有机会在请求进入和响应离开时做处理,就像洋葱的每一层都有两面。
5.5、核心价值和应用场景
1. 日志记录(统计耗时)
// app/middleware/logger.js
module.exports = () => {
return async function logger(ctx, next) {
const start = Date.now();
await next(); // 等待整个请求处理完
const cost = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${cost}ms`);
};
};
2. 错误捕获(全局异常处理)
// app/middleware/error_handler.js
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next(); // 等待内层执行,捕获任何错误
} catch (err) {
console.error('捕获到错误:', err);
// 统一错误响应
ctx.status = err.status || 500;
ctx.body = {
code: 'ERROR',
message: err.message,
};
}
};
};
3. 权限校验(提前中断)
// app/middleware/auth.js
module.exports = () => {
return async function auth(ctx, next) {
const token = ctx.get('authorization');
if (!token) {
ctx.status = 401;
ctx.body = { error: '未授权' };
return; // ❌ 不调用 next(),请求在此终止,不会进入内层
}
// 校验通过,继续
await next();
};
};
4. 响应加工(统一格式)
// app/middleware/response_formatter.js
module.exports = () => {
return async function formatter(ctx, next) {
await next(); // 等待控制器设置响应
// 如果响应是对象,包装统一格式
if (ctx.body && typeof ctx.body === 'object') {
ctx.body = {
code: 0,
data: ctx.body,
timestamp: Date.now(),
};
}
};
};
5.4、对比传统线性模型
传统模型(Express 风格)
// 问题:无法捕获后续中间件的响应
app.use((req, res, next) => {
console.log('日志开始');
next(); // 调用后就失控了,不知道什么时候响应结束
console.log('日志结束'); // ⚠️ 可能提前执行,无法统计耗时
});
洋葱模型(Koa/Egg 风格)
app.use(async (ctx, next) => {
console.log('日志开始');
await next(); // ✅ 等待内层完全执行完毕
console.log('日志结束'); // 这里能准确捕获整个处理时间
});
核心区别:await next() 让外层同步等待内层完成,而不是" fire and forget "。
5.5、调试技巧:打印洋葱轨迹
// app/middleware/debug.js
module.exports = () => {
return async function debugOnion(ctx, next) {
const layers = [];
// 包装 next,记录层级
const wrapNext = async (depth = 0) => {
const prefix = ' '.repeat(depth) + '→';
console.log(`${prefix} 进入第 ${depth + 1} 层`);
if (depth < 3) { // 模拟多层
await wrapNext(depth + 1);
} else {
console.log(' '.repeat(depth) + ' 执行核心业务');
ctx.body = 'OK';
}
console.log(`${prefix} 离开第 ${depth + 1} 层`);
};
await wrapNext();
};
};
// 输出效果:
→ 进入第 1 层
→ 进入第 2 层
→ 进入第 3 层
→ 进入第 4 层
执行核心业务
→ 离开第 4 层
→ 离开第 3 层
→ 离开第 2 层
→ 离开第 1 层
5.6、总结
await next()是分水岭:调用前处理请求,调用后处理响应- 不调用
next()= 中断请求:权限校验失败时直接返回 - 洋葱模型 = 同步等待:可以准确控制执行顺序和捕获异常
六、Koa.js VS Egg.js
一句话总结:Koa 是基础框架 (毛坯房),Egg.js 是企业级框架 (精装修 + 物业管理)。
6.1、核心关系
Egg.js 基于 Koa 2.x 深度定制,不是简单的封装,而是:
- 继承:完全兼容 Koa 的中间件机制
- 扩展:添加了插件、约定、多进程等能力
- 增强:提供了企业级最佳实践
Koa
↓ 继承 + 扩展
Egg.js
↓ 二次开发
你的业务应用
6.2、核心区别对比表
| 维度 | Koa | Egg.js |
|---|---|---|
| 定位 | 极简 Web 框架 | 企业级应用框架 |
| 上手成本 | 低,但什么都得自己搭 | 有学习曲线,但开箱即用 |
| 中间件 | 需要自己管理 | 内置 + 插件化 |
| 插件生态 | 零散,无统一规范 | 官方插件 + 规范 |
| 多进程 | 无,需自己实现 Cluster | 内置 Master-Worker-Agent |
| 约定规范 | 无,自由发挥 | 严格的目录 + 命名规范 |
| 配置管理 | 简单,手动加载 | 多环境配置 + 插件配置合并 |
| 开发效率 | 适合个人小项目 | 适合团队协作的企业项目 |
| 维护成本 | 长期维护成本高 | 低,有规范约束 |
6.3、Egg.js 独有的核心能力
1. 多进程模型
Koa 是单进程,Egg.js 内置:
- Master:进程管理、端口监听
- Worker:处理 HTTP 请求(几核 CPU 启动几个)
- Agent:执行后台任务(日志切割、定时任务)
2. 强大的约定
类似umi.js与react的关系
app/
├── app/
│ ├── router.js # 自动加载
│ ├── controller/ # 自动挂载到 app.controller
│ ├── service/ # 自动挂载到 ctx.service
│ ├── middleware/ # 自动注册
│ ├── extend/ # 自动扩展
│ └── model/ # 自动挂载到 ctx.model
└── config/
├── config.{env}.js # 自动合并
└── plugin.js # 自动启用插件
6.4、如何选择?
✅ 选 Koa 当 :
- 个人小项目、快速原型
- API 服务极其简单
- 你想完全掌控所有细节
- 团队技术栈统一,能自己沉淀最佳实践
✅ 选 Egg.js 当 :
- 企业级 Web 应用
- 团队协作,需要规范约束
- 需要插件生态(数据库、Redis、MQ 等)
- 需要多进程、性能监控等企业特性
- 项目会长期维护、多人接手
6.5、性能对比
两者性能几乎无差异,因为 Egg.js 的核心请求处理就是 Koa:
- 基准测试:Egg.js 比裸 Koa 慢约 3-5% (插件加载和路由匹配的代价)
- 换来的是:开发效率提升 50%+ 和维护成本降低 70%+