后端开发··1 阅读·预计 18 分钟

从 Express 到 Koa:为什么大厂都在用洋葱模型重构后端?

引言

如果你还在用 Express 写 Node.js 后端,可能已经落后了。

2024 年,无论是阿里的 Egg.js、腾讯的 TSF,还是字节跳动的内部框架,都在做同一件事:用 Koa 的洋葱模型重构中间件系统。这不是跟风,而是 Express 的线性中间件模型在复杂业务场景下真的扛不住了。

本文将深入剖析 Koa 的洋葱模型原理,对比 Express 的线性模型,并给出可直接落地的实战代码。

一、Express 的痛点:为什么线性模型不够用了?

1.1 Express 中间件执行顺序

const express = require('express');
const app = express();

app.use((req, res, next) => {
  console.log('1. 开始');
  next();
  console.log('5. 结束'); // 这行不会按预期执行
});

app.use((req, res, next) => {
  console.log('2. 中间件');
  next();
});

app.get('/', (req, res) => {
  console.log('3. 路由处理');
  res.send('Hello');
  console.log('4. 响应后');
});

实际输出:

1. 开始
2. 中间件
3. 路由处理
4. 响应后

问题在哪?

  • next() 之后的代码(第 5 步)不会执行
  • Express 一旦进入路由处理,响应发出后流程就结束了
  • 你无法在响应后统一处理日志、清理资源、统计耗时

1.2 真实业务场景的困扰

假设你要实现一个 API 耗时统计中间件

// Express 版本 - 有问题的实现
app.use((req, res, next) => {
  const start = Date.now();
  next();
  // 这里执行不到,因为 res.send() 已经结束了请求
  const duration = Date.now() - start;
  console.log(`${req.path} 耗时: ${duration}ms`);
});

解决方案? 你需要劫持 res.end,代码变得丑陋:

// Express 的妥协方案
app.use((req, res, next) => {
  const start = Date.now();
  const originalEnd = res.end.bind(res);
  
  res.end = function(...args) {
    const duration = Date.now() - start;
    console.log(`${req.path} 耗时: ${duration}ms`);
    originalEnd(...args);
  };
  
  next();
});

这种猴子补丁的方式既不优雅,也容易引发 bug。

二、Koa 的洋葱模型:优雅的解决方案

2.1 什么是洋葱模型?

Koa 的中间件执行顺序像剥洋葱:先进后出(FILO)。

请求 → 中间件1 → 中间件2 → 中间件3 → 业务逻辑
       ↑           ↑           ↑
       └───────────┴───────────┘
       返回时逆序执行

代码示例:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('1. 进入外层中间件');
  await next();
  console.log('5. 离开外层中间件');
});

app.use(async (ctx, next) => {
  console.log('2. 进入中层中间件');
  await next();
  console.log('4. 离开中层中间件');
});

app.use(async (ctx) => {
  console.log('3. 执行业务逻辑');
  ctx.body = 'Hello Koa';
});

app.listen(3000);

输出:

1. 进入外层中间件
2. 进入中层中间件
3. 执行业务逻辑
4. 离开中层中间件
5. 离开外层中间件

关键区别:

  • await next() 之后的代码一定会执行
  • 可以在响应后统一处理逻辑
  • 代码结构清晰,符合直觉

2.2 洋葱模型的实现原理

Koa 的核心是一个递归 Promise 链

// Koa 中间件执行的核心逻辑(简化版)
function compose(middleware) {
  return function(context, next) {
    let index = -1;
    
    function dispatch(i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'));
      index = i;
      
      let fn = middleware[i];
      if (i === middleware.length) fn = next;
      if (!fn) return Promise.resolve();
      
      try {
        // 关键:将 next 包装成 Promise
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
    
    return dispatch(0);
  };
}

执行流程解析:

  1. dispatch(0) 执行第一个中间件
  2. 中间件调用 next() 实际上是 dispatch(1)
  3. 形成递归,直到所有中间件执行完毕
  4. Promise 链回溯,执行每个中间件 await next() 之后的代码

三、实战:用 Koa 重构 Express 项目

3.1 耗时统计中间件(对比版)

Express 版本(丑陋):

app.use((req, res, next) => {
  const start = Date.now();
  const originalEnd = res.end.bind(res);
  
  res.end = function(...args) {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} - ${duration}ms`);
    originalEnd(...args);
  };
  next();
});

Koa 版本(优雅):

app.use(async (ctx, next) => {
  const start = Date.now();
  await next(); // 等待所有后续中间件和业务逻辑执行完毕
  const duration = Date.now() - start;
  console.log(`${ctx.method} ${ctx.path} - ${duration}ms`);
});

优势:

  • 无需劫持任何方法
  • 代码量减少 50%
  • 可读性大幅提升

3.2 错误统一处理中间件

// error-handler.js
module.exports = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    // 统一错误处理
    ctx.status = err.status || 500;
    ctx.body = {
      success: false,
      message: err.message,
      code: err.code || 'INTERNAL_ERROR'
    };
    
    // 记录错误日志
    console.error(`[Error] ${ctx.method} ${ctx.path}:`, err);
    
    // 上报监控系统(如 Sentry)
    // sentry.captureException(err);
  }
};

使用:

const errorHandler = require('./error-handler');

// 放在最外层,捕获所有错误
app.use(errorHandler);
app.use(otherMiddleware);

3.3 数据库事务中间件

这是洋葱模型最实用的场景之一:

// transaction.js
const { sequelize } = require('./db');

module.exports = async (ctx, next) => {
  const transaction = await sequelize.transaction();
  ctx.transaction = transaction;
  
  try {
    await next();
    await transaction.commit();
  } catch (err) {
    await transaction.rollback();
    throw err;
  }
};

业务代码使用:

const router = require('koa-router')();

router.post('/order', async (ctx) => {
  const { transaction } = ctx;
  
  // 所有数据库操作自动在事务中
  await Order.create({...}, { transaction });
  await Inventory.decrement({...}, { transaction });
  await Payment.create({...}, { transaction });
  
  ctx.body = { success: true };
});

app.use(transaction);
app.use(router.routes());

关键点:

  • 事务在中间件中开启
  • 业务逻辑执行完毕后自动提交
  • 如果业务逻辑抛错,自动回滚

3.4 JWT 鉴权中间件

// auth.js
const jwt = require('jsonwebtoken');

module.exports = async (ctx, next) => {
  const token = ctx.headers.authorization?.replace('Bearer ', '');
  
  if (!token) {
    ctx.status = 401;
    ctx.body = { message: '未提供 Token' };
    return;
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    ctx.user = decoded;
    await next();
  } catch (err) {
    ctx.status = 401;
    ctx.body = { message: 'Token 无效' };
  }
};

路由级使用:

const auth = require('./auth');

// 公开路由
router.post('/login', loginHandler);

// 需要鉴权的路由
router.get('/profile', auth, getProfile);
router.post('/order', auth, createOrder);

四、从 Express 迁移到 Koa 的完整方案

4.1 项目结构调整

express-project/          koa-project/
├── routes/               ├── middlewares/     # 中间件目录
│   ├── user.js           │   ├── error.js
│   └── order.js          │   ├── auth.js
├── middlewares/          │   ├── logger.js
│   └── auth.js           │   └── transaction.js
├── app.js                ├── routes/
└── package.json          │   ├── user.js
                          │   └── order.js
                          ├── app.js
                          └── package.json

4.2 核心代码迁移

Express app.js → Koa app.js:

// app.js (Koa)
const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const router = require('./routes');
const errorHandler = require('./middlewares/error');
const logger = require('./middlewares/logger');

const app = new Koa();

// 中间件顺序很重要
app.use(errorHandler);  // 最外层捕获错误
app.use(logger);        // 日志记录
app.use(bodyParser());  // 解析请求体
app.use(router());      // 路由

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

4.3 路由迁移

Express 路由 → Koa 路由:

// routes/user.js (Koa)
const Router = require('koa-router');
const router = new Router({ prefix: '/users' });
const auth = require('../middlewares/auth');

// GET /users
router.get('/', async (ctx) => {
  const users = await User.findAll();
  ctx.body = { success: true, data: users };
});

// GET /users/:id
router.get('/:id', async (ctx) => {
  const user = await User.findByPk(ctx.params.id);
  if (!user) {
    ctx.status = 404;
    ctx.body = { message: '用户不存在' };
    return;
  }
  ctx.body = { success: true, data: user };
});

// POST /users (需要鉴权)
router.post('/', auth, async (ctx) => {
  const user = await User.create(ctx.request.body);
  ctx.status = 201;
  ctx.body = { success: true, data: user };
0 评论

评论区

登录 后参与评论