Koa 洋葱模型:中间件执行原理与工程实践

Koa 中间件是接收 ctx 与 next 的 async 函数,请求逐层进入、await next() 之后再逐层退出。本文从 compose 原理讲到日志、跨域、请求体解析、错误兜底的完整中间件链,以及时序验证与排错清单。

  • Koa
  • 概念
  • 入门
  • 更新于 2026-10-31

接手一个 Koa 项目时,最容易卡住的地方往往不是写业务接口,而是中间件:日志为什么打了两遍?POST 的请求体怎么是 undefined?为什么同一个中间件,有的接口生效、有的接口不生效?

这些问题几乎都指向同一个机制——洋葱模型。它不是一句比喻,而是 koa-compose 里几十行代码实现出来的确定行为:注册顺序决定入程顺序,回程顺序相反;await next() 是你唯一能挂到这棵洋葱上的钩子。

这篇把 Koa 洋葱模型从源码讲到可运行的工程实践:先看 compose 怎么把 next 串成 Promise 链,再一步步搭出包含请求 ID、访问日志、统一错误处理、请求体解析、跨域和路由的中间件链,最后用自动化测试把每一个结论钉死。你读完应该能自己判断一个中间件该放在哪一层、怎么验证它的执行位置,以及出问题时先查哪里。

文中所有代码都在 Node.js 24.16.0 + TypeScript 下实际跑过,输出是实测结果,不是推演。

一、先建立 Koa 的心智模型:没有 res,只有 ctx

Koa 2 起基于 async/await,核心是 app.use() 注册的中间件栈。它没有 Express 那种由框架注入的 res 对象,而是把请求与响应合并进一个 ctx,交给每个中间件。

ctx 上的东西 属于 典型用法
ctx.request 请求侧 ctx.request.header、ctx.query、ctx.path;ctx.request.body 需要 bodyparser 才有
ctx.response 响应侧 ctx.response.status、ctx.response.type
ctx.state 请求级共享数据 中间件之间传值的约定位置,例如放 ctx.state.requestId
ctx.set(field, val) 响应头 ctx.set('x-request-id', id)
ctx.get(field) 请求头 读上游透传下来的 x-request-id
ctx.throw(status, msg) 错误 抛出带 status 属性的错误,交给错误中间件
ctx.body / ctx.status 响应体 / 状态码 赋值即产出响应
ctx.req / ctx.res Node.js 原生对象 逃生舱口,一般不要用
ctx.params 路径参数 路由匹配后才有,例如 /articles/:id 的 id

为什么要合并成一个对象?Express 的中间件签名是 (req, res, next),写响应要 res.end(),忘了写请求就挂住。Koa 的签名是 (ctx, next):中间件只负责修改 ctx,框架在整条链跑完之后统一把 ctx 渲染成 HTTP 响应。「改响应」变成「改状态」,中间件才可能被自由组合——这正是洋葱模型能成立的前提。

另外两个容易踩的事实:

  • Koa 没有内置的请求体解析,ctx.request.body 默认是 undefined,必须自己装 bodyparser;
  • ctx.request.body 与 ctx.query、ctx.params 不是一回事,后两者由 Koa 自带的 request 对象解析,前者来自 bodyparser 中间件。

二、洋葱模型:注册顺序就是入程顺序

先看最核心的一段。下面三个中间件注册顺序是 A → B → C:

app.use(async (ctx, next) => {
  console.log('A 入');
  await next(); // 把控制权交给下一层,等它跑完再回来
  console.log('A 出');
});

app.use(async (ctx, next) => {
  console.log('B 入');
  await next();
  console.log('B 出');
});

app.use(async (ctx, next) => {
  console.log('C 入');
  await next();
  ctx.body = '最内层产出的响应体'; // 响应体由最内层设置
  console.log('C 出');
});

执行顺序是:

A 入 → B 入 → C 入 → C 出 → B 出 → A 出

前半段叫入程,按注册顺序从外往里;后半段叫回程,按相反顺序从里往外。之所以叫洋葱,是因为 await next() 把链条劈成了两半——你在 next() 之前写的是「请求进来时做的事」,在 next() 之后写的是「响应出去前做的事」。

跑一下看(本文配套示例工程的 scripts/onion-order.ts):

# 启动一个只打印顺序的最小应用,实测输出见下
npx tsx scripts/onion-order.ts

实测输出:

A 入
B 入
C 入
C 出
B 出
A 出
响应体: 最内层产出的响应体

回程不是可有可无的装饰。它至少承担四件事:

  1. 记耗时:入程记开始时间,回程算差值,这就是访问日志里的 ms;
  2. 统一错误兜底:在 next() 外面包一层 try/catch,内层任何地方抛的异常都会被它接住;
  3. 改写响应:回程阶段外层仍能覆盖 ctx.body。实测里即使内层已经把 body 设成 inner,外层回程再设一次 ctx.body = 'outer',最终客户端拿到的仍是 outer;
  4. 释放资源:数据库事务、临时文件、锁,都应该在回程里释放。

第 3 条值得单独强调:内层已经把响应体设好了,外层在回程里仍然可以再赋值,最终响应由最后一次赋值决定。这条能力常用来做统一包装——各路由返回原始数据,最外层的格式化中间件在回程里统一套上 { ok: true, data } 结构。代价是这类中间件必须放在最外层,否则包不到最内层路由的响应。

三、compose 是怎么把 next 串起来的

「洋葱」不是 Koa 的魔法,是 koa-compose 干的事。Koa 3.2.1 依赖的是 koa-compose@4.1.0,它的全部实现只有四十多行:

function compose(middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!');
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!');
  }

  return function (context, next) {
    let index = -1; // 最后一次被调用的中间件下标
    return dispatch(0);

    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 传给当前中间件
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err); // 同步抛错也变成 rejected promise
      }
    }
  };
}

逐行对照着看,洋葱模型的四个关键点全在这里:

代码 解释了
dispatch.bind(null, i + 1) 每个中间件拿到的 next 都是「下一层」的入口,所以注册顺序即入程顺序
index 守卫 同一个 next() 调用两次会返回 next() called multiple times,防止链路被重复执行
Promise.resolve(fn(...)) 中间件返回同步值或 Promise 都能接住,Koa 不强制你必须 async
try/catch 包住调用 中间件里同步 throw 也会变成 rejected promise,不会绕过错误中间件

还有两处细节值得单独说。

其一,链条的终点是可选的:Koa 调 compose(this.middleware) 时并没有给最外层传入 next,所以最外层中间件调用 next() 会让 dispatch 直接返回 Promise.resolve(),整条链就此结束;随后 Koa 再用 respond(ctx) 把 ctx 渲染成 HTTP 响应。换个角度看,「洋葱芯」不是某个中间件,而是「所有中间件都执行完」这个状态本身。

其二,try/catch 包住 fn(context, ...) 意味着中间件里同步 throw 也会被转成 rejected promise。所以错误中间件不需要区分同步异常和异步异常,await next() 一句就能同时兜住两者——这也是为什么错误中间件必须 await:不 await 的话,同步抛出的异常根本进不了 catch 分支。

实测这个守卫:

Error: next() called multiple times
    at dispatch (node_modules/koa-compose/index.js:36:45)

同一个 next() 调两次,响应直接变成 500 Internal Server Error。这类错误在开发阶段靠一次 await next() 就能看出来,线上则表现为随机 500。

四、动手:搭一条真实的中间件链

下面这一节是可以照抄的完整工程。为了让结论可验证,我把它拆成六个步骤,每步都有对应文件。

步骤 1:初始化工程

# 建目录并初始化 package.json
mkdir koa-onion-demo && cd koa-onion-demo
npm init -y

# 安装运行时依赖(版本为本文实测时的当前版本)
npm install koa@3.2.1 @koa/router@15.7.0 @koa/bodyparser@6.1.0 @koa/cors@5.0.0

# 安装开发依赖:tsx 负责直接跑 TypeScript,tsc 负责类型检查
npm install -D typescript tsx @types/koa @types/koa__cors @types/node
{
  "name": "koa-onion-demo",
  "version": "1.0.0",
  "private": true,
  "description": "Koa 洋葱模型示例工程",
  "type": "module",
  "scripts": {
    "start": "tsx src/server.ts",
    "dev": "tsx watch src/server.ts",
    "demo:onion": "tsx scripts/onion-order.ts",
    "test": "tsx --test test/app.test.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@koa/bodyparser": "^6.1.0",
    "@koa/cors": "^5.0.0",
    "@koa/router": "^15.7.0",
    "koa": "^3.2.1"
  },
  "devDependencies": {
    "@types/koa": "^3.0.3",
    "@types/koa__cors": "^5.0.1",
    "@types/node": "^26.6.6",
    "tsx": "^4.23.15",
    "typescript": "^7.0.2"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "scripts", "test"]
}

这里有两个容易踩的点:一是 "type": "module",少了它 Node.js 会把 .ts 当 CommonJS 解析,import 直接报语法错;二是 module 与 moduleResolution 都用 NodeNext,此时相对导入必须写 .js 后缀(./middleware.js),编译产物和运行时才对得上。

步骤 2:写三个可复用的中间件

src/middleware.ts:

import type { Middleware } from 'koa';

/**
 * 请求 ID:透传上游传来的 x-request-id,没有就自己生成。
 * 注册在最外层,后面的中间件(含错误处理)都能从 ctx.state 读到它。
 */
export const requestId: Middleware = async (ctx, next) => {
  const id = ctx.get('x-request-id') || `req-${crypto.randomUUID().slice(0, 8)}`;
  ctx.state.requestId = id;
  ctx.set('x-request-id', id); // 回写响应头,前端报障时能直接给出 ID
  await next();
};

/**
 * 统一错误处理:注册在最外层,兜住所有下游中间件抛出的异常。
 */
export const errorHandler: Middleware = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    const e = err as Error & { status?: number; expose?: boolean };
    // ctx.throw(404, '...') 抛出的错误带 status 属性
    const status = e.status && e.status >= 400 ? e.status : 500;
    ctx.status = status;
    ctx.body = {
      ok: false,
      error: status === 500 ? '服务器内部错误' : e.message,
      requestId: ctx.state.requestId,
    };
    // 主动把异常交给进程级兜底(app.on('error'))
    ctx.app.emit('error', err, ctx);
  }
};

/**
 * 访问日志 + 耗时:await next() 之前记开始时间,回程时算耗时。
 */
export const logger: Middleware = async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} ${ctx.status} ${ms}ms rid=${ctx.state.requestId}`);
};

三个中间件各自代表一类典型形态:

  • requestId 是入程型:只做前置准备,不需要回程;
  • errorHandler 是包裹型:try 包住 await next(),把下游所有异常收进一个出口;
  • logger 是前后型:入程记起点,回程算终点。

注意 errorHandler 里读得到 ctx.state.requestId,所以它的注册位置必须在 requestId 之后——顺序即依赖关系。

步骤 3:组装中间件栈与路由

src/app.ts:

import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';
import cors from '@koa/cors';
import { errorHandler, logger, requestId } from './middleware.js';

export function createApp() {
  const app = new Koa();
  const router = new Router({ prefix: '/api' });

  router.get('/health', (ctx) => {
    // 健康检查只返回进程状态,不做重活
    ctx.body = { ok: true, uptime: Math.round(process.uptime()) };
  });

  router.post('/articles', (ctx) => {
    // bodyParser 已经把 JSON 请求体解析进 ctx.request.body
    const { title } = ctx.request.body as { title?: string };
    if (!title) ctx.throw(400, 'title 不能为空');
    ctx.status = 201;
    ctx.body = { ok: true, data: { id: 1, title } };
  });

  router.get('/articles/:id', (ctx) => {
    ctx.throw(404, '文章不存在'); // 交给最外层的错误中间件统一处理
  });

  router.get('/boom', () => {
    throw new Error('故意炸一个未包装的异常');
  });

  // ↓↓↓ 注册顺序 = 入程顺序,从外到内 ↓↓↓
  app.use(requestId); // 1. 最外层,先给这次请求一个 ID
  app.use(errorHandler); // 2. 兜底,必须包住后面所有中间件
  app.use(logger); // 3. 日志与耗时
  app.use(
    cors({
      // 回显请求来源,跨域联调时不用写死
      origin: (ctx) => ctx.get('origin') || '*',
    }),
  ); // 4. 跨域
  app.use(bodyParser()); // 5. 请求体解析
  app.use(router.routes()); // 6. 路由
  app.use(router.allowedMethods()); // 7. 405 / 预检响应

  // 8. 路由之后:只有没有命中任何路由的请求才会走到这里
  app.use(async (ctx) => {
    ctx.set('x-fallback', 'hit'); // 用于验证"路由之后的中间件什么时候执行"
    ctx.throw(404, '接口不存在');
  });

  return app;
}

这一段的顺序不是随便排的,四条约束各自有代价:

约束 违反后的症状
错误中间件必须包住全部业务中间件 异常直接冒到 Koa 默认处理器,响应体变成纯文本 Internal Server Error,格式不统一
bodyParser 必须在读 ctx.request.body 的路由之前 ctx.request.body 是 undefined,接口永远收不到数据
cors 必须在路由之前 命中路由的请求不会被放行,浏览器报跨域,但后端日志里其实收到过请求
路由之后的中间件只在未命中路由时执行 放在路由之后的兜底逻辑,对正常接口完全不生效

步骤 4:启动

src/server.ts:

import { createApp } from './app.js';

const port = Number(process.env.PORT ?? 3000);
const app = createApp();

// 进程级兜底:即使异常没被中间件捕获,也会走到这里
app.on('error', (err: Error, ctx) => {
  console.error(`[app error] ${err.message} path=${ctx?.path ?? '-'}`);
});

const server = app.listen(port, () => {
  console.log(`服务已启动: http://127.0.0.1:${port}`);
});

app.listen(port) 返回的是 Node.js 的 http.Server 实例,需要优雅关闭(关闭数据库连接、等待在途请求)时拿它就行。

app.on('error', cb) 是进程级兜底,和错误中间件不是一回事:错误中间件只接得住它下游抛出的异常,而 app.on('error') 能接住整个应用生命周期里的错误(包括错误处理本身出错)。这里有个容易忽略的细节:Koa 3.x 在 callback() 里会检查当前有没有 error 监听器,只有在没有监听器时才注册自己的默认 onerror。也就是说,一旦你写了 app.on('error', cb),Koa 就不再挂默认处理器,未对外暴露的异常栈也不会再自动打到 stderr——打印责任转移给了你的回调。实测:注册了 app.on('error') 的示例里,/api/boom 只输出一行结构化日志;不注册时,同一个异常会连同堆栈一起打到 stderr。

步骤 5:把洋葱顺序变成可见的脚本

scripts/onion-order.ts:

import Koa from 'koa';
import type { AddressInfo } from 'node:net';

// 三个只做打印的中间件,用来观察洋葱模型的进出顺序
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('A 入');
  await next();
  console.log('A 出');
});

app.use(async (ctx, next) => {
  console.log('B 入');
  await next();
  console.log('B 出');
});

app.use(async (ctx, next) => {
  console.log('C 入');
  await next();
  ctx.body = '最内层产出的响应体'; // 响应体由最内层设置
  console.log('C 出');
});

// 监听 0 号端口 = 由系统分配一个空闲端口,避免和本机已有服务冲突
const server = app.listen(0);
const { port } = server.address() as AddressInfo;

const res = await fetch(`http://127.0.0.1:${port}/`);
console.log('响应体:', await res.text());

server.close();

步骤 6:用测试把结论钉死

test/app.test.ts:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import Koa from 'koa';
import { createApp } from '../src/app.js';

/** 起一个随机端口的服务,跑完用例再关掉 */
async function withServer(fn: (base: string) => Promise<void>) {
  const app = createApp();
  const server = app.listen(0);
  const { port } = server.address() as AddressInfo;
  try {
    await fn(`http://127.0.0.1:${port}`);
  } finally {
    server.close();
  }
}

test('洋葱模型:入程按注册顺序,回程按相反顺序', async () => {
  const order: string[] = [];
  const app = new Koa();

  app.use(async (ctx, next) => {
    order.push('A-in');
    await next();
    order.push('A-out');
  });
  app.use(async (ctx, next) => {
    order.push('B-in');
    await next();
    order.push('B-out');
  });
  app.use(async (ctx, next) => {
    order.push('C-in');
    await next();
    ctx.body = 'ok';
    order.push('C-out');
  });

  const server = app.listen(0);
  const { port } = server.address() as AddressInfo;
  await fetch(`http://127.0.0.1:${port}/`);
  server.close();

  assert.deepEqual(order, ['A-in', 'B-in', 'C-in', 'C-out', 'B-out', 'A-out']);
});

test('错误中间件把 ctx.throw(404) 转成统一 JSON', async () => {
  await withServer(async (base) => {
    const res = await fetch(`${base}/api/articles/1`);
    assert.equal(res.status, 404);
    const body = await res.json();
    assert.equal(body.ok, false);
    assert.equal(body.error, '文章不存在');
    assert.ok(body.requestId, '响应里应带上请求 ID');
  });
});

test('bodyParser 之后才能读到 ctx.request.body', async () => {
  await withServer(async (base) => {
    const res = await fetch(`${base}/api/articles`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ title: '洋葱模型' }),
    });
    assert.equal(res.status, 201);
    const body = await res.json();
    assert.equal(body.data.title, '洋葱模型');
  });
});

test('命中路由后,注册在路由之后的中间件不会执行', async () => {
  await withServer(async (base) => {
    const hit = await fetch(`${base}/api/health`);
    assert.equal(hit.status, 200);
    assert.equal(hit.headers.get('x-fallback'), null, '命中路由时不应触发路由后的中间件');

    const miss = await fetch(`${base}/api/nope`);
    assert.equal(miss.status, 404);
    assert.equal(miss.headers.get('x-fallback'), 'hit', '未命中路由时才会走到路由之后的中间件');
  });
});

test('CORS 中间件回显请求来源', async () => {
  await withServer(async (base) => {
    const res = await fetch(`${base}/api/health`, {
      headers: { origin: 'http://localhost:5173' },
    });
    assert.equal(res.headers.get('access-control-allow-origin'), 'http://localhost:5173');
  });
});

test('ctx.body 与 ctx.status 的赋值顺序决定最终状态码', async () => {
  const cases: Array<{ name: string; handler: (ctx: Koa.Context) => void; status: number }> = [
    { name: '只赋 body', handler: (ctx) => { ctx.body = { ok: true }; }, status: 200 },
    { name: '先 status 再 body', handler: (ctx) => { ctx.status = 404; ctx.body = { ok: false }; }, status: 404 },
    { name: '先 body 再 status', handler: (ctx) => { ctx.body = { ok: false }; ctx.status = 404; }, status: 404 },
    { name: '204 会清掉 body', handler: (ctx) => { ctx.body = 'x'; ctx.status = 204; }, status: 204 },
  ];

  for (const c of cases) {
    const app = new Koa();
    app.use((ctx) => { c.handler(ctx); });
    const server = app.listen(0);
    const { port } = server.address() as AddressInfo;
    const res = await fetch(`http://127.0.0.1:${port}/`);
    const text = await res.text();
    server.close();
    assert.equal(res.status, c.status, `${c.name} 期望状态码 ${c.status}`);
    if (c.status === 204) assert.equal(text, '', '204 不应带响应体');
  }
});

五、怎么验证:三条命令与六个断言

# 看洋葱进出顺序(实测输出见第二节)
npm run demo:onion

# 跑自动化断言(Node.js 内置 test runner,无需额外测试框架)
npm test

# 类型检查
npm run typecheck

npm test 的实测输出:

✔ 洋葱模型:入程按注册顺序,回程按相反顺序 (51.9938ms)
✔ 错误中间件把 ctx.throw(404) 转成统一 JSON (24.4317ms)
✔ bodyParser 之后才能读到 ctx.request.body (44.6727ms)
✔ 命中路由后,注册在路由之后的中间件不会执行 (19.2191ms)
✔ CORS 中间件回显请求来源 (7.7129ms)
✔ ctx.body 与 ctx.status 的赋值顺序决定最终状态码 (36.9447ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

手工验证则用这几条 PowerShell 命令(服务启动后执行):

# 健康检查,应返回 ok: true
Invoke-RestMethod http://127.0.0.1:3000/api/health

# POST 一个 JSON,bodyParser 解析后应回显 title
Invoke-RestMethod http://127.0.0.1:3000/api/articles -Method Post -ContentType 'application/json' -Body '{"title":"onion-model"}'

# 未命中路由:应返回 404 与统一错误 JSON
try { Invoke-RestMethod http://127.0.0.1:3000/api/nope } catch { $_.ErrorDetails.Message }

# 命中路由的请求:响应头里不应出现 x-fallback
(Invoke-WebRequest http://127.0.0.1:3000/api/health -UseBasicParsing).Headers['x-fallback']

实测响应体:

{"ok":true,"data":{"id":1,"title":"onion-model"}}
{"ok":false,"error":"接口不存在","requestId":"req-1f6c2fac"}

最后一条命令返回空值,因为命中路由后请求根本没走到路由之后的兜底中间件——这正是那条断言想固定下来的行为。

六、进阶与边界

路由命中后为什么不再往下走

router.routes() 内部是一个中间件:匹配到路由就执行路由处理器,并且不会再调用 next();匹配不到才把控制权交回给后面的中间件。所以注册在路由之后的东西,对命中路由的请求完全不生效。

这条行为有个不太直观的副作用:router.allowedMethods() 的 405 判定发生在它的回程(await next() 之后),而你的兜底中间件如果抛异常,这个判定就被整个跳过了。实测三种写法(对只注册了 POST /api/articles 的路由发 GET):

路由之后的兜底写法 实际状态码 Allow 头 响应体
ctx.throw(404, '接口不存在')(抛异常) 404 无 接口不存在
ctx.status = 404; ctx.body = {...}(只设值) 405 POST 自己的 JSON
不放兜底中间件 405 POST Method Not Allowed

本文示例选择第一种,代价是拿不到 405,好处是所有 404 的响应体格式统一。若你的 API 更在意方法语义,把兜底中间件改成只设状态码与响应体即可——但要注意此时状态码会被 allowedMethods() 改成 405,而响应体仍是你自己那份 JSON。

ctx.body 与 ctx.status 的赋值顺序

Koa 的 ctx.body 赋值器里有这么一段:如果此前没有显式设置过状态码,就推断为 200;而 ctx.status = 404 会被记为「显式设置」,之后的 ctx.body 赋值不再覆盖它。反过来,如果状态码是 204 或 304,已有的 body 会被清空(这两种状态码按协议不允许带响应体)。实测结论:

写法 最终状态码 响应体
只赋 ctx.body 200 有
先 ctx.status = 404 再赋 ctx.body 404 有
先赋 ctx.body 再 ctx.status = 404 404 有
ctx.body = 'x' 再 ctx.status = 204 204 空

实践建议:状态码和响应体一起写,别指望赋值顺序帮你纠正。写 ctx.status = 404 再写 ctx.body = {...} 是最不容易出错的顺序。

ctx.throw 与 ctx.state

ctx.throw(404, '文章不存在') 抛出的错误带 status 属性(http-errors 创建),错误中间件里用 err.status 就能还原状态码,用 err.message 还原对外文案。注意对外文案要区分:4xx 的 message 可以直接回给客户端,5xx 建议统一成「服务器内部错误」,真实原因只写进日志。

ctx.state 是请求级共享数据,适合放 requestId、当前用户、租户 ID 这类「本次请求内跨中间件传递」的值。它的生命周期严格等于一次请求,不要拿它当全局变量用。

中间件的两种写法

// 前置型:只做入程的事,不需要回程
app.use(async (ctx, next) => {
  ctx.set('x-app-version', '1.0.0');
  await next(); // 仍然要调用 next,否则后面的中间件不会执行
});

// 终止型:直接给响应,不再往下走
app.use(async (ctx) => {
  if (ctx.path === '/favicon.ico') {
    ctx.status = 204; // 到此为止,不再调用 next()
    return;
  }
  await next();
});

把中间件写成工厂函数

中间件需要参数时(超时时长、要求的请求头、允许的角色),标准做法是写成工厂函数:启动时调用工厂拿到真正的中间件,再注册它。

import type { Middleware } from 'koa';

/**
 * 要求请求带上指定请求头的中间件工厂。
 * 工厂在启动时调用一次,返回的函数才是被注册的中间件。
 */
export function requireHeader(name: string): Middleware {
  return async (ctx, next) => {
    if (!ctx.get(name)) {
      ctx.throw(400, `缺少请求头 ${name}`);
    }
    await next();
  };
}

// 注册时把参数固定下来
app.use(requireHeader('x-tenant-id'));

有两个坑:别把工厂本身注册进去——app.use(requireHeader) 传进去的是工厂而不是中间件,Koa 会拿 (ctx, next) 去调用它,参数完全对不上;也别在中间件内部再读配置——那等于每个请求都读一次,配置应该和工厂一样在启动时确定。

链越长,回程越深

每一层中间件都至少多一次 Promise 跳转,回程的深度与链长一致。把「一个判断」单独拆成一层中间件是常见的时间浪费——中间件是给「请求前后都要做事」的能力用的,不是给普通函数调用用的。判别标准很简单:这个逻辑需要在回程里做事吗?需要就写成中间件,不需要就留在业务代码里。

忘记 await next() 会怎样

这是最高频也最难查的一类 bug。实测对比(内层中间件里有一个 await 模拟 IO):

写法 实际执行顺序 最终响应体
next()(少了 await) outer-in → outer-out outer(内层的 body 被外层覆盖,且时机不确定)
await next() outer-in → inner → outer-out outer(外层在回程里主动覆盖内层 body)

两者的最终 body 看起来一样,差别在执行顺序:少了 await,外层的收尾代码会在内层还在跑的时候就执行,回程逻辑、事务提交、耗时统计全部错位。判断标准很简单:一个中间件只要写了 next(),就必须 await 它。

七、选型与对比

同一件事的三种中间件模型

维度 Koa 洋葱模型 Express 传统中间件 Koa + koa-compose 手动组合
中间件签名 async (ctx, next) => {} (req, res, next) => {} async (ctx, next) => {}
请求/响应对象 合并成一个 ctx req 与 res 分开 合并成一个 ctx
执行顺序 入程正序、回程倒序 单向,层层 next() 往下 由 compose 决定,同 Koa
响应时机 链跑完后由框架统一输出 中间件自己 res.end() 链跑完后统一输出
回程能力 有,天然支持 没有,得靠包装 next 有
典型用途 日志、耗时、事务、统一错误 老项目兼容 教学与自定义组合

Koa 的取舍很清楚:牺牲「直接操作 res」的自由,换来中间件的前后对称与统一渲染。

生态包选型

需求 推荐 说明
Koa 本体 koa@3.x 要求 Node.js ≥ 18;本文实测 3.2.1
路由 @koa/router koa-router(无作用域)已在 npm 标记 deprecated,官方指引迁移到 @koa/router;两者 API 相同,routes() / allowedMethods() 可直接替换
请求体解析 @koa/bodyparser koa-bodyparser(无作用域)最后一次发布停在 4.4.1,@koa/bodyparser 是带类型声明的续作
跨域 @koa/cors 配合 router.allowedMethods() 处理预检
组合器 koa-compose Koa 已内置依赖,一般不需要单独装

无作用域的 koa-router 与 koa-bodyparser 仍能装、能跑,本文所有 API 在两者上都成立;选带作用域的版本是为了拿到持续维护与类型声明。

一份推荐的注册顺序

位置 中间件 作用 排在这儿的理由
1 请求 ID / trace 给本次请求一个标识 必须在最外层,错误处理也要用它
2 统一错误处理 try/catch 包住全链 必须包住所有业务中间件
3 访问日志 / 耗时 记方法、路径、状态码、耗时 回程统计耗时,需要在业务外面
4 跨域 放行预检与来源 必须在路由之前,否则命中路由的请求不放行
5 请求体解析 解析 JSON / 表单 必须在读 ctx.request.body 的路由之前
6 路由 分发到具体接口 命中即终止,不再往下走
7 allowedMethods 405 与预检 紧跟路由,回程里改状态码
8 404 兜底 未命中路由时的统一响应 只对未命中路由的请求生效

八、排错清单:问题 → 原因 → 修法

问题 原因 修法
POST 接口拿到的 ctx.request.body 是 undefined 没注册 bodyparser,或注册在读 body 的路由之后 把 bodyParser() 提到路由之前;实测未注册时 body 为 undefined
日志打了两遍,或顺序是 入 出 入 出 同一个中间件注册了两次,或某个中间件内部又调了一遍 app.use 检查注册点;app.use 只在启动时调用一次
回程代码比预期早执行 写了 next() 却漏了 await 补上 await next();实测少了 await 时顺序变成 outer-in → outer-out
偶发 500,日志里是 next() called multiple times 同一个 next() 被调用两次 合并成一次调用;这是 compose 的保护机制
跨域报错,但后端日志里收到过请求 CORS 中间件注册在路由之后 把它挪到路由之前;命中路由的请求不会再往下走
错误响应体格式不统一,有的接口返回纯文本 异常冒到了 Koa 默认处理器 用最外层 try/catch 中间件统一处理,并确认它包住了业务中间件
404 的响应体一会儿是 JSON、一会儿是 Not Found 路由后的兜底逻辑写法不一致 统一用 ctx.throw(404, '...'),交给错误中间件渲染
期望 405,实际是 404 兜底中间件抛了异常,跳过了 allowedMethods() 的回程判定 兜底改成设置 ctx.status 与 ctx.body,不抛异常
设置了 ctx.status = 204,响应体却没了 204/304 按协议不允许带 body,Koa 会清空 204 就别设 body;需要「成功但无内容」就用 204,需要返回数据就用 200
ctx.body 设置了但响应是旧的 外层中间件在回程里覆盖了 body 检查外层中间件的回程代码,确认覆盖是有意为之
改了 ctx.state 但下游读不到 中间件注册顺序反了 ctx.state 的读写有先后依赖,状态生产方必须注册在消费方之前

九、给自己项目定一份中间件约定

中间件出问题几乎都不是逻辑写错,而是位置写错。因为位置错了之后,代码照样能跑,只是对某些请求不起作用——这类问题在联调时容易被忽略,等到线上才暴露。所以比起口头约定,更有效的是把下面这份清单变成自动化检查或 code review 的固定项。

把这几条贴进 code review 清单,比事后排查便宜:

  • 所有中间件都是 async (ctx, next) => {},出现在文件顶部的注释里写清它的类型(前置 / 后置 / 包裹 / 终止);
  • 出现了 next() 就一定跟着 await;
  • 错误处理中间件注册在最外层(在业务中间件之前);
  • bodyparser 在所有读 ctx.request.body 的路由之前;
  • 跨域、日志、请求 ID 这类全局能力都在路由之前;
  • 路由之后只放「未命中路由」的兜底逻辑,不放通用能力;
  • 每个自定义中间件至少有一条自动化断言覆盖它的位置或副作用;
  • 对外文案与内部错误分离:4xx 回 err.message,5xx 统一回「服务器内部错误」,真实原因只进日志。

参考资料

  • Koa 官方文档 —— 中间件、ctx API、app.on('error') 的官方说明
  • koa-compose —— compose 的完整实现,next 链与 index 守卫的来源
  • koa-bodyparser —— 请求体解析中间件(无作用域版本,已停止更新,续作见 @koa/bodyparser)
  • koa-router —— 路由中间件(无作用域版本已 deprecated,续作见 @koa/router)

标签

  • #Koa
  • #middleware
  • #onion-onion-model
  • #async

相关课程