有了「路由怎么设计、Controller 该干什么、请求响应长什么样」的共识之后,在实际编码中我们还会反复遇到一类横切逻辑:每个请求都要解析 body、都要鉴权、都要记日志、出错都要转成统一格式。 若把这些都写在每个 Controller 里,代码会大量重复且难以维护。
因此我们这部分专门讨论中间件模式与框架原理。我们会说明什么是中间件、它如何与路由和 Controller 配合;接着解释洋葱模型的含义以及「进入」与「离开」对称的执行顺序;最后分别梳理 Express 与 Koa 的执行流程,帮助你在使用或选型时知其然且知其所以然。
中间件(middleware)是「请求—响应」管道上的一环。从调用方的角度看,一个 HTTP 请求进入服务器后,不会直接落到某条路由的 handler,而是先经过一系列中间件;每个中间件可以对请求做一点处理(例如解析 body、校验 token、记录开始时间),然后选择「把控制交给下一环」或「直接结束响应」。响应写出时,控制会按相反或相同的顺序返回,视框架实现而定。
这样,解析 body、鉴权、日志、错误统一格式化等横切逻辑就可以从 Controller 里抽出来,写成独立的函数,挂到管道上,被多条路由复用。
在 Express 中,中间件的签名是 (req, res, next) => { ... }:req 和 res 就是原生 http 模块里的请求与响应对象(或框架在其上的封装),next 是一个函数,调用它表示「交给下一个中间件或路由 handler」。在 Koa 中,中间件的签名是 (ctx, next) => { ... }:ctx 是上下文对象,封装了请求、响应以及框架提供的一些便捷方法,next 同样表示「交给下一环」。
无论哪种风格,中间件的职责都是处理横切逻辑,并在合适的时候调用 next() 把控制传递下去。若某环既不调用 next() 也不结束响应(例如不调用 res.end() 或 Koa 里不设置 ctx.body),请求就会挂起,客户端一直等不到响应。
与此相关的是中间件与路由、Controller 的关系。路由负责「根据 method 和 path 匹配到哪个 handler」,而中间件在「匹配到具体路由」之前或之后执行。通常我们会先挂一批全局中间件(例如 body 解析、请求日志),再挂路由;这样每条路由的 handler 被调用时,req.body 已经解析好、请求已被记过日志。
同一批中间件可以被多条路由复用,因此不需要在每个 Controller 里重复「先解析 body 再干活」的代码。Controller 只关心业务:从已经「干净」的 req 上取参数,调服务层,写响应;至于 body 是怎么从流里解析出来的、鉴权是怎么做的,都交给管道上的中间件。
下面这段代码演示 Express 中如何用内置的 express.json() 解析 JSON body,以及如何写一个简单的日志中间件。在进入时打印请求方法和路径,调用 next() 交给下一环,等下游全部执行完后再打印耗时;这里 Express 的「next 之后」会在当前事件循环的后续时机执行,与 Koa 的洋葱对称不同,下一节会对比。
|const express = require('express'); const app = express(); // 解析 JSON body,挂到 req.body app.use(express.json()); // 自定义日志中间件 app.use((req, res, next) => { const start = Date.now(); console.log(`${req.method} ${req.url}`); next(); // Express 中 next() 之后的代码会在「后续」执行,但顺序与 Koa 洋葱不同 setTimeout(() => console.log(` -> ${Date.now() - start}ms`), 0); }); app.get('/users/:id', (req, res) => { res.json({ id: req.params.id, name: 'Alice' }); });

若把 body 解析、鉴权、日志都写在每个 Controller 的开头,会出现大量重复代码;而且一旦要调整「所有接口都要先鉴权」或「错误都要转成统一 JSON 格式」,就要改动每一个 handler。
把横切逻辑抽成中间件之后,这些逻辑只写一次,通过 app.use(middleware) 或路由级别的 router.use(middleware) 挂到管道上,所有经过该管道的请求都会自动经过这些逻辑。新增接口时只需挂好路由,无需再复制粘贴鉴权或日志代码;修改横切行为时也只需改中间件实现,符合「开放封闭」:对扩展开放(新路由自动享有已有中间件),对修改封闭(改中间件即可影响所有经过的请求)。
中间件必须选择「调用 next() 把控制交给下一环」或「直接结束响应」;若既不调用 next() 又不结束响应,请求会一直挂起,客户端拿不到结果。
在理解了中间件是「管道上的一环」之后,下一个关键问题是:当有多个中间件时,请求与响应究竟按什么顺序经过它们?这就引出了洋葱模型。
当多个中间件按顺序注册时,请求会从第一个中间件开始,依次「进入」每一个,直到最后一个(通常是路由 handler);之后,控制会「返回」——在 Koa 里,返回的顺序与进入的顺序对称:最后进入的最先返回,最先进入的最后返回。把这条「进入 → 到达最里层 → 再沿原路返回」的路径画出来,横截面就像一层层洋葱圈,因此叫洋葱模型(onion model)。
在 Koa 中,中间件是 async 函数,通过 await next() 把控制交给「下一个中间件」。执行到 await next() 时,当前中间件会暂停,引擎去执行下一个中间件;下一个中间件里可能再次 await next(),如此递归,直到某一个是路由 handler,不再调用 next(),开始写 ctx.body 并返回。
返回时,Promise 链一层层 resolve,于是「上一个中间件」里 await next() 之后的代码才会执行。因此,先注册的中间件,其「next 之后的代码」会在后注册的中间件以及路由都执行完之后才运行,形成「进入时 1→2→3→路由,离开时 路由→3→2→1」的对称顺序。
这种对称性特别适合「包一层、拆一层」的逻辑。例如计时:在进入时记录开始时间,在离开时(即 await next() 之后)计算耗时并打日志,这样整条链路的耗时都能被准确测量。又如错误处理:在最外层注册一个「捕获错误并转成统一 JSON」的中间件,在离开路径上若发现 ctx 上挂了错误,就写对应的状态码和 body;内层中间件或路由只需 throw new Error(...),不必每个 handler 都写 try/catch。
Express 的中间件模型是「线性」的:next() 只是把控制交给下一环,当前函数里 next() 之后的代码会在当前同步执行流结束后、在事件循环的后续时机执行,因此「next 之后」与「下一环之后」的先后顺序并不像 Koa 那样严格对称。若要实现「进入时做 A、离开时做 B」的效果,在 Express 里通常要在下一环的「末尾」再回调或通过 res.on('finish') 等方式处理。

因为「离开」路径是确定的,在 Koa 里我们可以把「响应前的最后处理」放在最外层中间件的 await next() 之后。例如统一给响应头加 X-Response-Time、在响应发出前做审计日志、或在最外层 catch 所有未处理的错误并映射成统一格式。这些逻辑写在一个中间件里即可,内层路由无需关心;内层只负责业务和可能抛出的错误,外层负责「兜底」和横切收尾。
Express 没有原生的洋葱语义,若要类似效果,往往需要把「离开时」的逻辑写在每个路由的末尾、或借助 res.on('finish') 等事件,不如 Koa 的写法统一。
Express 的应用由一系列中间件和路由组成,它们按 app.use(...) 和 app.get/post/...(...) 的注册顺序排成一条链。每个请求进来时,框架会按这条链依次调用:先执行第一个中间件,若该中间件调用了 next(),就执行下一个;若某个中间件是路由匹配(例如 app.get('/users/:id', handler)),且当前请求的 method 和 path 匹配,则执行该 handler,否则继续找下一个匹配的中间件或路由。
一旦某个环节调用了 res.end() 或 res.json() 等结束响应的方法,后续的链就不会再被调用(除非之前已经调用了 next(),那时后续环节可能已经在执行)。若某环节既不调用 next() 又不结束响应,请求就会挂起。
错误在 Express 里通过 next(err) 传递。若某个中间件或路由执行中抛出异常或主动调用 next(err),Express 会跳过后续「普通」中间件,转而去执行错误处理中间件——即签名为四个参数的 (err, req, res, next) => { ... } 的函数。因此通常会在链的末尾挂一个错误处理中间件,用来把 err 转成统一格式的 JSON 和状态码,避免在每个 handler 里写 try/catch。
下面是一段典型的 Express 结构:全局中间件(body 解析、日志)、路由、最后是错误处理中间件。
|const express = require('express'); const app = express(); app.use(express.json()); app.use(loggerMiddleware); app.get('/users/:id', getUserById); app.post('/users', createUser); // 错误处理中间件:四参数 app.use((err, req, res
Koa 本身只提供「洋葱」式的中间件机制,不内置路由。我们通过 app.use(middleware) 注册的每个中间件都是一个 async 函数,签名为 (ctx, next) => { ... }。请求进来时,Koa 会按注册顺序依次调用这些中间件;每个中间件里通过 await next() 把控制交给下一个,下一个全部执行完后才会回到当前中间件执行 await next() 之后的代码。
路由通常由 @koa/router 等库提供,其本质也是一个中间件:根据 ctx.method 和 ctx.path 匹配到对应 handler 并执行,若匹配不到则继续 await next()。因此整条链仍然是「中间件 1 → 中间件 2 → 路由中间件(内部执行匹配的 handler)→ 返回 → 中间件 2 的 next 后 → 中间件 1 的 next 后」。
错误在 Koa 里可以用 try/catch 或全局错误事件处理。在最外层包一个 async 中间件,内部 try { await next(); } catch (e) { ctx.status = 500; ctx.body = { code: 'INTERNAL_ERROR', message: '...' }; },即可统一捕获下游抛出的错误并转成响应。
Koa 也支持 app.on('error', (err) => { ... }),用于记录日志等,但响应体的设置通常仍在中间件里完成。
|const Koa = require('koa'); const Router = require('@koa/router'); const app = new Koa(); const router = new Router(); app.use(async (ctx, next) => { try { await next

Express 生态成熟、资料多,线性模型容易理解,但「离开」路径上的统一逻辑要靠约定或事件;Koa 体积小、洋葱模型适合计时与统一错误处理,路由和 body 解析等需额外中间件。
在实际项目中,无论选哪种,本讲所建立的「中间件是管道上的一环」「洋葱的进入与离开对称」「错误用 next(err) 或 try/catch 集中处理」这些概念都适用;下一讲我们会从设计模式的角度再看中间件与组合模式,把管道和可复用逻辑的关系再抽象一层。
Express 中若发生错误,应使用 next(err) 将错误交给四参数错误处理中间件,而不是在业务里直接 res.status(500).json(...) 后不调用 next,否则错误处理中间件无法统一记录或转换错误格式。
这节课我们围绕中间件模式与框架原理展开了三块内容。在什么是中间件中,我们说明了中间件是「请求—响应」管道上的一环,签名为 (req, res, next) 或 (ctx, next),职责是处理 body 解析、鉴权、日志等横切逻辑,并通过调用 next() 把控制交给下一环;同时明确了中间件与路由、Controller 的关系,以及为何要把横切逻辑抽成中间件复用。
在洋葱模型中,我们解释了进入与离开的对称顺序:在 Koa 里通过 await next() 实现「先进入的中间件、其 next 之后的代码最后执行」,并说明了这种对称性对计时、统一错误处理和响应收尾的便利。
在 Express 与 Koa 的执行流程中,我们分别梳理了 Express 的线性链表与 next()、错误通过 next(err) 交给四参数中间件,以及 Koa 的洋葱与 await next()、路由作为中间件、错误用 try/catch 或最外层中间件统一处理,并简要对比了两种模型对编写横切逻辑的影响。
理解了中间件与管道之后,你会发现很多「可插拔、可组合」的写法都符合同一类思想:把逻辑拆成小函数,按顺序组合成一条链。下一部分我们将讨论 Node.js 常见设计模式,其中会从模块化与解耦、事件发布/订阅以及中间件与组合模式的角度,再审视中间件在架构中的位置,并与本讲的管道概念衔接起来,形成更完整的设计视角。