普通视图

发现新文章,点击刷新页面。
昨天 — 2025年4月3日首页

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

作者 Summer_Xu
2025年4月3日 18:13

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

1. 基础 HTTP 服务器回顾

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

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 类。

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) 中间件机制的灵魂。它负责将注册的多个中间件函数按照正确的顺序串联起来执行。

/*
 * 复习一下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. 中间件示例与“洋葱模型”

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

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 异步编程中的流程控制难题,形成了富有表现力且易于维护的洋葱模型。

❌
❌