当 API 的资源和错误契约稳定下来,路由数量通常也会迅速增加。日志、鉴权、校验、限流和错误转换如果散落在每个处理器里,规则迟早会漂移;中间件正是为了把这些横切逻辑放回同一条请求管道。
但中间件抽走重复代码的同时,也接管了请求的控制权。next() 早一行或晚一行,结果可能不只是重复响应,而是直接越过安全边界。
那次故障最让人困惑的地方,是它不像故障。
一个需要登录的导出接口,大部分时间正常返回 200,偶尔却会在同一秒里出现两条互相矛盾的记录:访问日志说请求成功,错误日志紧接着又报 Cannot set headers after they are sent to the client。更危险的是,个别无效令牌也拿到了导出结果。
我最先怀疑的是令牌缓存。接着查了数据库连接池、网关重试和负载均衡,甚至对比了出问题请求落到的进程。它们都没有解释一个事实:同一个 requestId 下,Controller 的日志总比“鉴权失败”早几十毫秒。
把日志按毫秒排开以后,顺序非常刺眼:
10:42:18.031 auth: 开始验证令牌
10:42:18.033 controller: 开始生成导出结果
10:42:18.047 response: 200
10:42:18.069 auth: 令牌无效,准备返回 401
10:42:18.070 error: Cannot set headers after they are sent关键转折不在缓存,也不在网络,而在一段看起来很顺眼的中间件:
// 错误示例:验证尚未完成,控制权已经交给下游
async function requireUser(req, res, next) {
const verifying = verifyToken(req.get('authorization'));
next();
const user = await verifying;
if (!user) {
return res.status(401).json({ message: '令牌无效' });
}
req.user = user;
}作者的直觉是“先启动验证,下面再等结果”,可 next() 不是一句普通的流程提示。它把请求的控制权交了出去。Controller 一旦开始查库、写响应,鉴权中间件就无法再把那次交接收回来。
为什么问题只是“偶尔”出现?因为这里暗中制造了竞速:令牌验证快时,Controller 使用身份前或许已经得到结果;验证慢时,下游先运行,甚至先完成响应。延迟的微小变化,决定了安全检查是否来得及。
修复其实只有几行:先得出鉴权结论,通过后写入可信身份,再交接控制权。
async function requireUser(req, res, next) {
const user = await verifyToken(req.get('authorization'));
if (!user) {
return res.status(401).json({ message: '令牌无效' });
}
req.user = user;
return next();
真正值得记住的不是这次漏写或早写了 next(),而是排查时暴露出的认知缺口:我们把中间件当成“请求前顺手跑一下的函数”,却没有把它当成一套控制权协议。顺序、短路、回栈和错误传播,全部由这套协议决定。
我现在更愿意把一次请求想成一张沿处理链传递的工单。日志中间件给它补上 requestId 和开始时间;鉴权中间件确认操作者;校验中间件把外部输入整理成可信数据;路由最后把它交给 Controller。
但这不是无条件向前滚动的流水线。每一环拿到数据的同时,也拿到一次控制流决策权:
next,允许下一环运行。
这也是责任链模式在 Web 框架里的具体形态:框架保存一组遵守相同调用约定的函数,为每次请求建立上下文,再从第一项开始调度。链上的处理者不知道下一位是谁,只知道怎样把控制权交还给调度器。
事故之后,我给中间件代码加了一条很朴素的审查规则:每个分支都要能回答,请求是继续,还是到此响应。 如果答案是“都没有”,客户端会一直等;如果答案是“两个都做”,就容易重复执行业务或重复写响应。
中间件真正抽象的不是几段重复代码,而是控制流。注册顺序展示依赖,next 表示交接,return 表示当前函数结束,错误边界则规定失败最终落在哪里。
排查这类问题时,我也见过另一种极端:既然中间件好用,就把权限、库存、价格计算全塞进去。结果路由看起来很薄,控制链却变成了另一套隐形业务系统。
判断边界时,我会先问“这条规则覆盖多少请求”:
身份认证和业务授权尤其容易混在一起。验证令牌并得到当前用户,是通用身份认证;“这个用户能不能取消这笔订单”还取决于订单归属和状态,通常不该被藏进一个看似通用的 auth 中间件。
next 传递的不是请求,而是后续处理权Express 的中间件常写成这样:
function attachRequestId(req, res, next) {
req.requestId = crypto.randomUUID();
next();
}Koa 把请求与响应包装进 ctx,并让 next() 返回代表整个下游过程的 Promise:
async function attachRequestId(ctx, next) {
ctx.state.requestId = crypto.randomUUID();
await next();
}两段代码都把数据写入本次请求的共享上下文,差别在交接之后。Express 的 next() 通知 Router 继续分派,却没有提供“等待全部异步下游完成”的 Promise 契约;Koa 的 await next() 会暂停当前中间件,等下游完成或失败后再回到这里。
还有一个常见误解:Express 里写 return next(),并不会把它变成 Koa 的 await next()。这里的 return 只是立刻结束当前 JavaScript 函数,避免交接后又继续执行本函数的代码。这个习惯仍然很有价值,因为它能让控制分支更清楚。
鉴权失败时,请求不该进入 Controller:
function requireToken(req, res, next) {
const token = req.get('authorization');
if (!token) {
return res.status(401).json({
code: 'UNAUTHENTICATED',
message: '缺少访问令牌',
});
}
return next();
res.json() 完成响应,return 结束当前函数,二者分工不同。反过来,如果代码既不调用 next(),也没有结束响应,处理链就停在原地。进程仍然健康,监控可能也没有异常,但客户端只能等到超时。
不要“先调用 next,再决定是否拒绝”。控制权交给下游以后,Controller 可能已经读写数据库或开始发送响应。鉴权、限流和输入校验都应在确认通过后再放行。
next 只能交接一次一次中间件调用只能把控制权交给下游一次。重复调用相当于要求同一段下游逻辑再跑一遍,轻则触发组合器保护,重则重复写库或重复发送响应。
// 错误示例:debug 分支会导致 next 被调用两次
async function broken(ctx, next) {
if (ctx.query.debug) {
await next();
}
await next();
}正确做法是让分支互斥,或者在第一次交接后立即结束当前函数。这个约束不是代码风格问题,而是控制权不能被重复授予。
那次事故让我开始用日志顺序验证自己的判断,而不是只看代码缩进。下面三个 Koa 中间件已经足够把洋葱模型跑出来:
app.use(async (ctx, next) => {
console.log('A 进入');
await next();
console.log('A 返回');
});
app.use(async (ctx, next) => {
console.log('B 进入');
await next
输出顺序是:
A 进入
B 进入
C 路由
B 返回
A 返回
框架没有神秘地把 A、B 又执行一遍。A 等待 B 返回的 Promise,B 等待 C;C 完成后,B 的异步函数继续,最后 A 继续。所谓“洋葱”,就是嵌套异步函数在进入和回栈时呈现出的对称顺序。
这个理解能直接指导日志和错误处理。外层在 await next() 前记录开始时间,在 finally 中记录结束时间,就覆盖了下游的完整生命周期:
app.use(async (ctx, next) => {
const startedAt = performance.now();
try {
await next();
} finally {
const duration = performance.now() - startedAt;
console.log(`${ctx.method} ${ctx.
我倾向于把清理和计时放进 finally。只写在 await next() 后面,一旦下游抛错,那一行就不会执行,偏偏最需要记录的失败请求反而消失了。
await,错误就会逃出边界下面这段代码看似有 try/catch,实际上接不住下游稍后发生的 Promise 拒绝:
// 错误示例:next 返回的 Promise 没有进入当前等待链
app.use(async (ctx, next) => {
try {
next();
} catch (error) {
ctx.status = 500;
}
});try 块在 next() 返回 Promise 后就结束了。将来的异步失败不会倒流进已经离开的同步 try/catch。写成 await next(),才把下游拒绝纳入当前异步函数的错误边界。
Express 调用 next() 后会立刻分派下一项。如果下游全是同步代码,调用栈结束时自然也会回到上游,所以 next() 后面的同步语句同样可能在下游之后执行。真正拉开差距的是异步完成时机:
app.use((req, res, next) => {
console.log('A 进入');
next();
console.log('A 的 next 之后');
});
app.use(async (req, res) => {
console.log('B 进入');
await new Promise
通常会先看到 A 的 next 之后,再看到 B 完成。因此,在 Express 里记录响应完成后的访问日志,不能假设 next() 返回就代表响应已经结束。更可靠的做法是监听响应事件:
app.use((req, res, next) => {
const startedAt = performance.now();
res.once('finish', () => {
const duration = performance.now() - startedAt;
console.log(req.method, req.originalUrl, res.statusCode, duration);
});
next();
});finish 表示响应头和响应体的最后一部分已经交给操作系统发送,不表示客户端已经完整接收。若还要识别客户端提前断开,可以结合 close 事件和 res.writableEnded 判断,而不是把两个事件都当作“成功完成”。
事故复盘时,我们把中间件注册表直接放进了检查清单。原因很简单:框架不会根据函数名猜谁应该先执行。
app.use(requestId);
app.use(accessLogger);
app.use(express.json({ limit: '1mb' }));
app.use('/api', apiRouter);
app.use(notFoundHandler);
app.use(errorHandler);这个顺序隐含了真实依赖:日志需要先拿到 requestId;读取 req.body 的校验器依赖 body 解析;404 必须等路由都没匹配后再生成;Express 的四参数错误处理器要位于可能传出错误的中间件之后。
顺序错了,代码可能仍能启动,却会悄悄改变语义:
next(err) 传来的错误。路由内部还有一条更短的子链:
router.post(
'/orders',
requireUser,
validateCreateOrder,
createOrderController,
);只有身份和输入都通过,请求才会到达 Controller。任何一环已经给出最终响应,后续环节都不应再运行。

那次排障能找到转折点,靠的是所有日志都带着同一个 requestId。没有它,鉴权失败和 200 响应看起来会像两次无关请求。
一条有用的访问日志至少要回答:哪个请求、访问什么资源、最终状态是什么、用了多久。requestId 应尽量在外层创建,让鉴权、Controller 和服务日志都能沿用。Express 常在进入时注册 finish 和必要的 close 监听;Koa 则适合在 try/finally 中观察下游。
日志不是请求数据的镜像。密码、完整令牌、Cookie 和敏感请求体不该为了“方便排查”被复制进日志。真正有价值的是关联标识、稳定的错误码、路径、状态和阶段耗时。
请求头里的令牌只是外部输入。鉴权中间件要完成提取、签名与有效期检查,以及必要的用户查询,然后把验证结果写进 req.user、res.locals.user 或 ctx.state.user。下游应该读取这份可信结果,不要各自再解析一遍原始令牌。
缺少或无效凭证通常对应 401;身份已经确认,但没有执行某项操作的权限,通常对应 403。把所有拒绝都包装成 500,不仅语义错误,也会把真正的服务故障淹没在告警里。
body 解析只是把字节变成 JavaScript 值,并不代表数据满足接口契约。校验还要处理必填项、类型、长度、枚举、跨字段约束,以及未允许字段。
我更喜欢让校验器产出一份新数据,而不是让 Controller 继续使用原始 req.body:
function validateCreateUser(req, res, next) {
const name = String(req.body?.name ?? '').trim();
const email = String(req.body?.email ?? '').trim().toLowerCase();
if (!name || !email.includes('@')) {
return
Controller 只读 res.locals.input,等于明确声明:“能走到这里的数据已经通过当前路由的输入契约。”项目可以用 schema 工具替代手写判断,但这条数据流不该改变。

没有一种顺序能覆盖所有系统,但我会先按下面的基线排列,再为少数路由显式调整:
最外层建立 requestId、基础访问日志和错误边界,让最早发生的失败也能被关联和记录。
配置安全响应头、代理信任和跨域等应用级规则;这些设置必须与真实部署拓扑一致。
使用带大小上限的 body 解析器,只在需要的路由解析 multipart 等大体积数据。
进入 Router 后完成身份认证,再执行路由专属的权限检查与输入校验;若某条规则确实依赖输入,要把局部顺序写清楚。
Express 普通中间件签名是 (req, res, next),错误处理中间件签名是 (err, req, res, next)。调用 next(err) 后,分派器会跳过剩余普通中间件,寻找后面的错误处理器。
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
console.error(req.requestId, err);
return res.status(err.status ?? 500).json({
code: err.code ?? 'INTERNAL_ERROR',
message: err.expose ? err.message : '服务器内部错误',
检查 res.headersSent 正是从那次重复响应事故里留下的必要防线。错误处理器能统一尚未发送的响应,却无法撤回已经写入 socket 的响应头或响应体。已经开始流式发送时,通常只能中止流、记录原因,并让客户端识别结果不完整。
Express 还有两个特殊控制信号:路由回调中的 next('route') 会跳过当前路由剩余处理函数,继续寻找下一条匹配路由;Router 中的 next('router') 会退出当前 Router。它们适合少量守卫逻辑,但一条路由若频繁依赖这种跳转,拆开路由往往更清楚。
Express 5 会观察路由和中间件返回的 Promise,async handler 抛错或返回的 Promise 被拒绝时,错误会自动进入错误通道:
app.get('/users/:id', async (req, res) => {
const user = await userService.getById(req.params.id);
res.json({ data: user });
});边界依然不是无限的。定时器回调、传统回调 API,或一个没有 return 的独立 Promise 链,都可能脱离 handler 返回的 Promise。它们必须在自己的异步边界捕获错误,再调用 next(error):
app.get('/report', (req, res, next) => {
setTimeout(() => {
try {
throw new Error('报表生成失败');
} catch (error) {
next(error);
}
}, 10);
});如果项目仍使用 Express 4,也不能套用 Express 5 的 Promise 自动转发行为。常见做法是让异步 handler 返回 Promise,并通过统一包装函数 .catch(next)。
try/catch 包住下游 PromiseKoa 的错误边界通常放在链的外层:
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.app.emit('error', error, ctx);
ctx.status = error.status ?? 500;
ctx.body = {
code: error.code ?? 'INTERNAL_ERROR',
message: error.expose ? error.message :
app.on('error') 适合集中记录错误,但监听事件不等于已经给客户端生成响应。统一 JSON 仍应由能够写 ctx.status 和 ctx.body 的中间件完成。日志和响应是两项职责。

请求级错误边界只处理能够归属于当前处理链的失败。uncaughtException 和未处理的 Promise 拒绝属于进程生命周期问题,不能靠返回一份 500 JSON 假装服务已经恢复。
compose,框架的魔法只剩一个索引事故修完后,我用一个最小组合器做了回归实验。它很短,却能解释 Koa 风格中间件的注册、交接、回栈和重复调用保护:
function compose(middleware) {
return function run(ctx) {
let lastIndex = -1;
function dispatch(index) {
if (index <= lastIndex) {
return Promise.reject(new Error('next() 被重复调用'));
}
lastIndex = index;
它只有三个真正重要的机制:
next 是记住 index + 1 的闭包,中间件不需要知道下游函数的名字。dispatch 总是返回 Promise,因此 await next() 能等待完整下游并形成对称回栈。lastIndex 只允许索引前进,第二次交接会被明确拒绝。
再向前一步,把 compose 接到 http.createServer,就得到一个能运行的迷你框架。下面的示例包含上下文、短路、错误传播、404 和最终响应,可以直接保存为 mini-framework.mjs 后运行。
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
function compose(middleware) {
return function run(ctx) {
let lastIndex = -1;
function dispatch(index) {
if (index <= lastIndex) {
用几条请求就能把不同分支跑一遍:
curl 'http://localhost:3000/hello?name=小虎'
curl 'http://localhost:3000/hello'
curl 'http://localhost:3000/private'
curl -H 'Authorization: Bearer demo-token' 'http://localhost:3000/private'
curl 'http://localhost:3000/boom'它们会分别走到成功响应、输入短路、鉴权短路、携带可信身份的成功响应,以及最外层错误边界。这个小框架没有路由参数、流处理、内容协商等生产能力,但框架骨架已经完整可见:注册函数、组合链条、构造上下文、调度下一环、传播错误、生成最终响应。
我现在遇到“偶发超时”“明明返回了 200 又报错”“错误处理器没接住”“某个请求绕过鉴权”时,不会先盯着业务逻辑,而是先画出这次请求的控制权轨迹。
具体会按这个顺序检查:
next、响应、throw,还是 next(error);没有出口意味着挂起,多个出口意味着重复执行风险。next,共享上下文是否在下游读取前写好。await next(),Express 的 Promise 是否被返回或观察,回调与定时器错误是否显式进入错误通道。headersSent,流式响应失败时采用什么策略。next;再加一个慢鉴权或慢下游用例,专门揭露时序假设。中间件数量多并不可怕。真正危险的是:依赖藏在注册顺序里,交接发生得太早,错误离开了框架能观察的异步链,而响应结束以后代码还在试图改写结果。
从那次事故以后,我判断一个中间件是否可靠,只看三件事:它在交接前保证了什么,交接后还拥有多少控制权,失败时错误会沿哪条路径离开。 这三个问题能回答清楚,洋葱模型、责任链和框架调度就不再是需要背诵的名词,而是一套能用于设计、审查和排障的工程方法。
请求管道清楚以后,HTTP 边界里的公共逻辑终于不再四处复制。但中间件只能组织一次请求经过哪些关卡,无法替我们拆开关卡里面的业务职责。一个控制器仍然可能同时计算价格、选择支付渠道、写数据库和发送通知。接下来真正影响可维护性的,是变化能否被限制在局部,而不是文件数量看起来是否足够“分层”。
通过检查后才进入 Controller;所有路由都未命中时生成 404;Express 的错误处理中间件放在可能产生错误的链之后。