我们团队上个月接手了一个老项目。300 万日活的 API 网关,跑在 Express 4 上,中间件链 38 个,平均每个请求要穿 11 层。高峰期 p99 延迟 860ms,CPU 长期 70% 以上。

老板说优化一下。我们花了 5 天,把核心链路用 Hono 重写了一遍。结果 p99 从 860ms 降到 320ms。吞吐量从 2800 req/s 涨到 6400 req/s。

真香。

为什么要动 Express 这根老骨头

Express 的性能瓶颈不是秘密。它的中间件模型建立在回调之上,每个 next() 都是一次函数调用栈的跳转。在 V8 引擎下,深层嵌套的回调链会反复触发栈重建和上下文切换。

我们的压测数据是:一个 6 层中间件链(含日志、鉴权、限流、路由、校验、响应),Express 单实例极限 2900 req/s。Hono 同样的 6 层逻辑,直接干到 6800 req/s。

说实话,这个差距不完全怪 Express。Express 设计于 2010 年,那时候 Node.js 还没有 Promise 原生支持。它的异步模型是时代的产物。但时代变了,Web 标准有了 Request 和 Response 对象,fetch API 成了默认姿势。Hono 从一开始就站在了这个现代地基上。

另外一个直接动因:我们团队里 3 个新同学花了 2 天才理清楚一条响应链路里 res.json() 和 next() 的执行顺序。Express 的中间件签名是 (req, res, next),这没问题。问题在于当你需要同时处理响应和错误时,逻辑开始绕了。

血泪教训:你永远无法让回调风格代码变得可推导。

Hono 的中间件模型,和 Express 有什么本质不同

Hono 用 app.use() 注册中间件,但它的中间件是 (c, next) 签名。c 是 Context,封装了 Request 和 Response。每一个中间件可以是 async 函数,await next() 之后的代码会在后续中间件执行完毕后继续运行。

这就解决了 Express 最大的痛点——请求生命周期内的时序控制。在 Express 里,你想要在响应发出之后执行日志,得监听 res.on('finish')。在 Hono 里,你在 await next() 之后写日志就行。

我们用 5 个中间件做了一次对比测试。

我先说结论:在开发体验上,Hono 的 await next() 模型接近 Koa 2,但比 Koa 2 更轻。Koa 2 最小应用带依赖约 12MB,Hono 零依赖,就一个 14KB 的文件。

我们有个小应用,在 Express 下全量依赖 npm install 要 8.6 秒。Hono 只要 1.2 秒。

Day 1 下午,我们把一个 5 个中间件的服务重写完毕。功能完全一致,测试用例全绿。

对比维度 Express 4 Hono 3
最小中间件链耗时(3 层) 1.8ms 0.9ms
6 层中间件链耗时 4.6ms 1.7ms
冷启动时间(生产容器) 920ms 340ms
依赖体积(含子依赖) 42MB 23KB
p99 延迟(线上 300 万日活) 860ms 320ms
峰值吞吐(单实例 2C4G) 2800 req/s 6400 req/s
内存占用(稳态) 210MB 155MB

表中数据来自我们的测试环境和线上灰度。测试环境是两台 2C4G 的阿里云 ECS,Node 20.11,压测工具 wrk 跑了 10 分钟,每个场景 3 轮取中位数。

踩坑实况:这 4 个坑差点让我们回滚

坑 1:getClientIP 与反向代理

Day 2,我们上线了第一个 Hono 服务。线上跑了一个小时,发现所有用户 IP 都是 127.0.0.1。

困惑了半小时。后来看 Hono 文档里写着 getClientIP() 默认从 env 里读,不直接解析 x-forwarded-for。Express 的 req.ip 会自动基于 trust proxy 处理。Hono 需要你手动指定。

解法:在入口处自己写一个中间件,把 x-real-ip 或 x-forwarded-for 的值挂到 c.set('ip', ...),后面直接用 c.get('ip')。

这个坑算不上 bug,但如果你从 Express 迁移过来,第一眼就会踩。我们团队 4 个后端,3 个踩了同一次。

坑 2:异步中间件的顺序陷阱

Hono 的 await next() 赋予了中间件洋葱模型的能力,但你把代码放在 await next() 前后的语义完全不同。放在前面,作为前置拦截;放在后面,作为后置处理。

我们曾经写过一个日志中间件:

app.use(async (c, next) => {
  console.log('start', c.req.path)
  await next()
  console.log('end', c.req.path)
})

这个写法没问题。有问题的是我们在后置代码里访问了 c.req.text()。在响应阶段,请求体已经不可读。这个 bug 导致线上 2% 的 POST 请求日志缺失。

解法很简单:任何对请求体的访问,必须放在 await next() 之前。

坑 3:原生 c.req.param() 只匹配当前路由

Express 里,中间件通过 req.params 可以访问挂载路由的参数。Hono 里,如果你在中间件里调用 c.req.param('id'),它只会返回当前路由的匹配值。如果你在一个全局中间件里想拿路径参数,拿不到。

这个差异非常隐蔽。我们有一个路由鉴权逻辑,需要在中间件里读取 userId。Express 里直接 req.params.userId 就拿到了。Hono 里必须在路由匹配之后才能拿。

解法建议:在需要参数的地方用路由级中间件,不要用全局中间件。例如:

app.get('/users/:id', authMiddleware, handler)

把 authMiddleware 绑在路由上,c.req.param('id') 就好使了。

这个坑让我们 Day 3 凌晨 2 点回滚过一个版本,耗时 1 小时。

坑 4:Hono 的 c.env 不是万能的

Hono 支持 .env 文件吗?不支持。它推荐你把环境变量放在 c.env 里,但这个 c.env 实际是运行平台注入的。在 Node.js 上,它默认指向 process.env。

但我们有一个服务里有自定义的配置中心,把配置挂在 globalThis 上。在 Express 里可以直接访问。换到 Hono 后,如果在中间件里访问 globalThis,没问题。但如果用 c.env 指向的自定义属性,得手动绑定到 app.request 上,否则取不到。

说实话,这个坑不算 Hono 的错,更多是我们的架构没有遵循规范。但迁移过程中,它确实额外消耗了我们半天时间。

迁移策略:不需要重写全部

很多团队一说重写,就把所有代码推翻重来。我们的策略是:以路由为单位,渐进迁移。

具体做法:

  1. 保持 Express 服务作为入口不动。
  2. 新增一个 Hono 应用,挂载到 /v2 前缀。
  3. 用 HTTP_PROXY 或者 Node 的 http-proxy-middleware 把 /v2 请求转发给 Hono。
  4. 每迁移一个路由组,就把 Express 里对应路由注释掉。
  5. 全部迁移完成,把 Hono 应用提升为入口。

第 2 周,我们完成了全量迁移。36 个路由,全部切到 Hono。Express 服务退役。

这样做的最大好处是:每个阶段都可回滚。我们只在 Day 3 凌晨回滚过一次,就是上面写到的 param 的坑。其余时间都是平滑过渡。

回头看,这个渐进策略比"一夜重写"稳得多。我们团队规模 5 人,没有专职测试,全靠自动化回归。渐进迁移让我们每天都能保持可发布状态。

性能实测的细节

压测方案:wrk 线程 8,连接 1000,时长 60s,每个场景 3 轮。

场景一:纯中间件链(6 层,全部空操作)

  • Express:4600 req/s,延迟 p50 8.2ms,p99 12.4ms
  • Hono:10300 req/s,延迟 p50 3.7ms,p99 6.1ms

场景二:真实业务链(日志 + 鉴权 + 限流 + 路由 + 数据校验 + 业务逻辑 + 响应)

  • Express:2800 req/s,p50 12ms,p99 860ms(线上)
  • Hono:6400 req/s,p50 5.8ms,p99 320ms(线上)

这里有一个细节值得注意:Hono 的吞吐量提升,不只是因为框架轻。它在内部对路由做了基于 RegExp 的预编译,而且对 Request 的解析是按需懒加载。这意味着你在中间件里不访问 c.req.body,它永远不会去解析 body。Express 会在某些路径下提前解析。

这个差异解释了一个现象:我们线上有个只做健康检查的 /healthz 路由,在 Express 下 p99 是 45ms。在 Hono 下是 6ms。因为 Hono 不碰 body,而 Express 会走一遍 body-parser(我们全局挂载了)。

我们团队的测试环境数据也支持这一点:一个只返回 {ok:1} 的空路由,Express 单实例 9800 req/s,Hono 单实例 21000 req/s。

说实话,差距大到我们一开始怀疑压测工具出了问题。后来用 autocannon 做交叉验证,数据基本一致。

值得警惕的适配性问题

Hono 不是银弹。有一些场景它存在明显短板。

Socket.io:Hono 官方没有适配器。你在 Hono 里集成的办法是手动挂一个 socket.io 实例到 Node 的 http server 上。能跑,但中间件模型和 socket 事件是两套体系,开发体验割裂。我们目前有一个实时通知服务,还是留在 Express。

文件上传:Hono 支持 c.req.formData(),但超大型文件的流式处理体验不如 Express 里的 multer 成熟。Hono 社区里有 hono/middleware,但生态丰富度不如 Express。我们用 Hono 处理超过 50MB 的上传时,需要自己写流处理逻辑。

数据库事务中间件:我们有一组老代码,依赖 Express 的中间件来开启和提交数据库事务。Hono 的 await next() 模型在做事务时反而更别扭。因为事务需要在业务逻辑执行完毕后提交,但 Hono 的中间件在 await next() 后拿不到具体的响应业务数据。我们最终用了一个 app.use(async (c, next) => { await next(); /* 手动判断业务码 */ }) 的方式,绕了一点弯路。

但我也要承认 Hono 在 Context 状态的传递上不如 Express 的请求对象直观。Express 里你可以在 req 上任意挂属性,Hono 里你得用 c.set('key', value) 和 c.get('key')。这个有类型约束,是好事,但确实要写更多代码。我们用了一个 Context 的 helper 类来统一存取值,代码量增加约 15%。

另外,Hono 3 的 TypeScript 类型支持非常强。我们的接口路由和参数类型是共享的,从 @hono/zod-validator 拿到类型推导,整个链路的类型安全做到了端到端。Express 生态里没有同等体验的库。

我们团队的一点点经验总结

  • 迁移前先建一套自动化回归脚本。我们用的是 supertest,测试用例从 120 个扩展到 180 个,全绿耗时从 3 分钟降到 42 秒(因为 Hono 的测试不需要起端口,直接拿 app 处理函数调用)。
  • hono 的测试策略和 Express 不同。Hono 的 app.request() 返回一个 Response 对象,可以直接用原生测试框架断言。我们用 Node 内置的 node:test 就能跑,不用 Jest。
  • 新老服务并行期间,我们在网关层做了一个 x-framework 请求头,用来区分流量来源,方便链路追踪对比。
  • Day 5,我们做了 10% 流量灰度,p99 下降 30%,没有报错。
  • Day 7,全量切换,线上无重大事故。

话说回来,Hono 的社区活跃度比不上 Express,但增长很快。npm 周下载量我们上个月查的接近 240 万,两年前这个数字只有 10 万。文档质量在开源项目里算中上,但中文资料很少。我们团队看官方英文文档加源码,整体熟悉花了 2 天。

我个人认为,对于一个新项目,选 Hono 而不是 Express,是更合适的选择。但存量项目的重写,必须像我们一样,有明确的收益曲线和回滚计划,不要拍脑袋全量上线。

最后我们团队内部在讨论一个问题:既然 Hono 的 await next() 模型这么好用,为什么 Express 5 没有采用?Express 5 走了 Promise 支持但不彻底,它的核心回调模型没变。这个决定让 Express 5 的性能相比 Express 4 提升不到 8%。

我们赌了 Hono,目前看是赌对了。

你的项目里还有多少条 Express 中间件链?它为你扛了多少不必要的延迟?