模拟 Koa 中间件机制与洋葱模型

通过一个简化的 Koa 类来理解 Node.js 的 http 模块以及 Koa.js 框架核心的中间件机制,特别是其著名的"洋葱模型"和异步流程控制。

1. 基础 HTTP 服务器回顾

在深入 Koa 机制之前,我们首先需要一个基础的 HTTP 服务器。Node.js 内建的 http 模块允许我们轻松创建。

javascript 复制代码
const http = require("http");

const hostname = "127.0.0.1";
const port = 3000;


 const server = http.createServer((req, res) => {
   res.statusCode = 200;
   res.setHeader("Content-Type", "text/plain");
   res.end("Hello World\n");
 });

 
 server.listen(port, hostname, () => {
   console.log(`Server running at http://${hostname}:${port}/`);
 });

这个基础服务器展示了如何监听端口、接收请求 (req) 并发送响应 (res)。然而,当业务逻辑变得复杂时,直接在 createServer 的回调中处理所有事情会变得难以维护。这就是中间件模式发挥作用的地方。

2. Koa 类:模拟 Koa 的核心

为了模拟 Koa 的行为,我们创建了一个 Koa 类。

javascript 复制代码
class Koa {
  constructor() {
    this.middleware = []; //中间件栈
  }

  use(fn) {
    if (typeof fn !== "function")
      throw new TypeError("middleware must be a function!");
    this.middleware.push(fn);
    return this; // 支持链式调用 .use(fn1).use(fn2)
  }

  // 创建上下文对象
  createContext(req, res) {
    const context = {};
    context.req = req;
    context.res = res;
    context.url = req.url;
    context.method = req.method;
    // 可以在这里添加更多 Koa ctx 上的常用属性或方法,例如 context.body
    context.body = "Not Found"; // 默认响应体
    res.statusCode = 404; // 默认状态码
    return context;
  }

  // 处理请求的核心回调
  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => {
      // 根据ctx.body的类型设置Content-Type
      if (typeof ctx.body === "string") {
        ctx.res.setHeader("Content-Type", "text/plain; charset=utf-8");
      } else if (typeof ctx.body === "object" && ctx.body !== null) {
        ctx.res.setHeader("Content-Type", "application/json; charset=utf-8");
        ctx.body = JSON.stringify(ctx.body); // 对象转 JSON 字符串
      }
       // 如果没有设置 body 但状态码是 404,则设置 body
      if (ctx.res.statusCode === 404 && ctx.body === "Not Found") {
           ctx.body = 'Not Found';
           ctx.res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      }

      ctx.res.end(ctx.body);
    }

    // 执行中间件组合函数
    return fnMiddleware(ctx)
      .then(handleResponse) //所有中间件执行完毕,成功后处理响应
      .catch((error) => { // 捕获中间件链中的错误
        console.error("Middleware Error:", error);
        ctx.res.statusCode = 500;
        ctx.res.setHeader("Content-Type", "text/plain");
        ctx.res.end("Internal Server Error");
      });
  }

  // 启动服务器
  listen(...args) {
    // 注册中间件 -> 应为组合中间件
    const fnMiddleware = compose(this.middleware); // 组合所有注册的中间件
    // 当一个http请求进来时,回调触发
    const server = http.createServer((req, res) => {
      // 为每个请求创建独立的上下文对象
      const ctx = this.createContext(req, res);
      // 处理请求
      this.handleRequest(ctx, fnMiddleware);
    });

    return server.listen(...args); // 将 listen 参数传给 http.server.listen
  }
}
  • constructor : 初始化一个空数组 this.middleware 用于存储所有注册的中间件函数。
  • use(fn) : 这是注册中间件的方法。它接收一个函数 fn,校验其类型后将其添加到 this.middleware 数组中。返回 this 以支持链式调用 (app.use(mw1).use(mw2)).
  • createContext(req, res) : Koa 的核心概念之一是 Context (上下文) 对象,通常表示为 ctx。这个方法为每个进入的请求创建一个 ctx 对象,将原始的 req (请求) 和 res (响应) 对象封装起来,并提供一些便捷的属性和方法(这里简化了,只添加了 url, method, body 和默认状态码)。这使得中间件访问请求和修改响应更加方便,而无需直接操作 reqres
  • handleRequest(ctx, fnMiddleware) : 这是处理请求流程的核心。它接收创建好的 ctx 对象和 组合后 的中间件函数 fnMiddleware。它调用 fnMiddleware(ctx) 来启动中间件链的执行。
    • .then(handleResponse): 当中间件链成功执行完毕 (Promise resolved) 时,调用 handleResponse 来发送最终的 HTTP 响应。handleResponse 会根据 ctx.body 的类型和 ctx.res.statusCode 来设置正确的响应头并发送内容。
    • .catch((error) => { ... }): 如果在中间件执行过程中任何地方抛出错误 (Promise rejected),这个 .catch 会捕获它,记录错误日志,并发送一个标准的 500 内部服务器错误响应。这是 Koa 健壮性的体现,提供了一个统一的错误处理机制。
  • listen(...args) : 这个方法负责启动 HTTP 服务器。
    1. 它首先调用 compose(this.middleware) 来获取一个 单一的、组合后的 中间件处理函数 fnMiddleware
    2. 然后,它使用 http.createServer 创建服务器实例。对于每一个进来的请求 (req, res):
      • 调用 this.createContext(req, res) 创建该请求独有的 ctx 对象。
      • 调用 this.handleRequest(ctx, fnMiddleware) 来执行中间件链并处理响应。
    3. 最后,调用底层 http 服务器的 listen 方法,开始监听指定的端口和主机。

3. compose 函数:中间件的"指挥官"

compose 函数是 Koa (以及我们模拟的 Koa) 中间件机制的灵魂。它负责将注册的多个中间件函数按照正确的顺序串联起来执行。

javascript 复制代码
/*
 * 复习一下JS中async/await和Promise的工作方式
 * async函数特性会返回一个Promise对象,即使内部没有return,也会被自动包装在一个resolved的Promise种
 * 而await的作用是等待一个Promise对象的状态变为settled,即resolved或rejected,如果某一个Promise还处于
 * pending未完成的状态,当前的async函数就会暂停执行,然后跳出当前async函数执行其他同步代码,这些任务包括,
 * 1. setTimeout/setInterval回调
 * 2. Promise的.then或.catch回调
 * 3. 用户交互事件等
 * 一旦某个Promise状态变成了resolved,async函数就会在之前暂停的地方恢复执行
 */

// 中间件组合函数
function compose(middleware) {
  // 确保 middleware 是一个数组
  if (!Array.isArray(middleware))
    throw new TypeError("Middleware stack must be an arry!");
  // 确保中间件数组中的每个元素都是函数
  for (const fn of middleware) {
    if (typeof fn !== "function")
      throw new TypeError("Middleware must be composed of functions!");
  }

  // 返回一个最终要执行的函数,接收 context 和一个可选的 next 函数(通常是 http 服务器的结束处理)
  return function (context, next) {
    let index = -1; // 用于检测 next() 是否被多次调用

    // 定义 dispatch 函数,用于递归调用中间件
    function dispatch(i) {
      // 如果一个中间件内多次调用 next(),则 index 会小于 i
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;

      let fn = middleware[i];
      // 当 i 等于中间件数量时,说明所有中间件已执行完毕
      // 如果有传入 next(通常没有,或者是一个最终处理),则执行它
      if (i === middleware.length) fn = next;
      // 如果 fn 不存在(到达末尾且没有 next),则直接 resolve
      if (!fn) return Promise.resolve();

      try {
        // 执行当前中间件 fn
        // 传入 context 和下一个中间件的 dispatch 调用 (dispatch.bind(null, i + 1)) 作为 next 参数
        // 使用 Promise.resolve 包装,确保即使中间件不是 async 函数也能正常工作
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (error) {
        // 捕获中间件执行中的同步错误
        return Promise.reject(error);
      }
    }
    // 开始执行第一个中间件
    return dispatch(0);
  };
}
  • 输入与输出 : compose 接收一个中间件函数数组 middleware,返回一个 新的函数 。这个返回的函数才是最终在 handleRequest 中被调用的,它接收 context 对象作为参数。
  • dispatch(i) : 这是 compose 内部的核心递归函数。它的作用是执行第 i 个中间件。
  • index 变量 : 用于确保在一个中间件函数内部,next() (即 dispatch(i+1)) 只被有效调用一次。如果尝试调用多次,会抛出错误。这是 Koa 严格控制流程的一部分。
  • 递归调用与 next : compose 最巧妙的部分在于 return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
    • 它执行当前的中间件 fn
    • 关键是第二个参数 dispatch.bind(null, i + 1):它创建了一个 新的函数 ,这个新函数调用 dispatch 时,索引 i 会自动加 1。这个新函数就是传递给当前中间件 fnnext 参数!
    • 所以,当中间件内部调用 await next() 时,实际上是在调用 await dispatch(i + 1),从而触发下一个中间件的执行。
  • Promise.resolve() 包装 : 使用 Promise.resolve() 包裹 fn(...) 的调用,是为了兼容同步和异步中间件。即使 fn 不是 async 函数,compose 也能基于 Promise 正确地处理执行链。如果 fnasync 函数,它本身返回的就是 Promise,Promise.resolve() 对其没有影响。如果 fn 是普通函数,Promise.resolve() 会将其返回值(或 undefined)包装成一个 resolved Promise。
  • 处理链末端 : 当 i 到达 middleware.length 时,意味着所有注册的中间件都执行完了它们 next() 调用之前的部分。如果 compose 调用时传入了第二个参数 next (在我们的 MyKoa 例子中通常没有),则会执行这个 next。否则,dispatch 会返回 Promise.resolve(),标志着中间件链向前传递的部分结束。

4. 中间件示例与"洋葱模型"

现在我们来看具体的中间件如何协同工作:

javascript 复制代码
const app = new Koa();

app
  .use(async (ctx, next) => {
    const start = Date.now();
    console.log(`--> MW1 Start ${ctx.method} ${ctx.url}`);
    await next(); // 调用下一个中间件 (暂停 MW1, 执行 MW2)
    // await next(); // 在这里再次调用 next() 会触发 "next() called multiple times" 错误
    const ms = Date.now() - start;
    console.log(`<-- MW1 End (${ms}ms)`); // MW2 和 MW3 完全结束后才会执行这里
    // 可以在这里设置响应头等
    ctx.res.setHeader("X-Response-Time", `${ms}ms`);
  })
  .use(async (ctx, next) => {
    console.log(` --> MW2 Start`);
    // 模拟一个异步数据库查询或 API 调用
    await new Promise((resolve) => setTimeout(resolve, 100)); // 模拟耗时操作
    console.log(` <-- MW2 Async Done`);
    await next(); // 调用下一个中间件 (暂停 MW2, 执行 MW3)
    console.log(`<-- MW2 End`); // MW3 完全结束后才会执行这里
  })
  .use(async (ctx, next) => {
    console.log(` --> MW3 Start`);
    if (ctx.url === "/") {
      ctx.body = "Hello from MyKoa!";
      ctx.res.statusCode = 200;
    } else if (ctx.url === "/json") {
      ctx.body = { message: "This is JSON" };
      ctx.res.statusCode = 200;
    }

    // 即使这个中间件是最后一个,也可以选择性地调用 next()
    // 如果后面没有中间件了,调用 await next() 会立即返回 Promise.resolve()
    // await next(); // 在这里调用 next() 没有实际效果,因为后面没有中间件了,但不会报错
    console.log(`<-- MW3 End`); // next() 之后(或没有调用 next())的代码
  });

const hostname = "127.0.0.1";
const port = 3000;

app.listen(port, hostname, () => {
  console.log(`MyKoa server running at http://${hostname}:${port}/`);
});

执行流程:

  1. 请求进入 : handleRequest 调用 fnMiddleware(ctx),实际上是 dispatch(0)
  2. MW1 开始 : dispatch(0) 执行 MW1 (app.use 的第一个函数)。
    • 记录 start 时间。
    • 打印 --> MW1 Start GET /
    • 遇到 await next(),它实际上是 await dispatch(1)。MW1 的执行暂停。
  3. MW2 开始 : dispatch(1) 执行 MW2。
    • 打印 --> MW2 Start
    • 遇到 await new Promise(...),模拟异步操作。MW2 的执行暂停,等待 setTimeout 完成。此时 JavaScript 事件循环可以处理其他任务。
    • setTimeout 完成后,Promise resolve,await 结束。
    • 打印 <-- MW2 Async Done
    • 遇到 await next(),即 await dispatch(2)。MW2 的执行再次暂停。
  4. MW3 开始 : dispatch(2) 执行 MW3。
    • 打印 --> MW3 Start
    • 判断 ctx.url === '/'true
    • 设置 ctx.body = 'Hello from MyKoa!'ctx.res.statusCode = 200
    • 打印 <-- MW3 End
    • MW3 函数执行完毕。由于没有 await next() 或者 next() 指向的是一个空的 Promise.resolve(),dispatch(2) 返回的 Promise resolve。
  5. MW2 恢复 : await next() (即 await dispatch(2)) 在 MW2 中结束。
    • 打印 <-- MW2 End
    • MW2 函数执行完毕。dispatch(1) 返回的 Promise resolve。
  6. MW1 恢复 : await next() (即 await dispatch(1)) 在 MW1 中结束。
    • 计算耗时 ms
    • 打印 <-- MW1 End (${ms}ms)
    • 设置响应头 X-Response-Time
    • MW1 函数执行完毕。dispatch(0) 返回的 Promise resolve。
  7. 响应发送 : handleRequest 中的 .then(handleResponse) 被触发,handleResponse 读取 ctx.res.statusCode (200) 和 ctx.body ('Hello from MyKoa!'),设置 Content-Type 并调用 ctx.res.end() 发送响应给客户端。

这就是"洋葱模型":

  • 请求像剥洋葱一样,按顺序穿过每个中间件的 await next() 之前的部分(从 MW1 到 MW3)。
  • 到达最内层(MW3 处理响应逻辑)后,控制权再像穿洋葱一样,按相反的顺序依次经过每个中间件 await next() 之后的部分(从 MW3 回到 MW1)。

我的观点与理解:

  • 关注点分离 (Separation of Concerns): 洋葱模型极大地促进了代码的模块化。每个中间件可以专注于一个特定的任务,如日志记录 (MW1)、身份验证、数据校验、压缩、最终响应处理 (MW3) 等。
  • 可预测的异步流程 : async/awaitcompose 的结合,使得即使存在复杂的异步操作(如 MW2 的 setTimeout),整个请求-响应的生命周期流程仍然是清晰和可预测的。await next() 确保了后续中间件(包括其内部的异步操作)完成后,控制权才会返回。
  • 强大的控制力 : 中间件不仅可以在 next() 之前操作请求 (ctx),还可以在 next() 之后操作响应 (ctx)。例如,MW1 在所有内部中间件执行完毕后计算总耗时并添加到响应头,这是洋葱模型回流阶段的典型应用。
  • 灵活性 : 中间件可以选择性地调用 next()。如果不调用 next(),请求处理流程将在当前中间件处终止(后续中间件不会执行),这对于实现路由、权限控制等非常有用(虽然本例中没有显式展示终止流程)。
  • 错误处理 : composehandleRequest 提供的 try...catch 和 Promise .catch 机制,为整个中间件链提供了一个集中的错误处理点,简化了错误管理。

总而言之,通过模拟 Koa 的 compose 函数和中间件执行流程,我们能深刻理解其设计的精妙之处,特别是它如何利用 async/await 和 Promise 优雅地解决了 Node.js 异步编程中的流程控制难题,形成了富有表现力且易于维护的洋葱模型。

相关推荐
代码匠心21 小时前
AI 自动编程:一句话设计高颜值博客
前端·ai·ai编程·claude
_AaronWong1 天前
Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记
前端·javascript·vue.js
cxxcode1 天前
I/O 多路复用:从浏览器到 Linux 内核
前端
用户5433081441941 天前
AI 时代,前端逆向的门槛已经低到离谱 — 以 Upwork 为例
前端
JarvanMo1 天前
Flutter 版本的 material_ui 已经上架 pub.dev 啦!快来抢先体验吧。
前端
恋猫de小郭1 天前
AI 可以让 WIFI 实现监控室内人体位置和姿态,无需摄像头?
前端·人工智能·ai编程
哀木1 天前
给自己整一个 claude code,解锁编程新姿势
前端
程序员鱼皮1 天前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github
UrbanJazzerati1 天前
Vue3 父子组件通信完全指南
前端·面试
是一碗螺丝粉1 天前
5分钟上手LangChain.js:用DeepSeek给你的App加上AI能力
前端·人工智能·langchain