Engineering

Hono 到底是什么:从 app.fetch 到多运行时适配

Hono 不是一个监听端口的 HTTP Server,而是一套以 Web Standard Request/Response 为边界的请求处理框架。理解这个边界,就能看清 Router、Context、Middleware 和 Adapter 各自负责什么。

第一次看到下面这段代码时,很容易产生一个疑问:

serve({
  fetch: app.fetch,
  port: 3000,
})

如果 app 已经是一个 Hono 应用,为什么还需要 serve()?Hono 自己不是 HTTP Server 吗?

答案是:Hono 的核心不是一个负责监听端口的服务器,而是一套请求处理框架。它接收标准 Request,返回标准 Response

app.fetch(request: Request): Response | Promise<Response>

至于请求如何从 Node.js、AWS Lambda、Cloudflare Workers 或 Bun 到达这里,由运行时或 Adapter 负责。

这个边界是理解 Hono 的起点。

把一次请求拆成五层

Hono 的核心可以压缩成五层:

Web Standard HTTP 抽象
+ Router 匹配
+ Context 状态容器
+ Middleware compose 调度
+ TypeScript 类型传播

它们解决的是不同问题。

层次负责什么不负责什么
Web Standard HTTPRequest / Response 统一请求模型不监听端口
Router根据 method 和 path 找出匹配项不执行 handler
Context保存一次请求中的共享状态不决定执行顺序
compose调度 middleware 和 handler不做路由匹配
TypeScript 类型传播参数、环境和响应类型不校验外部真实数据

完整链路可以写成:

运行时 Server 或 Event Runtime
-> Adapter
-> Web Standard Request
-> app.fetch()
-> Router.match()
-> Context
-> compose(matched handlers)
-> Web Standard Response
-> Adapter 写回运行时

框架的复杂度并没有消失,而是被分配到了边界清晰的组件中。

Adapter 为什么存在

以 Node.js 为例,原生 HTTP Server 使用的是:

IncomingMessage
ServerResponse

Hono 使用的是:

Request
Response

@hono/node-server 的核心职责就是在两组接口之间翻译:

IncomingMessage
-> Web Request
-> app.fetch()
-> Web Response
-> ServerResponse

在原生支持 Web Fetch API 的运行时中,这一层可能很薄;在 AWS Lambda 这类事件运行时中,Adapter 还要完成 Event 与 Request、Response 与 Lambda Result 的双向转换。

这带来一个重要结果:Hono 核心不需要理解每个平台的请求对象。增加运行时支持时,变化主要停留在边界层。

Router 只回答“谁匹配”

注册代码:

app.use('*', logger())
app.use('/api/*', auth())
app.get('/api/users/:id', handler)

不会立即执行这些函数。Hono 先把它们注册进 Router。请求到达后,Router 根据 method 和 path 找出匹配项,并保持注册顺序。

Hono 默认使用 SmartRouter。它在第一次真正匹配时,优先尝试性能更高的 RegExpRouter;如果路由表达超出其支持范围,再退到更通用的 TrieRouter

这个选择只发生在 matcher 建立阶段,不是每次请求都重新判断。代价是 matcher 建立后,不再适合继续动态追加路由。

因此默认 Router 适合路由集合在服务启动时已经确定的普通应用;插件系统、运行时动态挂载路由等场景,需要更明确地考虑 TrieRouter 或自己的生命周期约束。

Middleware 和 route handler 本质上是同一种函数

在运行时,middleware 和 route handler 都可以看成:

type Handler = (
  context: Context,
  next: () => Promise<void>
) => Response | void | Promise<Response | void>

差异主要来自职责:

  • middleware 通常调用 await next(),把控制权交给下一层。
  • route handler 通常返回 Response,结束请求。
  • middleware 也可以不调用 next(),直接返回 401 或其他响应。

Router 得到匹配项后,compose() 会把它们组合成一条执行链。简化后的核心是:

function compose(handlers) {
  return function run(context) {
    let index = -1

    return dispatch(0)

    async function dispatch(i) {
      if (i <= index) {
        throw new Error('next() called multiple times')
      }

      index = i
      const handler = handlers[i]

      if (!handler) return

      return handler(context, () => dispatch(i + 1))
    }
  }
}

next 不是关键字。它只是 compose() 在运行时创建并传给当前 handler 的闭包:

() => dispatch(i + 1)

洋葱模型不是额外魔法

假设执行链为 A、B、handler:

const A = async (c, next) => {
  console.log('A before')
  await next()
  console.log('A after')
}

const B = async (c, next) => {
  console.log('B before')
  await next()
  console.log('B after')
}

输出会是:

A before
B before
handler
B after
A after

所谓洋葱模型,本质上是普通函数调用栈和 await

  1. A 在 await next() 处暂停。
  2. B 在自己的 await next() 处暂停。
  3. handler 返回。
  4. B 继续执行。
  5. A 继续执行。

理解这一点之后,中间件顺序、提前返回、错误传播和重复调用 next() 的问题都会变得具体。

Context 是一次请求的共享容器

Router 负责找出匹配项,compose 负责执行,Context 则承载一次请求中的共享状态,例如:

  • 当前请求和路径参数。
  • 环境变量与平台绑定。
  • 校验后的数据。
  • 中间件写入的业务状态。
  • 最终响应与错误状态。

它把 middleware 之间需要共享的数据放在请求生命周期内,而不是塞进全局变量。

应用级路由表和 handler 通常长期存在;Context、dispatch 索引和 next 闭包只属于一次请求。请求结束且没有其他异步任务继续引用它们后,这些状态就可以被回收。

类型安全有两个层次

Hono 的 TypeScript 类型系统可以把路径参数、环境变量和响应类型传播到后续代码,但客户端提交的数据在运行时仍然只是外部输入。

因此类型安全需要两层配合:

Validator / Schema
-> 在运行时证明输入满足约束

TypeScript 类型传播
-> 让校验结果在后续代码中可用

只写泛型不能阻止客户端发送错误数据;只做运行时校验而不传播类型,又会损失开发体验。

为什么这个模型值得理解

Hono 的价值不只在于它小或快,而在于它把 Web 框架的核心边界暴露得足够清楚:

Adapter 统一运行时差异
Router 决定谁匹配
Context 保存请求状态
compose 决定如何执行
类型系统约束编译期关系
Validator 检查运行时输入

更重量级的框架也在处理相似问题,只是它们还叠加了容器、注解、反射、AOP 和更多基础设施。

先通过 Hono 看清一条请求如何从运行时进入框架、经过匹配和中间件、最终返回响应,再去理解其他 Web 框架,会更容易分辨哪些是通用结构,哪些只是特定生态的实现方式。

Back to Blog

Related Posts

View All Posts »