一、从一道面试题说起:中间件到底是怎么串起来的?

很多朋友第一次接触 Koa 的时候,都会被它的洋葱模型吸引。看着官方文档上那个一层套一层的示意图,感觉很有意思,但是真到了自己动手实现一个的时候,往往就懵了。尤其是 compose 这个函数,短短几十行代码,却承载了整个框架最核心的调度逻辑。

我记得自己当初学习的时候,最大的困惑就是:await next() 到底做了什么?为什么在中间件里写了 await next(),代码就会先执行后面的中间件,等后面的都执行完了,再回来执行当前中间件剩下的部分?这种控制流的反转,光靠读文档是很难真正理解的。

所以这篇文章,我想带着大家从头手写一个 compose 中间件引擎。我们不依赖任何第三方库,就是用纯 JavaScript 把 Koa 最核心的那部分逻辑给实现出来。当你亲手写一遍之后,会发现原来那个看似神秘的洋葱模型,其实背后就是一个非常巧妙的函数组合方式。

二、先搞懂我们要实现的目标

2.1 中间件在 Koa 里长什么样

在 Koa 中,一个中间件就是一个函数,它接收两个参数:ctx(上下文对象)和 next(下一个中间件的入口)。比如下面这个最典型的写法:

// 技术栈:JavaScript (Koa 风格中间件定义)
const middleware = async (ctx, next) => {
  // 进入中间件时做的操作
  console.log('开始执行中间件');
  
  // 调用 next() 去执行下一个中间件
  await next();
  
  // 下一个中间件执行完毕后,回到这里继续执行
  console.log('中间件执行完毕');
};

这里需要注意的是,next 并不是一个普通的函数,它是一个被包装过的函数。调用 next() 返回的是一个 Promise,所以在 await next() 的时候,当前中间件的代码会暂停执行,等到后续的中间件链路全部处理完成之后,才会继续往下走。

2.2 compose 函数要做的事

compose 函数的输入是一个中间件数组,输出是一个组合后的唯一入口函数。这个入口函数接收 ctx 作为参数,启动整个中间件链路。我们先规定一下接口:

// 技术栈:JavaScript (compose 接口定义)
function compose(middlewares) {
  // 输入:中间件数组
  // 输出:一个函数,接收 ctx 参数
  
  return function (ctx) {
    // 这里将启动整个中间件执行流程
  };
}

现在我们明确了目标,接下来一步一步去实现。为了让思路更清晰,我们先从一个最简单的两中间件串行执行开始,然后再推广到任意数量的中间件。

三、一步步搭起中间件引擎的骨架

3.1 最原始的递归调用想法

假设我们有两个中间件,数组是 [m1, m2]。要执行它们,最简单的想法就是先调用 m1(ctx, next),其中 next 应该指向 m2 的执行入口。而 m2 执行的时候,它的 next 应该是一个空函数。

按照这个思路,我们可以写出第一版:

// 技术栈:JavaScript (第一版 compose)
function compose(middlewares) {
  return function (ctx) {
    // 定义一个 dispatch 函数,负责处理第 i 个中间件
    function dispatch(i) {
      // 取出第 i 个中间件
      const middleware = middlewares[i];
      
      // 如果没有中间件了,返回一个空 Promise
      if (!middleware) {
        return Promise.resolve();
      }
      
      // 执行当前中间件,并且手动构造 next
      return middleware(ctx, () => {
        // 这个函数作为 next 传给中间件
        // 当中间件调用 next() 时,就去执行下一个中间件
        return dispatch(i + 1);
      });
    }
    
    // 从索引 0 开始启动
    return dispatch(0);
  };
}

这里我们其实已经完成了 compose 的核心骨架。dispatch(i) 做的事情很简单:取出第 i 个中间件,然后把它执行一下,执行的时候手动传入一个 next,这个 next 就是 dispatch(i + 1)

这种写法有一个直观的理解方式:每个中间件都可以控制是否调用 next()。如果调用了,就进入后续链路;如果不调用,就不会进入后续的中间件。 这样一来,每个中间件都有能力决定请求是否继续往下传递。

3.2 验证一下基本流程

我们写几个简单的中间件来测试一下:

// 技术栈:JavaScript (测试第一版 compose)
const m1 = async (ctx, next) => {
  console.log('m1 开始');
  await next();
  console.log('m1 结束');
};

const m2 = async (ctx, next) => {
  console.log('m2 开始');
  await next();
  console.log('m2 结束');
};

const handler = compose([m1, m2]);

handler({}).then(() => {
  console.log('全部执行完成');
});

执行上面的代码,控制台输出的是:

m1 开始
m2 开始
m2 结束
m1 结束
全部执行完成

看到这个输出顺序,大家是不是觉得有点感觉了?最先进入的中间件 m1,它的后续代码在最后才执行。这就是洋葱模型的雏形——请求处理像一个洋葱一样,一层一层往里剥,到达中心后再一层一层往外穿。

3.3 边界情况处理

到目前为止,上面的代码在“理想情况下”是没问题的。但我们写代码必须考虑各种边界情况,比如:

  • 如果中间件数组是空的,应该怎么办?
  • 如果同一个中间件被重复添加,会导致什么?
  • 如果中间件被调用多次 next(),会怎样?

我们来完善一下,把注释加上,把边界情况也处理掉:

// 技术栈:JavaScript (compose 完整版)
function compose(middlewares) {
  // 首先做类型校验
  if (!Array.isArray(middlewares)) {
    throw new TypeError('中间件必须是数组');
  }
  
  // 校验数组中的每一项都必须是一个函数
  for (const item of middlewares) {
    if (typeof item !== 'function') {
      throw new TypeError('中间件必须是函数');
    }
  }
  
  return function (ctx) {
    // 定义一个变量,用于记录当前执行的中间件索引
    let index = -1;
    
    // 核心的 dispatch 函数,接收中间件索引
    function dispatch(i) {
      // 防止一个中间件里多次调 next()
      // 如果 i 小于或等于 index,说明 next() 被重复调用了
      if (i <= index) {
        return Promise.reject(new Error('next() 被重复调用'));
      }
      
      index = i;
      
      // 取出当前要执行的中间件
      const middleware = middlewares[i];
      
      // 如果没有中间件了,直接返回成功
      if (!middleware) {
        return Promise.resolve();
      }
      
      // 执行中间件,传入 ctx 和 包装好的 next
      try {
        return middleware(ctx, () => dispatch(i + 1));
      } catch (error) {
        // 捕获同步错误,转换为 Promise 拒绝
        return Promise.reject(error);
      }
    }
    
    // 启动第一层中间件
    return dispatch(0);
  };
}

大家注意一下 index 这个变量的作用。它是为了防重入设计的。想象一下,如果某个中间件在 next() 之后又调用了一次 next(),那么 dispatch 就会被重复执行两次。有了 index 的判断,在第二次调用的时候会发现 i <= index,于是直接报错停止执行。这一点对于保证中间件链路的稳定性很重要。

四、实现一个 Web 服务器版的完整示例

光有 compose 还不够,我们需要把它放到一个真实的 Koa 环境中,看看整个请求处理的流程是怎样的。下面我们来实现一个极简版的 Koa,通过 http 模块构建一个真正的 Web 服务。

4.1 设计 context 对象

Koa 的 ctx 是一个上下文对象,它把 Node.js 原生的 reqres 封装到了一起。这里我们做一个简化版本,只保留必要的字段和方法。

// 技术栈:JavaScript (极简 Koa 服务端实现)
const http = require('http');

// 极简版本的 Koa 类
class MyKoa {
  constructor() {
    // 存储中间件数组
    this.middlewares = [];
  }
  
  // 注册中间件
  use(middleware) {
    this.middlewares.push(middleware);
    // 返回 this,支持链式调用
    return this;
  }
  
  // 创建上下文对象
  createContext(req, res) {
    const ctx = {
      req: req,
      res: res,
      // body 属性,用于存储响应内容
      body: undefined,
      // 封装一下查询参数
      get query() {
        const url = new URL(this.req.url, 'http://localhost');
        return url.searchParams;
      }
    };
    return ctx;
  }
  
  // compose 方法,就是我们上面实现的那个函数
  compose(middlewares) {
    return function (ctx) {
      let index = -1;
      
      function dispatch(i) {
        if (i <= index) {
          return Promise.reject(new Error('next() 被重复调用'));
        }
        
        index = i;
        const middleware = middlewares[i];
        
        if (!middleware) {
          return Promise.resolve();
        }
        
        try {
          return middleware(ctx, () => dispatch(i + 1));
        } catch (err) {
          return Promise.reject(err);
        }
      }
      
      return dispatch(0);
    };
  }
  
  // 处理请求的方法
  handleRequest(req, res) {
    // 创建上下文
    const ctx = this.createContext(req, res);
    
    // 组装整个链路
    const fn = this.compose(this.middlewares);
    
    // 执行
    fn(ctx)
      .then(() => {
        // 执行完毕后,把 ctx.body 返回给客户端
        if (ctx.body !== undefined) {
          res.statusCode = 200;
          // 如果 body 是对象,转换为 JSON
          if (typeof ctx.body === 'object') {
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify(ctx.body));
          } else {
            res.setHeader('Content-Type', 'text/plain');
            res.end(String(ctx.body));
          }
        } else {
          res.statusCode = 404;
          res.end('Not Found');
        }
      })
      .catch((err) => {
        // 这里捕获链路中的错误
        console.error('处理请求时出错:', err);
        res.statusCode = 500;
        res.end('Internal Server Error: ' + err.message);
      });
  }
  
  // 启动服务器
  listen(port) {
    const server = http.createServer((req, res) => {
      this.handleRequest(req, res);
    });
    
    server.listen(port);
    console.log(`服务器已启动,监听端口 ${port}`);
  }
}

4.2 用极简 Koa 构建应用

写好了核心框架,我们来用这个极简版 Koa 构建一个应用。这里我们要重点展示中间件的执行顺序、ctx 的传递,以及 next 的链路控制。

// 技术栈:JavaScript (使用 MyKoa 构建应用)
const app = new MyKoa();

// 中间件1:记录请求耗时
app.use(async (ctx, next) => {
  // 记录开始时间
  const start = Date.now();
  
  console.log(`开始处理请求: ${ctx.req.url}`);
  
  // 调用下一个中间件
  await next();
  
  // 计算耗时
  const cost = Date.now() - start;
  console.log(`请求处理完成,耗时 ${cost} 毫秒`);
});

// 中间件2:处理 /api/user 路径
app.use(async (ctx, next) => {
  // console 输出确认进入了这个中间件
  console.log('进入 /api/user 处理器');
  
  if (ctx.req.url.startsWith('/api/user')) {
    // 直接设置响应体,并且不再调用 next()
    ctx.body = {
      name: '张三',
      age: 25,
      city: '北京'
    };
    console.log('/api/user 处理器返回数据');
  } else {
    // 其他路径交给后续中间件处理
    await next();
  }
  
  console.log('离开 /api/user 处理器');
});

// 中间件3:兜底处理
app.use(async (ctx, next) => {
  console.log('进入兜底中间件');
  
  if (ctx.req.url === '/ping') {
    ctx.body = 'pong';
    return;
  }
  
  // 如果走到这里还没设置 body,说明路径没有被任何中间件处理
  // 我们就不调用 next() 了,让框架返回 404
  await next();
});

// 启动服务器
app.listen(3000);

打开终端执行这个脚本,然后在浏览器或 curl 中访问 http://localhost:3000/api/user,控制台会输出类似这样的信息:

服务器已启动,监听端口 3000
开始处理请求: /api/user
进入 /api/user 处理器
/api/user 处理器返回数据
离开 /api/user 处理器
请求处理完成,耗时 3 毫秒

仔细看这个输出顺序:首先记录请求开始,然后进入实际业务处理中间件,设置好响应数据后,开始逐层退回去。这就是洋葱模型的完整执行路径。

五、错误传播机制:让异常在链路中流动

在实际开发中,错误处理是最令人头疼的部分之一。Koa 的优势在于,它的错误会沿着中间件链路一层层向外传播。我们先来看看传播的规律。

5.1 主动捕获错误

最简单的错误处理方式,是在最外层包一个 try/catch。因为 compose 返回的是一个 Promise,所以我们可以这样写:

// 技术栈:JavaScript (compose 的错误捕获)
const compose = require('./compose');

const middlewares = [];

// 外层的错误捕获中间件
middlewares.push(async (ctx, next) => {
  try {
    console.log('外层中间件开始');
    await next();
    console.log('外层中间件正常结束');
  } catch (err) {
    console.log('外层中间件捕获到错误:', err.message);
    ctx.body = { error: err.message };
  }
});

// 可能会出错的中间件
middlewares.push(async (ctx, next) => {
  console.log('进入业务中间件');
  
  // 模拟一个异常
  throw new Error('业务逻辑出错');
  
  // 注意:这里的代码不会被执行
  await next();
});

const handler = compose(middlewares);

// 传入一个模拟的 ctx 对象
const ctx = {
  body: undefined
};

handler(ctx).then(() => {
  console.log('链路执行完毕,ctx.body =', ctx.body);
});

执行结果:

外层中间件开始
进入业务中间件
外层中间件捕获到错误: 业务逻辑出错
链路执行完毕,ctx.body = { error: '业务逻辑出错' }

从这个例子里可以看到,当内层中间件抛出错误时,错误会向外传播,直到被某个 try/catch 捕获。如果没有捕获,这个 Promise 就会变成 rejected。

5.2 深入分析错误传播的路径

我们需要更细致地分析一下:错误是怎么从最内层传出来的。回顾一下 compose 的实现:

  • 中间件 m2 执行的时候抛出异常
  • dispatch(1) 返回的 Promise 变为 rejected
  • dispatch(0)m1 调用了 await next(),这行代码的返回值就是 dispatch(1) 的结果,所以这个异常会被 m1 里的 catch 捕获

这就意味着,只有当某个中间件使用了 try/catch 包裹 await next(),才能直接捕获到后续中间件的错误。外层没有做任何捕获的话,错误会直接冒泡到最顶层。

这一点和 Koa 的官方行为是一致的:你可以在最外层的中间件里统一处理异常。在真实的 Koa 项目中,常见做法是写一个专门的错误处理中间件来做日志记录、错误码转换等操作。

5.3 使用事件机制做全局兜底

Koa 还有一种全局错误处理方式,就是在 app 实例上监听 error 事件。我们可以在 MyKoa 类里继承 EventEmitter 来实现这个能力。

// 技术栈:JavaScript (MyKoa 支持全局错误事件)
const http = require('http');
const EventEmitter = require('events');

class MyKoa2 extends EventEmitter {
  constructor() {
    super();
    this.middlewares = [];
  }
  
  use(middleware) {
    this.middlewares.push(middleware);
    return this;
  }
  
  createContext(req, res) {
    return {
      req,
      res,
      body: undefined,
      app: this  // 挂载 app 引用,方便在中间件中访问
    };
  }
  
  compose(middlewares) {
    return function (ctx) {
      let index = -1;
      
      function dispatch(i) {
        if (i <= index) {
          return Promise.reject(new Error('next() 被重复调用'));
        }
        
        index = i;
        const middleware = middlewares[i];
        
        if (!middleware) {
          return Promise.resolve();
        }
        
        try {
          return middleware(ctx, () => dispatch(i + 1));
        } catch (err) {
          return Promise.reject(err);
        }
      }
      
      return dispatch(0);
    };
  }
  
  handleRequest(req, res) {
    const ctx = this.createContext(req, res);
    const fn = this.compose(this.middlewares);
    
    fn(ctx)
      .then(() => {
        if (ctx.body !== undefined) {
          res.statusCode = 200;
          if (typeof ctx.body === 'object') {
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify(ctx.body));
          } else {
            res.setHeader('Content-Type', 'text/plain');
            res.end(String(ctx.body));
          }
        } else {
          res.statusCode = 404;
          res.end('Not Found');
        }
      })
      .catch((err) => {
        // 触发全局 error 事件
        this.emit('error', err, ctx);
        
        // 如果 ctx.body 已经被错误处理中间件设置了,就返回它
        if (ctx.body !== undefined) {
          res.statusCode = 200;
          if (typeof ctx.body === 'object') {
            res.setHeader('Content-Type', 'application/json');
            res.end(JSON.stringify(ctx.body));
          } else {
            res.setHeader('Content-Type', 'text/plain');
            res.end(String(ctx.body));
          }
        } else {
          res.statusCode = 500;
          res.end('服务器内部错误');
        }
      });
  }
  
  listen(port) {
    const server = http.createServer((req, res) => {
      this.handleRequest(req, res);
    });
    
    server.listen(port);
    console.log(`服务器已启动,监听端口 ${port}`);
  }
}

// 测试一下全局错误事件
const app2 = new MyKoa2();

// 监听全局错误事件
app2.on('error', (err, ctx) => {
  console.error(`全局捕获到错误:${err.message}`);
  console.error('发生错误的 URL:', ctx.req.url);
  
  // 设置容错的响应体
  ctx.body = {
    code: "500",
    message: "系统繁忙,请稍后重试"
  };
});

// 业务中间件
app2.use(async (ctx, next) => {
  if (ctx.req.url === '/error') {
    throw new Error('数据库连接失败');
  }
  await next();
});

// 返回数据的中间件
app2.use(async (ctx) => {
  ctx.body = { ok: true };
});

app2.listen(3001);

这种全局事件的方式,在高并发场景下非常实用,因为你不可能在每个中间件里都写一遍 try/catch,集中式错误管理是更好的选择。

六、next 链路的控制流反转:深入理解执行时机

很多朋友对洋葱模型的困惑在于:为什么 await next() 之后,要等所有的后面中间件都执行完才回来?它不是应该立即往下走吗?

要回答这个问题,我们需要回到 JavaScript 的事件循环机制。next() 返回的是一个 Promise,而 await 会暂停当前函数的执行,把这个 Promise 的解决作为恢复条件。也就是说,await next() 这一行,有点像一个“接缝”,执行到这里时,函数会把自己的控制权交出去,等待 dispatch(i + 1) 这条链路完成后再恢复。

所谓“控制流反转”,就是把这个决策权从上层中间件下放到下层中间件。上层中间件虽然写了后续代码,但它不知道什么时候执行,只有当下层全部搞定才能执行。

6.1 挂起与恢复的例子

来看一个能体现出这种“挂起恢复”特性的例子:

// 技术栈:JavaScript (控制流反转演示)
const compose = require('./compose');

const middlewares = [];

middlewares.push(async (ctx, next) => {
  console.log('1. 第一层中间件:准备进入');
  await next();
  console.log('4. 第一层中间件:完成退出');
});

middlewares.push(async (ctx, next) => {
  console.log('2. 第二层中间件:准备进入');
  await new Promise((resolve) => {
    // 模拟异步IO操作,比如读文件、访问数据库
    setTimeout(() => {
      console.log('3. 异步操作完成');
      resolve();
    }, 1000);
  });
  await next();
  console.log('5. 第二层中间件:完成退出');
});

middlewares.push(async (ctx) => {
  console.log('(到达最内层,返回结果)');
  ctx.body = '最终结果';
});

const handler = compose(middlewares);
const ctx = {};
handler(ctx).then(() => {
  console.log('6. 全部执行完毕');
});

执行结果是:

1. 第一层中间件:准备进入
2. 第二层中间件:准备进入
3. 异步操作完成
(到达最内层,返回结果)
5. 第二层中间件:完成退出
4. 第一层中间件:完成退出
6. 全部执行完毕

注意看第 3 行到第 5 行的输出顺序。当第二层中间件等待异步操作完成的过程中,第一层中间件并不会继续往下执行,它是被 await 挂起的。只有当第二层中间件完全执行完(包括它自己的后续代码),控制权才会交还给第一层。这种“挂起”和“唤醒”的节奏,就是控制流反转的具体表现。

6.2 如果不调用 next 会怎样

这个情况也很有意思。如果某个中间件内部判断条件不满足,直接 return 了,就没有调用 next(),那么后面的所有中间件都不会执行,控制流直接往回走了。

// 技术栈:JavaScript (短路逻辑)
const compose = require('./compose');

const middlewares = [];

middlewares.push(async (ctx, next) => {
  console.log('登录验证中间件');
  
  // 判断是否已经登录
  if (!ctx.isLogin) {
    console.log('用户未登录,拦截请求');
    ctx.body = '请先登录';
    // 注意这里没有调用 next()
    return;
  }
  
  await next();
});

middlewares.push(async (ctx) => {
  console.log('真实的业务逻辑');
  ctx.body = '这里是敏感数据';
});

const handler = compose(middlewares);

// 模拟未登录的请求
handler({ isLogin: false }).then(() => {
  console.log('未登录请求处理完成');
});

console.log('------------');

// 模拟已登录的请求
handler({ isLogin: true }).then(() => {
  console.log('已登录请求处理完成');
});

输出结果:

登录验证中间件
用户未登录,拦截请求
未登录请求处理完成
------------
登录验证中间件
真实的业务逻辑
已登录请求处理完成

这种短路机制在很多场景下非常有用,比如权限控制、请求校验、接口限流等等。你可以在中间件链的任何位置中断请求,只需要不调用 next() 就行。

七、关联技术对比:Koa 的 compose 与 Express 的中间件机制

既然我们讨论了 Koa 的中间件引擎,那就顺便提一下 Express 的中间件机制作为对比。这不是冗余内容,而是为了让你更深入地理解 Koa 设计的特点。

Express 的中间件也是通过 next() 串起来的,但它的 next 是同步遍历的,并不强制要求 await。也就是说在 Express 里,写异步中间件需要非常小心,如果不加 await,代码的执行顺序可能会和你想象的不一样。另外,Express 并没有像 Koa 那样在 next() 周围建立天然的 Promise 链,所以错误捕获通常要依赖专门的错误处理中间件,而且需要在所有中间件之后定义。

对比之下,Koa 的设计更现代、更统一。compose 把所有中间件组合成一个 Promise 链,因此你可以用 async/await 精确控制流程。洋葱模型的执行顺序天然适合日志记录、耗时统计、事务管理等需要“先进入后退出”的业务场景。

基于这个思路,我们再看一个稍微复杂点的业务场景。

八、实战场景:一个带日志和鉴权的接口服务

把前面的内容整合一下,我们来构建一个稍微完整一点的服务,演示实际应用中的写法。

8.1 需求描述

我们需要实现两个接口:

  • GET /api/user:需要登录认证,返回用户信息
  • GET /api/public:无需认证,返回公开信息

并且要对所有请求做日志记录,包括请求方法、路径、耗时。

8.2 完整的代码实现

// 技术栈:JavaScript (Node.js 完整中间件应用)
const http = require('http');

// ---------- 极简 compose 实现 ----------
function compose(middlewares) {
  return function (ctx) {
    let index = -1;
    
    function dispatch(i) {
      // 防止同一个 next 被多次执行
      if (i <= index) {
        return Promise.reject(new Error('next() 被重复调用'));
      }
      index = i;
      
      const middleware = middlewares[i];
      if (!middleware) {
        return Promise.resolve();
      }
      
      try {
        return middleware(ctx, () => dispatch(i + 1));
      } catch (err) {
        return Promise.reject(err);
      }
    }
    
    return dispatch(0);
  };
}

// ---------- 极简的应用类 ----------
class MiniApp {
  constructor() {
    this.middlewares = [];
  }
  
  use(...args) {
    // 支持传入多个中间件
    this.middlewares.push(...args);
    return this;
  }
  
  handle(req, res) {
    // 构造上下文
    const ctx = {
      req,
      res,
      body: undefined,
      // 解析后的 URL
      url: new URL(req.url, 'http://localhost')
    };
    
    const fn = compose(this.middlewares);
    
    fn(ctx).then(() => {
      if (ctx.body === undefined) {
        res.statusCode = 404;
        res.end('Not Found');
      } else if (typeof ctx.body === 'object') {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'application/json');
        res.end(JSON.stringify(ctx.body));
      } else {
        res.statusCode = 200;
        res.end(String(ctx.body));
      }
    }).catch((err) => {
      console.error('发生未捕获的错误:', err);
      res.statusCode = 500;
      res.end('Internal Server Error');
    });
  }
  
  listen(port) {
    const server = http.createServer((req, res) => this.handle(req, res));
    server.listen(port);
    console.log(`服务启动于 http://localhost:${port}`);
  }
}

// ---------- 构建业务应用 ----------
const app = new MiniApp();

// 1. 日志中间件 - 最外层
app.use(async (ctx, next) => {
  const start = Date.now();
  const method = ctx.req.method;
  const path = ctx.url.pathname;
  
  console.log(`[${new Date().toISOString()}] ${method} ${path} - 开始`);
  
  try {
    await next();
  } catch (err) {
    console.error(`[错误] ${method} ${path}: ${err.message}`);
    throw err;  // 继续向外抛
  }
  
  const cost = Date.now() - start;
  console.log(`[${new Date().toISOString()}] ${method} ${path} - 完成,耗时 ${cost}ms`);
});

// 2. 鉴权中间件
app.use(async (ctx, next) => {
  const authToken = ctx.req.headers['authorization'];
  const path = ctx.url.pathname;
  
  // 只保护 /api/user 路径
  if (path === '/api/user' && !authToken) {
    ctx.body = {
      code: 401,
      message: '未提供身份信息'
    };
    return;  // 中断链路
  }
  
  await next();
});

// 3. 业务路由中间件
app.use(async (ctx, next) => {
  const path = ctx.url.pathname;
  
  if (path === '/api/user') {
    ctx.body = {
      code: 0,
      data: {
        id: 1024,
        name: '李四',
        group: 'admin'
      }
    };
    return;
  }
  
  if (path === '/api/public') {
    ctx.body = {
      code: 0,
      data: {
        message: '欢迎访问公开接口'
      }
    };
    return;
  }
  
  // 其他路径交给下一个中间件
  await next();
});

// 4. 兜底中间件
app.use(async (ctx) => {
  ctx.body = {
    code: 404,
    message: `资源 ${ctx.url.pathname} 不存在`
  };
});

// 启动
app.listen(8080);

启动后用 curl 试试:

# 不带认证信息访问受保护接口
curl -i http://localhost:8080/api/user

# 不带认证信息访问公开接口
curl http://localhost:8080/api/public

# 访问不存在的接口
curl http://localhost:8080/no/such/api

控制台会输出每个请求的完整生命周期日志,包括进入和退出时间。这在实际生产环境里就是监控系统的基础。

九、技术优缺点与注意事项分析

9.1 compose 中间件引擎的优点

第一个优点是控制力极强。每个中间件都可以决定是否继续往下走,以及何时往下走。这种灵活性让代码的职责拆分变得容易,比如限流逻辑、缓存逻辑、权限逻辑都能独立成中间件,互不干扰。

第二个优点是错误传播自然。由于整个链路是 Promise 链,错误会自动沿着 next() 调用链向上传播。配合 async/await,写起来非常顺手,逻辑清晰。

第三个优点是可组合性高。中间件之间没有硬编码的依赖关系,完全可以自由调整顺序,甚至可以动态增删。这在大规模团队协作中非常友好。

9.2 存在的缺点

虽然设计精巧,但也不是没有缺点。第一个问题是对异步中间件要求高,如果中间件没有用 async 修饰,或者里面没有正确返回 Promise,就可能导致链路提前断裂。所以在写 Koa 中间件的时候,必须记住始终 return next() 或者使用 await next()

第二个缺点是过度灵活容易带来不确定性。如果中间件逻辑过于复杂,或者相互之间的顺序容易搞混,调试起来会比较头疼。尤其当中间件很多的时候,洋葱模型的嵌套层级深了,心智负担会加重。

第三个缺点是性能问题,每次请求都要创建一个 Promise 链,如果中间件数量非常多,内存和 CPU 的开销会比传统的同步遍历更大。但在绝大多数业务场景下,这种性能差异完全可以忽略不计。

9.3 实际开发中的注意事项

  • 中间件的顺序非常重要,一定要把像日志、错误捕获这类通用中间件排在前面。
  • 不要在中间件里做耗时很长的同步操作,这会阻塞事件循环。
  • 如果中间件内部有分支逻辑,要注意每个分支上是否应该调用 next(),避免出现该走却没走的情况。
  • 自定义错误中间件要放在最外层,否则捕获不到内部错误。
  • 做单元测试时,可以直接用 compose([...]) 搭配 mock 的 ctx 来验证执行顺序,非常方便。

十、文章总结

我从一个简单的数组遍历递归开始,一步步写出了 compose 函数。我们从最基础的 dispatch 设计入手,加入了防重入判断,然后把它应用到一个极简版的 Koa 服务器中。接着我们深入分析了错误传播的路径和 next 的控制流反转机制,最后讨论了应用场景和注意事项。

你会发现,剥开外壳之后,Koa 的洋葱模型底层本质上并不复杂。它就是把一组函数用递归调用串起来,然后用 Promise 作为统一的异步处理机制,配合 async/await 完成了优雅的流程控制。这种设计思想的精髓在于控制反转——上层组件不再直接指挥下层组件,而是提供一种“挂起”和“恢复”的机制,让整个链路既能灵活中断,又能可靠传透错误。

如果你要深入学习 Node.js 服务端框架,我强烈建议你亲自把这个引擎写一遍。不要只看着文档理解,动手写的过程中,无数次被 await next() 的输出顺序折腾两下,你就彻底明白它到底是怎么回事了。希望这篇文章能帮到你。