别把"会写中间件"当成"会写 async 函数"——两者之间隔着一整条链的稳定性。上一章的内核给了队列,这一节把队列上每个元素的契约讲严:签名长什么样、进层出层各自能做什么、异常怎么抛才不破坏链路、以及怎么给一个中间件写单元测试。学完这一节,本章后面所有自定义层才有地基。
一个规范的 Koa 中间件长这样:
async function middleware(ctx, next) { // ── 进层阶段:请求半程,做"准入"与"准备"── const start = Date.now(); ctx.state.start = start; await next(); // 把控制权交给内层,并在内层全部结束后回到这里 // ── 出层阶段:响应半程,做"统计"与"修饰"── ctx.set('X-Cost', String(Date.now() - start)); }
契约由四部分组成。参数一 ctx:第 2.3 节解剖过的公共通道,读写请求与响应、传递请求级状态都靠它。参数二 next:一个返回 Promise 的函数,调用它表示放行,不调用表示截停。async 关键字:不是风格偏好,是链路正常工作的前提——内核要把整条队列组成 Promise 链,任何一环不是 Promise 都会造成断链。返回值:中间件的返回值没有意义,一切输出都通过 ctx 传递,别依赖 return。
在这四条之上,还有一条隐含纪律:中间件应当无状态。不要在模块顶层放可变的全局变量来记请求状态——并发请求会互相踩踏。需要跨请求共享的配置放闭包或实例属性,需要请求内共享的放 ctx.state。
按行为分,中间件只有三种合法形态,写之前先想清楚自己要写哪种。
纯进层型:只做准入检查,await next() 之后的代码要么没有要么极简。鉴权层是典型:
function auth(required = true) { return async (ctx, next) => { if (!required) return next(); // 可选鉴权路由直接放行 const token = ctx.get('authorization'); if (!token) { // 截停:不调用 next,内层不执行 ctx.status = 401; ctx.body = { code: 'UNAUTHORIZED', message: '缺少凭证' }; return; } ctx.state.user = await verify(token); // 校验失败 throw,交给兜底层 await next(); }; }
纯出层型:进层只记录起点,逻辑全在 await next() 之后。耗时统计、统一响应头属于这类——它们工作的对象是"结果",必须等内层跑完。
包裹型:进层准备资源、出层释放资源,try/finally 是标配:
function withDbSession() { return async (ctx, next) => { const session = await pool.begin(); // 进层申请 ctx.state.session = session; try { await next(); await session.commit(); } catch (err) { await session.rollback(); // 异常路径也要释放 throw err; // 重新抛出,继续冒泡给兜底层 } finally { session.release(); // 无论成败,连接必须回池 } }; }
注意包裹型里 throw err 这一招:捕获、处理、再抛出是包裹层的标准动作——你释放了资源,但异常的传播不该被你打断,转换响应是兜底层的职责。
中间件是纯函数式的输入输出结构,测试它不需要起服务——构造假 ctx、手工驱动 next 即可。用 Jest 的写法:
// logger.test.js:测试一个耗时日志中间件 function logger() { return async (ctx, next) => { const start = Date.now(); await next(); ctx.set('X-Cost', String(Date.now() - start)); }; } test('出层阶段应写入 X-Cost 头', async () => { const headers = {}; const ctx = { set: (k, v) => { headers[k] = v; }, state: {} }; const fakeNext = async () => { /* 模拟内层,什么都不做 */ }; await logger()(ctx, fakeNext); expect(headers['X-Cost']).toBeDefined(); expect(Number(headers['X-Cost'])).toBeGreaterThanOrEqual(0); }); test('内层抛出异常时应继续向上冒泡', async () => { const ctx = { set: jest.fn(), state: {} }; const boom = async () => { throw new Error('inner'); }; await expect(logger()(ctx, boom)).rejects.toThrow('inner'); });
两个测试分别钉住了中间件的两种关键行为:正常路径的副作用、异常路径的传播。有了这两条测试,任何重构(换日志库、改计时方式)都有了安全网。这种"假 ctx 加假 next"的测法成本极低,建议每个自定义层至少覆盖这两条路径——8.3 节的集成测试会在此基础上补真实 HTTP 层的验证。
⚠️ 常见坑:为了"让测试好写"而在中间件里直接 require 数据库连接。依赖应该从工厂参数注入:
logger({ getReqId: () => uuid() })。中间件的依赖越少、越显式,测试与复用就越便宜。
最后把违规后果写实,这三类案例都在真实项目里反复出现过。其一,漏写 async:函数体里调用了 next() 却没 await,行为与 2.2 节实验三完全一致——内层脱离生命周期,响应时灵时不灵,且每次重启后现象可能不同(取决于事件循环时序),极难排查。其二,catch 后不重抛:包裹层为了"止血"把异常吞了,兜底层看不到,客户端拿到默认 404 而不是 500——因为异常被吞后内核认为链路正常结束、但没人写 body。其三,用回调风格的库不做包装:把一个 callback 风格的 Redis 客户端调用直接塞进 async 中间件,回调里的异常在 Promise 链之外,兜底层永远接不到。解法是用 util.promisify 或库自带的 Promise 版本,让一切异步都回到链上。
// 违规三的修复示例:把回调风格包装回 Promise 链 const { promisify } = require('util'); const redisGet = promisify(redisClient.get).bind(redisClient); app.use(async (ctx, next) => { const cached = await redisGet(`user:${ctx.state.user.id}`); // 异常可冒泡了 ctx.state.cachedUser = cached ? JSON.parse(cached) : null; await next(); });
💡 关键直觉:中间件签名规范的本质只有一句——让每一层的异步行为都留在同一条 Promise 链上。链在,顺序、错误、资源清理就全在;链断,前面两章讲的一切机制瞬间失效。
单人写层的手艺立住了,下一节看看社区货架上有哪些现成层——多数需求不必自己卷,但选哪个轮子、怎么装配,同样有讲究。