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 出
响应体: 最内层产出的响应体
回程不是可有可无的装饰。它至少承担四件事:
- 记耗时:入程记开始时间,回程算差值,这就是访问日志里的
ms; - 统一错误兜底:在
next()外面包一层try/catch,内层任何地方抛的异常都会被它接住; - 改写响应:回程阶段外层仍能覆盖
ctx.body。实测里即使内层已经把 body 设成inner,外层回程再设一次ctx.body = 'outer',最终客户端拿到的仍是outer; - 释放资源:数据库事务、临时文件、锁,都应该在回程里释放。
第 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 官方文档 —— 中间件、
ctxAPI、app.on('error')的官方说明 - koa-compose —— compose 的完整实现,
next链与index守卫的来源 - koa-bodyparser —— 请求体解析中间件(无作用域版本,已停止更新,续作见
@koa/bodyparser) - koa-router —— 路由中间件(无作用域版本已 deprecated,续作见
@koa/router)