我刚开始学习 Express 中间件时,记住的是这句话:

中间件就是一个函数,接收 reqresnext 三个参数。

这句话没有错,但它太像定义了。只记住定义,很容易出现一种尴尬:代码会写,却不一定真的知道它为什么存在。等到项目里出现鉴权、日志、跨域、错误处理这些通用逻辑时,才会发现中间件并不是一个语法点,而是一种组织请求流程的方式。

中间件真正重要的地方,不是它长什么样,而是它把 Web 服务里大量横切、重复、可组合的请求处理逻辑,整理成了一条清晰的流水线。


一、如果没有中间件,请求处理会变成什么样?

先看一个最朴素的接口:

1
2
3
4
app.get('/api/user', async (req, res) => {
const user = await getUserById(req.query.id)
res.json(user)
})

这段代码看起来很干净,因为它只处理“获取用户”这件事。

但真实业务里,一个请求进入服务端之后,通常不会这么简单。你可能还需要:

  • 打日志
  • 解析请求体
  • 处理 Cookie
  • 校验登录态
  • 校验权限
  • 做参数校验
  • 设置跨域响应头
  • 捕获异常
  • 统一返回格式
  • 记录接口耗时

如果没有中间件机制,我们很可能会把这些逻辑一股脑塞进每个路由里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
app.get('/api/user', async (req, res) => {
console.log(`[GET] ${req.url}`)

const token = req.headers.authorization
if (!token) {
return res.status(401).json({ message: '未登录' })
}

const currentUser = await verifyToken(token)
if (!currentUser) {
return res.status(401).json({ message: '登录已过期' })
}

if (!currentUser.permissions.includes('user:read')) {
return res.status(403).json({ message: '无权限' })
}

try {
const user = await getUserById(req.query.id)
res.json({
code: 0,
data: user,
message: 'ok',
})
} catch (err) {
console.error(err)
res.status(500).json({ message: '服务器错误' })
}
})

这样的问题非常明显:

  1. 业务逻辑被淹没了

    这个接口真正想做的事情只是“根据 id 获取用户”,但前后堆了很多通用逻辑。读代码的人要先穿过一层日志、认证、权限、异常处理,才能看到核心业务。

  2. 重复代码会越来越多

    登录校验不只 /api/user 需要,订单、评论、后台接口都需要。如果每个路由都写一遍,后期改鉴权规则时就会非常痛苦。

  3. 流程顺序不容易统一

    有的接口先校验权限再解析参数,有的接口先查数据库再判断登录,这种差异会让系统行为变得不可预测。

  4. 通用能力无法优雅复用

    日志、跨域、鉴权、错误处理,本质上不是某一个接口独有的能力,而是很多接口共享的基础设施。

所以,Express 中间件机制首先解决的问题是:

如何把请求处理过程中的通用逻辑,从具体业务路由中抽离出来,并且按顺序组织起来。


二、中间件的本质:把请求处理拆成一条流水线

Express 中间件可以理解成请求流转过程中的一个“处理节点”。

一个请求进来之后,不是直接交给最终路由函数,而是依次经过多个中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
请求进入
|
v
日志中间件
|
v
请求体解析中间件
|
v
登录校验中间件
|
v
权限校验中间件
|
v
业务路由处理函数
|
v
响应返回

把请求处理拆成一条流水线

在 Express 里,这条流水线通常长这样:

1
2
3
4
5
app.use(logger)
app.use(express.json())
app.use(auth)

app.get('/api/user', getUser)

每个中间件只负责一件相对独立的事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function logger(req, res, next) {
console.log(`[${req.method}] ${req.url}`)
next()
}

function auth(req, res, next) {
const token = req.headers.authorization

if (!token) {
return res.status(401).json({ message: '未登录' })
}

req.user = verifyToken(token)
next()
}

async function getUser(req, res) {
const user = await getUserById(req.query.id)
res.json(user)
}

这里最关键的不是 reqres,而是 next

next() 的含义是:

当前中间件的工作做完了,请把请求交给下一个处理节点。

如果当前中间件已经完成响应,就不需要再调用 next()

1
2
3
4
5
6
7
function auth(req, res, next) {
if (!req.headers.authorization) {
return res.status(401).json({ message: '未登录' })
}

next()
}

所以,中间件机制本质上是在提供一种可暂停、可继续、可提前结束的请求处理链


三、它解决的第一个核心问题:通用逻辑复用

中间件最直接的价值是复用。

比如登录校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function requireLogin(req, res, next) {
const token = req.headers.authorization

if (!token) {
return res.status(401).json({ message: '请先登录' })
}

const user = verifyToken(token)

if (!user) {
return res.status(401).json({ message: '登录状态无效' })
}

req.user = user
next()
}

你可以把它应用到一组接口:

1
2
3
app.get('/api/profile', requireLogin, getProfile)
app.post('/api/order', requireLogin, createOrder)
app.get('/api/order/list', requireLogin, getOrderList)

也可以挂载到某个路由前缀上:

1
app.use('/api/admin', requireLogin)

这样 /api/admin 下的所有接口都会先经过登录校验。

这就是中间件比“在每个接口里复制一段代码”更优雅的原因:它把重复逻辑变成了可复用、可插拔的模块。


四、它解决的第二个核心问题:关注点分离

一个接口处理函数应该尽量只表达业务意图。

比如下面这个路由:

1
2
3
4
app.get('/api/user/:id', requireLogin, async (req, res) => {
const user = await userService.findById(req.params.id)
res.json(user)
})

读这段代码时,你可以很快抓住重点:

  • 这是一个获取用户详情的接口
  • 它需要登录
  • 它通过 userService.findById 查询用户
  • 它返回用户数据

至于“如何判断登录”“如何解析 token”“未登录返回什么格式”,这些细节都被放进了 requireLogin

这就是关注点分离。

路由函数关心业务,中间件关心通用流程。两者配合之后,代码结构会更清楚:

1
2
3
中间件:我负责请求进入业务之前应该满足什么条件
路由:我负责这个接口真正要完成什么业务动作
错误处理中间件:我负责出问题之后怎么统一兜底

关注点分离还有一个隐性好处:代码更容易测试和维护

你可以单独测试鉴权中间件,也可以单独测试业务 service,而不需要每次都把所有逻辑混在一个大函数里验证。


五、它解决的第三个核心问题:流程编排

中间件不只是复用函数,它还提供了一种声明式的流程编排方式。

比如一个后台接口可能需要经过这些步骤:

1
2
3
4
5
6
7
app.post(
'/api/admin/product',
requireLogin,
requireRole('admin'),
validateBody(productSchema),
createProduct
)

这段代码像一条很清楚的流程说明:

1
2
3
4
5
创建商品之前:
1. 必须登录
2. 必须是管理员
3. 请求参数必须符合商品 schema
4. 最后才执行创建商品逻辑

流程编排

每一步都是一个中间件:

1
2
3
4
5
6
7
8
9
function requireRole(role) {
return function roleMiddleware(req, res, next) {
if (!req.user.roles.includes(role)) {
return res.status(403).json({ message: '无权限' })
}

next()
}
}

这类“返回中间件的函数”在 Express 里非常常见。它的好处是可以让中间件带参数,从而形成更灵活的组合:

1
2
3
requireRole('admin')
validateBody(productSchema)
rateLimit({ max: 100 })

这说明中间件机制不只是让我们少写代码,它还让我们能像搭积木一样组织请求处理流程。


六、它解决的第四个核心问题:统一错误处理

Express 中还有一种特殊中间件:错误处理中间件。

普通中间件通常接收三个参数:

1
function middleware(req, res, next) {}

错误处理中间件接收四个参数:

1
function errorHandler(err, req, res, next) {}

它一般放在所有路由的最后:

1
2
3
4
5
6
7
8
app.use((err, req, res, next) => {
console.error(err)

res.status(500).json({
code: 500,
message: '服务器内部错误',
})
})

当某个中间件或路由把错误交给 next(err) 时,Express 会跳过后续普通中间件,进入错误处理中间件:

统一错误处理

1
2
3
4
5
6
7
8
app.get('/api/user/:id', async (req, res, next) => {
try {
const user = await userService.findById(req.params.id)
res.json(user)
} catch (err) {
next(err)
}
})

这样做的价值很大:

  • 每个接口不用都写一套 try...catch 返回格式
  • 错误日志可以统一记录
  • 错误响应结构可以统一
  • 业务异常和系统异常可以集中转换

更进一步,我们可以定义业务错误:

1
2
3
4
5
6
class HttpError extends Error {
constructor(status, message) {
super(message)
this.status = status
}
}

然后在错误处理中间件中统一处理:

1
2
3
4
5
6
7
8
app.use((err, req, res, next) => {
const status = err.status || 500

res.status(status).json({
code: status,
message: err.message || '服务器内部错误',
})
})

于是业务代码可以更自然地表达异常:

1
2
3
if (!user) {
throw new HttpError(404, '用户不存在')
}

中间件机制在这里解决的是:

如何让错误从任意请求处理节点冒泡到统一出口。

这和 Promise 链、洋葱模型、责任链模式都有相似之处,本质都是在处理“流程中的异常如何统一收束”。


七、它解决的第五个核心问题:扩展框架能力

Express 本身很轻。

它没有强制内置一整套完整功能,而是把大量能力交给中间件生态来完成。

常见能力几乎都可以通过中间件扩展:

1
2
3
4
5
6
7
app.use(express.json())          // 解析 JSON 请求体
app.use(express.urlencoded()) // 解析表单请求体
app.use(cookieParser()) // 解析 Cookie
app.use(cors()) // 处理跨域
app.use(helmet()) // 设置安全相关响应头
app.use(morgan('combined')) // 记录访问日志
app.use(express.static('public')) // 托管静态资源

这也是 Express 设计很经典的地方:

框架核心保持简单,请求处理能力通过中间件扩展。

换句话说,中间件机制是 Express 插件化生态的基础。

你不需要等框架官方支持所有功能,只要能力能抽象成“请求进来时做点什么,必要时继续交给下一个节点”,就可以做成中间件。


八、从源码思路理解:中间件像一个队列

为了理解 next(),可以把 Express 中间件机制简化成下面的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const middlewares = []

function use(fn) {
middlewares.push(fn)
}

function handleRequest(req, res) {
let index = 0

function next(err) {
const fn = middlewares[index++]

if (!fn) {
return
}

if (err) {
return next(err)
}

fn(req, res, next)
}

next()
}

真实 Express 源码当然更复杂,它还要处理路由匹配、路径前缀、错误中间件、异步行为等。但核心思想可以先这样理解:

  1. app.use()app.get() 会注册一批处理函数
  2. Express 按注册顺序组织这些函数
  3. 请求进来后,从第一个匹配的处理函数开始执行
  4. 当前函数调用 next(),控制权才会交给下一个函数
  5. 如果直接 res.send() / res.json() 并结束响应,请求链就可以停止

所以 next() 不是一个普通回调,它代表的是控制权转移

中间件像一个队列

这也是为什么中间件顺序非常重要:

1
2
app.use(auth)
app.use('/public', express.static('public'))

和:

1
2
app.use('/public', express.static('public'))
app.use(auth)

这两段代码的行为可能完全不同。

前者访问静态资源也要先经过鉴权;后者静态资源会先被公开处理。


九、几个容易踩坑的点

1. 忘记调用 next()

1
2
3
app.use((req, res, next) => {
console.log(req.url)
})

这个中间件既没有响应客户端,也没有调用 next(),请求就会一直挂起。

正确写法:

1
2
3
4
app.use((req, res, next) => {
console.log(req.url)
next()
})

2. 响应之后又调用 next()

1
2
3
4
app.use((req, res, next) => {
res.json({ message: 'ok' })
next()
})

这可能导致后续中间件继续尝试写响应,出现类似 Cannot set headers after they are sent 的问题。

一般来说,如果当前中间件已经结束响应,就应该 return

1
2
3
app.use((req, res, next) => {
return res.json({ message: 'ok' })
})

3. 中间件顺序放错

1
2
app.post('/api/user', createUser)
app.use(express.json())

如果 createUser 里需要读取 req.body,上面的顺序就不对。因为 JSON 解析中间件注册在路由后面,请求进入路由时还没有被解析。

正确写法:

1
2
app.use(express.json())
app.post('/api/user', createUser)

4. 异步错误没有正确交给错误处理中间件

在异步路由里,如果你只是 throw,不同 Express 版本和写法下可能不会按你预期进入错误处理中间件。更稳妥的方式是显式 next(err),或者使用统一的异步包装函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
const asyncHandler = fn => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}

app.get(
'/api/user/:id',
asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id)
res.json(user)
})
)

十、和 Koa 洋葱模型有什么区别?

Express 的中间件更像线性流水线:

1
middleware1 -> middleware2 -> middleware3 -> route

它的思路是:当前节点处理完,调用 next() 进入下一个节点。

Koa 的中间件更典型的是洋葱模型:

1
2
3
4
5
6
7
8
9
10
11
app.use(async (ctx, next) => {
console.log('before A')
await next()
console.log('after A')
})

app.use(async (ctx, next) => {
console.log('before B')
await next()
console.log('after B')
})

执行顺序是:

1
2
3
4
before A
before B
after B
after A

洋葱模型

Koa 通过 async/await 让中间件天然具备“进入前”和“返回后”的两段逻辑,非常适合统计耗时、统一包装响应等场景。

Express 也可以做类似事情,比如:

1
2
3
4
5
6
7
8
9
app.use((req, res, next) => {
const start = Date.now()

res.on('finish', () => {
console.log(`${req.method} ${req.url} ${Date.now() - start}ms`)
})

next()
})

但从模型上看,Express 更偏线性责任链,Koa 更强调洋葱式组合。


十一、总结

Express 中间件机制看起来只是 reqresnext,但它背后解决的是 Web 服务非常核心的工程问题:

  • 复用:日志、鉴权、跨域、解析请求体等通用逻辑不用在每个接口里重复写
  • 分离:业务路由专注业务,中间件处理通用流程
  • 编排:多个处理步骤可以按顺序组合成清晰的请求流水线
  • 拦截:中间件可以决定继续执行,也可以提前结束响应
  • 扩展:Express 的大量能力都可以通过第三方中间件接入
  • 兜底:错误处理中间件让异常有统一出口

所以,中间件不是 Express 的一个语法点,而是它组织请求生命周期的核心机制。

真正理解中间件之后,再看 app.use(cors())app.use(express.json())app.use(auth),就不会只觉得它们是在“挂函数”,而是在构建一条从请求进入到响应返回的处理链。