Appearance
Node.js 服务端框架与中间件
问题
koa 的洋葱模型是什么?在没有 async / await 的年代它是怎么实现的?中间件里抛出的异常怎么统一处理?body-parser 做了什么?不用它的话,怎么手动解析一个带图片和字段的 POST 请求?
结论
理解路径
koa 这类框架的核心只有两件事:把 req / res 封装成 context,以及用一套中间件机制串起一次请求的处理流程。洋葱模型、异常处理、body 解析,本质都是「如何组织和拦截这条流程」。
koa 的洋葱模型是什么?
中间件按注册顺序「进入」,每个中间件用 await next() 把控制权交给下一个;next() 之后的代码要等内层所有中间件执行完,再逆序「穿出」。进入是顺序、返回是逆序,像一层层穿过洋葱再绕回来。
js
app.use(async (ctx, next) => {
console.log(1)
await next()
console.log(6)
})
app.use(async (ctx, next) => {
console.log(2)
await next()
console.log(5)
})
app.use(async (ctx, next) => {
console.log(3)
await next()
console.log(4)
})
// 输出顺序:1 2 3 4 5 6这种「先进后出」的结构让计时、日志、异常捕获、响应包装可以包在外层:外层中间件在 next() 前后各做一半工作,天然能度量和拦截内层的全部行为。
没有 async/await 时,洋葱模型怎么实现?
不管哪种语法,洋葱模型的内核都是 compose:把中间件数组组合成一个函数,给每个中间件传入 next,而 next 就是「调用下一个中间件」的函数,返回 Promise 串起整条链。
js
function compose(middlewares) {
return function (context, next) {
let lastIndex = -1
function dispatch(i) {
// 同一个中间件里多次调用 next() 是错误用法
if (i <= lastIndex) return Promise.reject(new Error('next() called multiple times'))
lastIndex = i
const fn = i === middlewares.length ? next : middlewares[i]
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, () => dispatch(i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
return dispatch(0)
}
}koa1 时代没有 async / await,用的是 Generator + co:中间件写成 function* (next) {},用 yield next 交出控制权,co 这个执行器递归地驱动 generator——yield 出来一个 Promise,等它 resolve 后再 next() 把结果送回继续执行。这正是后来 async / await 的语法糖原理,所以 koa2 换成 async 中间件后,洋葱模型的行为完全一致。
js
// koa1 风格:generator 中间件
function* logger(next) {
console.log('enter')
yield next // 交出控制权,等内层完成
console.log('leave')
}中间件的异常处理怎么做?
洋葱模型让最外层中间件成为天然的错误边界:用 try/catch 包住 await next(),内层任意中间件 throw 或返回 rejected Promise,都会被它捕获。
js
// 注册在最外层的统一错误处理中间件
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.status || 500
ctx.body = { message: err.message }
ctx.app.emit('error', err, ctx) // 触发集中日志
}
})要注意一个边界:只有「能被 await 链等到」的异步错误才能这样捕获。async 中间件里 throw 会变成 rejected Promise,被外层 await 捕到;但如果错误发生在回调式异步里(例如 setTimeout 内部直接 throw),它脱离了 Promise 链,外层 try/catch 捕不到,会变成未捕获异常。解决办法是把这类异步操作包成 Promise 再 await,或挂 process.on('uncaughtException') 兜底。
body-parser 做什么?不用它怎么解析 POST?
HTTP 请求体是一个可读流,req 本身不会替你解析。body-parser / koa-bodyparser 做的事是:监听 req 的 data / end 事件,把 chunk 拼成完整 buffer,再根据 Content-Type 解析成 JSON 或表单对象,挂到 ctx.request.body。
手动解析 application/json 并不难:
js
function parseJsonBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch (err) {
reject(err)
}
})
req.on('error', reject)
})
}带图片和字段的表单是 multipart/form-data,复杂在于它是二进制混合内容,不能当字符串处理。关键信息在请求头里:
http
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryabc123boundary 是各部分之间的分隔符。请求体按 --boundary 切成多段,每段有自己的头部:
------WebKitFormBoundaryabc123
Content-Disposition: form-data; name="title"
前端面试 ← 文本字段段
------WebKitFormBoundaryabc123
Content-Disposition: form-data; name="avatar"; filename="a.png"
Content-Type: image/png
‹二进制图片字节› ← 文件段
------WebKitFormBoundaryabc123--手动解析的要点:
- 从
Content-Type头取出boundary。 - 把 buffer 按
boundary字节序列切分,对 Buffer 操作而不是 String,否则图片二进制会被编码破坏。 - 每段再拆出头部(
name、filename、Content-Type)和内容:有filename的是文件,没有的是普通字段。
实际开发不会手撸这套字节解析,而是用 formidable、multer(Express)或 busboy(流式、性能好)。面试能讲清「boundary 分段 + 文件段是二进制要按 Buffer 处理」就抓住了重点。