深度起底Egg.js:洋葱模型的终极演绎

30 阅读8分钟

背景:从“能用”到“优雅”的架构跃迁

你是否曾有过这样的经历?当你用 Koa 或 Express 快速搭建起一个 Node.js 后端,初期一切顺利。但随着业务膨胀——鉴权、日志、监控、多环境配置、团队协作规范纷至沓来,你的 app.js 逐渐变成一个上千行的“意大利面条式”代码,维护成本指数级上升。

这正揭示了从“框架”到“企业级框架” 的核心命题:如何在高复杂度的业务场景下,依然保持代码的清晰、可维护与高性能。Egg.js 正是阿里为回答这一问题而生的“约束性艺术”。它并非简单的 Koa 封装,而是基于约定优于配置的理念,为你提供了一套开箱即用的最佳实践架构。

本文将带你进行一次 “深潜式”的源码级旅程,层层拆解一个 HTTP 请求在 Egg.js 内部的神秘冒险。你将彻底搞懂:

  1. 洋葱模型 如何让中间件拥有“预知未来”的能力?
  2. 控制器、服务层 的边界究竟在哪?为何你的业务逻辑总在“流浪”?
  3. Egg.js 相较于原生 Koa,多出的那些“重量”,究竟换来了什么至关重要的东西?

一、 一个请求的完整生命周期

当一个 HTTP 请求叩响 Egg.js 应用的大门,它将经历一场精密编排的管道旅行。下图清晰勾勒了其完整生命周期:

image.png

二、各层职责与交互流程

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);
};

执行流程

  1. 请求到达,Egg 按注册顺序匹配路由
  2. 匹配成功后,执行该路由绑定的中间件链
  3. 最后执行控制器方法

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、可视化洋葱模型

image.png

关键理解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、为什么叫"洋葱"?

因为中间件的执行轨迹 像切开的洋葱圈:

  1. 从外向内:逐层进入

  2. 到达核心:执行业务逻辑

  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、总结

  1. await next() 是分水岭:调用前处理请求,调用后处理响应
  2. 不调用 next() = 中断请求:权限校验失败时直接返回
  3. 洋葱模型 = 同步等待:可以准确控制执行顺序和捕获异常

六、Koa.js VS Egg.js

一句话总结:Koa 是基础框架 (毛坯房),Egg.js 是企业级框架 (精装修 + 物业管理)。

6.1、核心关系

Egg.js 基于 Koa 2.x 深度定制,不是简单的封装,而是:

  • 继承:完全兼容 Koa 的中间件机制
  • 扩展:添加了插件、约定、多进程等能力
  • 增强:提供了企业级最佳实践
Koa
  ↓ 继承 + 扩展
Egg.js
  ↓ 二次开发
你的业务应用

6.2、核心区别对比表

维度KoaEgg.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%+