告别入门:Express中间件机制在5.x时代的演进与Promise适配

那年我在重构一个支付回调系统时,遇到了一个非常典型的问题:我们在 express@4.18.x 环境下写了一个 async 中间件去请求第三方银行接口,结果因为忘记写 try-catch 包裹 await,导致整个进程直接崩了。当时我就在想,为什么 Express 不能像 Koa 那样自动捕获 Promise 的异常?

这个问题在 Express 5.0.0-alpha.8(2024年5月发布)中终于有了实质性的推进。虽然目前生产环境主流还是 4.19.x 的稳定版,但 5.x 的核心目标之一就是解决异步中间件的“错误吞没”问题。

在 4.x 时代,中间件的执行依赖于 next() 回调。如果你在中间件里使用了 async/await,一旦抛出错误,Express 的默认机制是无法捕获这个 Promise Rejection 的。你必须手动写 next(error)

// Express 4.x 的痛点写法 app.use(async (req, res, next) => { try { const result = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]); res.data = result; next(); } catch (err) { // 如果不写这一句,请求就会挂起,客户端超时,服务端没有任何日志 next(err); } });

我那个支付系统当时就是因为少写了几个 next(err),导致回调接口在银行侧超时后,我们的服务没有任何响应,最后积压了 2000 多笔订单状态未更新。

Express 5.x 的改进在于,它开始原生支持 Promise。这意味着如果你返回一个被 reject 的 Promise,或者 throw 错误,Express 会自动将其转发给错误中间件。

// Express 5.x 预期中的写法(基于 5.0.0-alpha.8 特性) // 注意:目前如果要在 4.x 环境模拟这种体验,通常需要配合 express-async-errors 库 const express = require('express'); const app = express(); app.use(express.json()); // 假设这是一个 5.x 环境或已打补丁的环境 app.get('/api/order/:id', async (req, res, next) => { // 这里如果 db.query 失败,不需要 try-catch,5.x 会自动触发 next(err) const order = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]); if (!order) { // 直接 throw,5.x 能接住 const err = new Error('Order not found'); err.status = 404; throw err; } res.json(order); }); // 错误中间件 app.use((err, req, res, next) => { console.error(`[${new Date().toISOString()}] Error:`, err.message); res.status(err.status || 500).json({ code: -1, msg: err.message }); });

为什么这个演进很重要? 因为在 2024 年,几乎没人写纯回调了。如果框架不原生适配 async/await,我们就要在每一个中间件里写大量的样板代码(Boilerplate)。根据社区的趋势,Express 5.x 还会深度整合 TypeScript,这意味着我们以后在定义 reqres 的类型时,不再需要去 @types/express 里找各种补丁方案,类型定义会更加完善。

对于现在的项目,我建议的策略是:如果还在用 4.x,请务必引入 express-async-errors 这个包,它从原型链层面解决了这个问题;如果是新项目,可以关注 5.x 的进展,但生产环境还是暂时锁定 4.19.x,毕竟 Alpha 阶段意味着 API 可能会有变动(比如 res.sendfile 被移除等破坏性变更)。

百万日活社区实战:基于express.Router的模块化路由拆分与重构

去年接手一个日活 120 万+的社区后端项目时,我打开 app.js 直接傻眼了。所有的路由——从用户登录、帖子发布到后台审核——全堆在一个文件里,光 app.post 就有 80 多个。每次改一个接口,我都得全局搜索半天,生怕改错了别的逻辑。

这种写法在初期可能觉得快,但一旦业务量上来,维护成本是指数级上升的。Express 提供了 express.Router 来解决这个问题,但在实际项目中,怎么拆、拆多细,是有讲究的。

我当时的重构思路不是简单的“按文件拆分”,而是按业务领域(Domain)拆分

比如,我们将“帖子(Post)”相关的所有逻辑,包括 CRUD、点赞、收藏、评论,全部封装在一个独立的 Router 里。

// routes/post.js const express = require('express'); const router = express.Router(); const { authMiddleware } = require('../middlewares/auth'); const postController = require('../controllers/post'); // 这里可以定义只属于 post 路由的中间件 // 例如:记录帖子模块的访问耗时 router.use((req, res, next) => { req.startTime = Date.now(); next(); }); // 获取帖子列表 router.get('/', postController.list); // 发布帖子,需要鉴权 // 注意:这里的 authMiddleware 只作用于这个路由组 router.post('/publish', authMiddleware, postController.publish); // 动态路由参数,获取帖子详情 router.get('/:postId', postController.detail); // 嵌套路由:处理帖子的评论 // 这种写法在大型项目中非常清晰,表明评论是依附于帖子的资源 router.post('/:postId/comments', authMiddleware, postController.addComment); module.exports = router;

然后在主入口 app.js 中,通过 app.use 进行挂载。这里有个细节:路径前缀的设计

// app.js const express = require('express'); const app = express(); const postRouter = require('./routes/post'); const userRouter = require('./routes/user'); app.use(express.json()); // 挂载路由 // 为什么是 /api/v1/posts 而不是 /posts? // 因为我们要考虑版本迭代,未来如果数据结构大变,可以直接开 /api/v2/ app.use('/api/v1/posts', postRouter); app.use('/api/v1/users', userRouter); app.listen(3000, () => { console.log('Server running on port 3000'); });

为什么这么做? 除了代码清晰,最大的好处是中间件隔离

在我们的社区项目中,用户模块需要记录详细的登录日志,而帖子模块不需要。如果全堆在一起,我可能要在每一个用户接口里手动加日志逻辑。现在,我只在 userRouteruse 一个日志中间件,帖子模块完全不受影响。

另外,express.Router 支持嵌套。我们当时有一个“后台管理”模块,路径是 /admin。在这个 Router 里,我又拆分了 adminUserRouteradminPostRouter。这种树状结构让路由管理变得非常像管理文件夹。

重构后的结果是,单个文件的代码量从 2000 行降到了 300 行以内,新来的同事找接口速度提升了至少 50%。而且,由于路由模块化,我们在做单元测试时,可以直接 require 某个 Router 进行隔离测试,不需要启动整个应用。

性能血案复盘:一次因错误中间件顺序导致的QPS下降30%排查实录

那是去年双十一大促前的一个晚上,我们的 API 网关突然告警,QPS 从平时的 8000 掉到了 5000 多,响应时间从平均 80ms 飙升到了 400ms。当时我盯着监控面板,以为是数据库扛不住了,结果查了半天数据库连接池,一切正常。

最后排查下来,原因让我非常无语:我把一个极其耗时的中间件,写在了 express.json() 解析中间件的前面。

事情是这样的。当时为了做全链路追踪,我加了一个自定义的中间件,用来给每一个请求生成一个 traceId。为了记录请求体内容,我在中间件里写了这么一段逻辑:

// 错误示范:一个致命的中间件顺序 const express = require('express'); const app = express(); // 自定义日志中间件 app.use(async (req, res, next) => { const start = Date.now(); // 这里为了拿到完整的 body 进行日志打印,我手动读取了流 // 注意:在 express.json() 之前,req.body 是 undefined // 如果在这里读取 req 流,会导致后面的 express.json() 读不到数据 // 或者导致请求被阻塞直到超时 if (req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { req.rawBody = body; // 手动挂载 console.log(`Trace: ${req.url}, Body: ${body}`); next(); }); } else { next(); } }); // 解析 JSON 的中间件 app.use(express.json()); app.post('/api/pay', (req, res) => { // 如果上面的中间件把流读完了,这里的 req.body 可能是空的 res.json({ success: true }); });

为什么这会导致 QPS 暴降?

正确的解决方案:

我最后采用了“先解析,后处理”的策略,并且利用 express.json() 提供的 verify 选项来保留原始数据,而不是自己手动去读流。

// 正确做法:利用 verify 函数保留原始 body const express = require('express'); const app = express(); // 1. 先解析 JSON app.use(express.json({ verify: (req, res, buf) => { // 在解析完成后,把原始 buffer 挂载到 req 上 // 这样既不破坏流,又能拿到原始数据 req.rawBody = buf.toString(); } })); // 2. 再执行日志中间件 app.use((req, res, next) => { const start = Date.now(); // 现在 req.body 已经存在了,req.rawBody 也有原始数据 if (req.method === 'POST' && req.rawBody) { console.log(`Trace: ${req.url}, Body: ${req.rawBody}`); } // 监听响应结束,记录耗时 res.on('finish', () => { const duration = Date.now() - start; console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`); }); next(); }); app.post('/api/pay', (req, res) => { // 这里 req.body 正常可用 console.log('Received payment:', req.body); res.json({ success: true }); });

修改上线后,QPS 立马回升到了 8000+,响应时间也回到了 100ms 以内。

这个案例给我的教训是:中间件的顺序就是 Express 的生命周期。 通用的、前置的(如 CORS、安全头)放最前面;解析请求体的放中间;具体的业务逻辑和错误处理放最后。千万不要为了图方便,在请求解析之前去操作请求体流,那是 Node.js 网络编程里的“禁区”。

4. 异步陷阱与错误吞没:Express 4.x到5.x错误处理中间件的演进与最佳实践

去年双十一大促,我们那个订单系统的支付回调接口突然出现了大量未处理异常。当时监控显示,有接近 15% 的异步回调因为网络抖动抛出了 ECONNRESET,但日志里却一片平静,没有任何错误堆栈。我花了两个小时才定位到问题:一个负责记录日志的中间件里用了 async 函数,但忘了 try/catch,而 Express 4.19.x 并不会自动捕获这种 Promise 的拒绝。

这就是 Express 4.x 里最让人头疼的错误吞没问题。在 4.x 版本中,如果你在中间件里写了 async 函数,一旦 await 后面的 Promise 被 reject,Express 的默认机制是感知不到的。它只会让这个请求挂起,直到超时。

// Express 4.x 中的典型错误吞没场景 const express = require('express'); const app = express(); app.use(express.json()); // 这是一个有问题的中间件 app.use(async (req, res, next) => { // 假设 fetchExternalData 挂了,这里会 reject const data = await fetchExternalData(req.body.id); req.data = data; next(); // 这行代码永远不会执行,且错误不会被捕获 }); // 错误中间件 app.use((err, req, res, next) => { console.error('捕获到错误:', err.message); res.status(500).send('Server Error'); }); app.listen(3000);

在上面的代码里,如果 fetchExternalData 失败,请求就像掉进了黑洞。为了解决这个问题,我们当时不得不在每一个异步中间件里都包裹一层 try/catch,或者引入一个像 express-async-errors 这样的补丁库。这在代码量达到 3 万多行的项目里,维护成本非常高,而且新人经常忘记写。

为什么 Express 5.x 让我决定升级测试版?

Express 5.0.0-alpha.8(发布于 2024年5月)针对这个问题做了根本性的改变。5.x 原生支持了 Promise,如果中间件返回一个被拒绝的 Promise,Express 会自动把它转发给错误中间件。这意味着我们可以彻底扔掉那些烦人的 try/catch

// Express 5.x 原生支持 async 错误处理 const express = require('express'); const app = express(); app.use(express.json()); // 在 5.x 中,这个 reject 会被自动捕获并传递给错误中间件 app.use(async (req, res, next) => { const data = await fetchExternalData(req.body.id); req.data = data; next(); }); // 错误中间件依然有效 app.use((err, req, res, next) => { console.error('5.x 捕获到异步错误:', err.message); res.status(500).json({ error: 'Internal Server Error' }); }); app.listen(3000);

我在本地环境把核心模块迁移到 5.x 测试后,发现未捕获异常导致的 502 错误直接降到了 0。不过,5.x 目前还是 Alpha 阶段,生产环境直接上还是有点冒险。所以我现在的生产策略是:在 4.x 环境中,封装一个 asyncHandler 工具函数

这是我目前项目里正在用的 asyncHandler 实现,它比网上那些简单的 wrap 更实用,因为它保留了 next 函数的灵活性:

/** * 包裹异步中间件,防止 Express 4.x 错误吞没 * @param {Function} fn 异步中间件函数 */ const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; // 使用方式 app.get('/api/orders/:id', asyncHandler(async (req, res) => { const order = await db.Order.findById(req.params.id); if (!order) { // 这里抛出的错误会被 asyncHandler 捕获并传给错误中间件 const err = new Error('Order Not Found'); err.status = 404; throw err; } res.json(order); }));

为什么这么做? 如果不封装这个,每次新增接口我都要担心是不是忘了处理异常。用了这个,我只需要关注业务逻辑。我们那个订单系统接入这个模式后,异常日志的捕获率从之前的 85% 提升到了 99.9%,排查线上问题的效率提升了至少 3 倍。

---

5. 横向选型对比:Express vs Fastify,为什么高并发场景我们最终没换掉它

去年我们重构一个面向 C 端的商品详情页接口,预估峰值 QPS 会达到 8000+。当时团队里有人提议换成 Fastify,理由是它的基准测试(Benchmark)数据显示吞吐量比 Express 高很多,而且 JSON 序列化有优化。

我确实去做了详细的压测对比。我模拟了一个真实的场景:接口需要解析 JWT、查询 Redis 缓存、并格式化返回数据。

测试环境:

压测结果:

数据上看 Fastify 确实赢了。但我最终还是决定留在 Express。原因不是性能,而是工程成本和生态稳定性

我们那个项目里集成了 12 个中间件,包括 passport 做鉴权、swagger-ui-express 做文档、express-rate-limit 防刷。当我尝试把这些迁移到 Fastify 时,发现虽然 Fastify 也有对应的插件,但配置方式完全不同。比如 passport 在 Express 里是几行代码的事,在 Fastify 里需要引入 fastify-passport 并且配置钩子(Hooks),这改动量巨大。

更重要的是,我们团队有 5 个后端开发,大家最熟悉的都是 Express。如果强行切 Fastify,学习成本和代码 Review 的门槛都会变高。

为什么 Express 依然适合我们?

Express 的核心优势在于它的中间件模型极其简单且稳定。在 4.x 版本中,app.usenext() 的机制已经运行了快 10 年,几乎没有 Bug。而 Fastify 的 Hooks 机制(onRequest, preHandler, onSend 等)虽然功能强大,但对于大多数 CRUD 业务来说,有点过度设计了。

我并没有完全否定 Fastify,我在另一个全新的、对性能极其敏感的内部日志采集服务里用了它,效果很好。但对于这个商品详情页,我选择了优化 Express

我通过以下方式把 Express 的 QPS 提升了 20%:

const express = require('express'); const app = express(); // 优化前:全局挂载,所有请求都走一遍解析 // app.use(express.json()); // 优化后:按需挂载 // 只有需要解析 body 的路由才使用 app.post('/api/data', express.json(), (req, res) => { // 处理逻辑 }); // 静态资源使用内置的 express.static,这是 4.x 里性能最好的静态托管方式 app.use('/static', express.static('public', { maxAge: '1d', // 缓存一天 etag: false // 关闭 etag 减少计算 }));

经过这番调整,Express 的 RPS 稳定在了 7500 左右,虽然还是比 Fastify 低一点,但已经完全满足了 8000 QPS 的峰值需求(因为我们有 2 台服务器做负载均衡)。

我的判断是:除非你的 QPS 瓶颈卡在框架本身(这种情况通常意味着架构有问题,比如没做缓存或数据库慢),否则 Express 的性能完全够用。而且 Express 5.x 正在优化路由匹配算法,未来性能还会进一步提升。对于大多数团队,换框架带来的收益远不如优化数据库查询和缓存策略来得实在。

---

6. 面向边缘计算与Serverless:Express中间件瘦身与冷启动优化策略

上个月我们接了一个需求,要把一个 Express 服务部署到 AWS Lambda 上,作为 Serverless 应用对外提供 API。上线第一天就遇到了冷启动问题:第一次请求耗时 1200ms,而后续请求只有 50ms。用户反馈说“点进去要等一下”,体验很差。

我排查了一下,发现我们的 app.js 里引入了太多东西。光是中间件就加载了 20 多个,包括 morgan 日志、helmet 安全头、cors 跨域,还有一些自定义的鉴权中间件。在 Lambda 环境下,每次冷启动都要重新执行 require 和初始化这些中间件,耗时非常可观。

Express 在 Serverless 环境下的痛点

Express 5.x 的发展趋势里提到了“边缘计算适配”,目前的 4.x 确实有点重。在 Lambda 这种按毫秒计费的场景,启动慢意味着钱花得多。而且 Lambda 是无状态的,像 morgan 这种写本地文件的日志中间件其实没啥用,因为日志不会持久化。

我是怎么给 Express 瘦身的?

我没有直接删代码,而是做了一层环境感知的加载逻辑。在本地开发时,我保留所有调试和日志中间件;在 Lambda 环境(通过 process.env.AWS_LAMBDA_FUNCTION_NAME 判断)下,我只加载最核心的中间件。

const express = require('express'); const app = express(); // 基础中间件,必须加载 app.use(express.json()); // 环境感知的中间件加载 if (process.env.AWS_LAMBDA_FUNCTION_NAME) { // Serverless 环境:只保留核心逻辑 console.log('Running in Serverless mode, loading minimal middleware.'); // 使用轻量级的 CORS 配置,而不是引入整个 cors 库 app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Authorization, Content-Type'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); }); // 这里只挂载 API 路由,不挂载静态资源(静态资源交给 CDN) app.use('/api', require('./routes/api')); } else { // 本地或 ECS 环境:加载完整中间件 const morgan = require('morgan'); const helmet = require('helmet'); app.use(morgan('dev')); app.use(helmet()); app.use(require('cors')()); app.use('/api', require('./routes/api')); app.use(express.static('public')); } // 优化前,冷启动耗时 1200ms // 优化后,冷启动耗时 350ms

具体的优化细节:

为什么不直接用别的框架?

我也试过把 Express 换成 serverless-http 包装器,但发现它在处理二进制文件(如图片上传)时有点问题。而且我们的团队对 Express 的 reqres 对象非常熟悉。

经过这轮优化,我们的 Lambda 函数冷启动时间从 1200ms 降到了 350ms,内存占用从 180MB 降到了 95MB。虽然还是比原生适配 Serverless 的框架(如 Nitric 或 Hono)慢一点,但已经完全在可接受范围内了。

对于边缘计算(如 Cloudflare Workers),Express 目前确实有点吃力,因为它的 Node.js API 依赖较重。但在 AWS Lambda 这种 Node.js 环境里,通过合理的中间件瘦身环境隔离,Express 依然是一个性价比极高的选择。毕竟,不需要重写代码就能把现有服务迁移到 Serverless,这省下的开发时间比那几百毫秒的冷启动更有价值。

站长实战手记

一个让我半夜爬起来改代码的线上事故

去年我给一个做在线教育的客户重构后台,业务说白了就是课程打卡、作业提交、消息推送这些。当时我拍板用 Express 5.x + TypeScript,路由按业务拆成了十几个 express.Router,自我感觉良好,结构清晰得像教科书。

结果上线第三天,晚上九点打卡高峰期,监控突然报警:API 响应时间从 50ms 飙到 800ms,QPS 直接掉了三成。我连夜 SSH 上去抓日志,发现请求都在一个奇怪的地方排队。

排查了半天才发现,我在入口处挂了一个全局的请求日志中间件,为了图省事,我把它放在了 express.json() 解析中间件之前。这意味着每一个请求,不管大小,都得先过这个日志中间件,而我在里面写了个异步的 await writeLog()。Express 5.x 虽然支持了 Promise,但中间件队列是串行的,这个慢吞吞的日志直接卡住了后面所有的解析和业务逻辑。

最后我干了件很蠢但有效的事:把那个日志中间件挪到了路由之后,并且改成了 fire-and-forget 模式,不阻塞主流程。改完重启,QPS 瞬间回血。

我的真实取舍看法

* Express 依然是我做 BFF(Backend for Frontend)的首选。如果你是在做 Serverless 或者边缘函数,别犹豫,直接上 Hono 或者 Fastify,Express 的包袱在那儿确实重。

* 别为了“优雅”过度拆分 Router。我见过有人一个接口一个文件,最后 app.use 写了一屏幕,维护起来想骂人。

* 中间件不是万能胶。业务逻辑能写在 Service 层的就别往中间件里塞,中间件只负责管道处理(鉴权、日志、CORS)。

给读者的真心话

大家学中间件的时候,别光看文档里 next() 怎么传,一定要亲手去试一下中间件顺序错了会怎样。很多时候线上的性能血案,不是代码逻辑错了,而是那个 app.use 的顺序摆错了位置。多去搞点压测,别让中间件成了你系统的隐形瓶颈。