12k
All articles

编写你自己的 Hono 中间件

构建 Hono 中间件,涵盖类型化上下文变量、请求计时、提前结束和路径作用域,并了解 next() 与响应顺序。

OpenReplay Team
OpenReplay Team
编写你自己的 Hono 中间件

Hono 中间件是一个接收 (c, next) 参数的异步函数。它的结束方式只有两种:一种是 await next() 且不返回任何内容,从而将请求继续传递下去;另一种是返回一个 Response,请求就此终止。

logger() 和 cors() 都是以工厂函数的形式调用的,因此在你动手编写自己的中间件之前,这个函数签名一直隐藏在视线之外。你写的第一个中间件通常都能正常工作。麻烦出现在下一步:当某个处理器读取中间件设置的值时,TypeScript 无法告诉你它的类型是什么。

本文从函数签名出发,逐层构建中间件:两个执行阶段及其运行顺序、一个需要同时用到两个阶段的请求计时器、在中间件被移入独立文件后仍能保持类型的上下文变量、提前退出,以及路径作用域。本文假定你已经跑通了路由,相关内容可参考 Hono 入门指南。如果你是从 Express 转过来的,框架对比和迁移说明覆盖了本文开始之前的那部分内容。文中示例基于 npm 上发布的 Hono 4.13.x 版本。

要点速览

  • Hono 中间件的形式是 (c, next):await next() 之前的代码在请求进入时运行,之后的代码在响应返回时运行。
  • 中间件按注册顺序执行入站逻辑,按注册的逆序执行出站逻辑,因此最先注册的中间件最先看到请求、最后看到响应。
  • 来自 hono/factory 的 createMiddleware() 配合 Variables 泛型,可以在中间件被移入独立文件后仍保持 c 和 next 的类型信息。
  • 返回 Response 而不调用 next() 会终止请求:注册在其后的一切都不会运行。
  • Hono 会捕获处理器和中间件抛出的错误,并将其交给 onError() 或返回 500,因此 next() 永远不会抛出异常。

函数签名与中间件的两种结束方式

中间件接收上下文和 next 函数,Hono 中间件指南只允许它有两种结束方式。它可以通过 await next() 并且完全不返回任何内容,把控制权继续沿链条向下传递;也可以交出一个属于自己的 Response,就地终止请求。另一个基本单元是处理器(handler),它总是产生一个 Response,而单个请求最终只会抵达其中之一。

import { Hono } from 'hono'

const app = new Hono()

app.use(async (c, next) => {
  console.log(`[${c.req.method}] ${c.req.url}`)
  await next()
})

app.get('/', (c) => c.text('Hello!'))

export default app

漏掉 await,函数的语义依然成立,但会失去顺序保证,所以请始终写成 await next()。

洋葱模型:Hono 中间件的执行顺序是怎样的?

放在 await next() 之前的代码在请求进入时运行,放在其之后的代码在响应返回时运行——正因如此,同一个函数才能在处理器执行前启动计时器,并在其执行后把耗时写入响应头。Hono 的概念文档把这种结构描绘成一颗洋葱:每一层都包裹着处理器,并在它的两侧各执行一次。中间件在入站时按注册顺序运行,在出站时按注册的逆序运行,所以你注册的第一个中间件最先看到请求,也最后看到响应。

import { Hono } from 'hono'

const app = new Hono()

app.use(async (_, next) => {
  console.log('timing start')
  await next()
  console.log('timing end')
})
app.use(async (_, next) => {
  console.log('  auth start')
  await next()
  console.log('  auth end')
})
app.use(async (_, next) => {
  console.log('    flags start')
  await next()
  console.log('    flags end')
})

app.get('/', (c) => {
  console.log('      handler')
  return c.text('Hello!')
})

export default app

一次请求会输出:

timing start
  auth start
    flags start
      handler
    flags end
  auth end
timing end

这里的缩进是直接写在日志字符串里的,并非 console.log 生成的。之所以这么做,是因为嵌套关系正是重点所在:每个中间件的出站部分,都被注册在它之前的所有中间件的出站部分所包裹。

一个横跨两个阶段编写的请求计时器

Hono 自带一个 Server-Timing 中间件,它来自 hono/timing,用于输出 Server-Timing 响应头;如果这种格式符合你的需求,它就是正确的选择。不过,手写的计时器仍然是演示两阶段执行最清晰的例子,因为它所报告的那个值,单独在任何一个阶段中都不存在。

import { createMiddleware } from 'hono/factory'

export const responseTime = createMiddleware(async (c, next) => {
  const start = Date.now()
  await next()
  c.header('X-Response-Time', `${Date.now() - start}ms`)
})

在 await next() 之后,响应已经存在,因此 c.header() 会直接写入该响应的头部,而不是写入预备的头部。同一个中间件页面还给出了一个关于 Cloudflare Workers 的注意事项:在那里,计时器锚定的是最后一次 I/O 而非真实流逝的时间,所以数据可能具有误导性。

如何沿链条向下传递带类型的值?

用 c.set() 写入的任何内容都只属于一次请求,并随之消失,因此这个值无法带到下一次请求,也无法在多个请求之间共享。下游代码通过 c.get('key') 或 c.var.key 读回它,两者都在 Context API 页面中有说明。如果内联设置,这些值到达下游时是没有类型的:

app.use(async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})

app.get('/', (c) => c.json({ id: c.var.requestId })) // no type information

来自 hono/factory 的 createMiddleware() 能在中间件被移入独立文件后,仍完整保留 c 和 next 上的类型;而给它传入一个 Variables 泛型,就能让该中间件设置的每个值对后续处理器都是类型安全的。

// middleware/request-id.ts
import { createMiddleware } from 'hono/factory'

export const requestId = createMiddleware<{
  Variables: { requestId: string }
}>(async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})
// index.ts
import { Hono } from 'hono'
import { requestId } from './middleware/request-id'

const app = new Hono().use(requestId).get('/', (c) => {
  return c.json({ id: c.var.requestId }) // string
})

export default app

为上下文变量标注类型有三种机制。默认情况下请选用中间那一行。

机制类型可见范围代价
new Hono<{ Variables }>()该 app 实例上的每一个路由需手工维护合并后的类型,且在每个声明处重复一遍
createMiddleware<{ Variables }>()该中间件下游的路由每个中间件一个泛型
扩展 ContextVariableMap应用中的每一个上下文会给中间件从未运行过的路由也加上类型

一旦扩展 ContextVariableMap,类型就会落到应用中的每一个上下文上,无论设置该值的中间件是否在那个路由上运行过。在它没有运行的地方,c.get() 看起来仍然有类型,但运行时值却是缺失的。Context API 文档中的警告用一对路由说明了这一点:一个使用了该中间件,另一个没有。请改为把类型限定在中间件的作用域内。

如何在不调用 next() 的情况下提前终止?

返回一个 Response 并跳过 next(),请求就在此结束:后续中间件不会运行,处理器也不会运行。

import { createMiddleware } from 'hono/factory'

export const apiKeyAuth = createMiddleware(async (c, next) => {
  if (c.req.header('X-API-Key') !== 'expected-key') {
    return c.text('Unauthorized', 401)
  }
  await next()
})

注册在该中间件之后的一切都不会运行,包括那些本应包裹它的中间件的出站部分。而注册在它之前的中间件,其出站部分仍会运行,因此最先注册的计时器依然会给这个 401 响应打上耗时标记。

如何把 Hono 中间件限定到特定路径?

把路径模式作为第一个参数传给 app.use(),即可将中间件限制在匹配的路由上;同时请记住,执行顺序就是注册顺序。

app.use(responseTime)          // every route
app.use('/api/*', apiKeyAuth)  // only /api/**
app.get('/api/orders', (c) => c.json([]))

responseTime 先注册,因此它包裹了 apiKeyAuth,连被拒绝的请求也会被计时。把这两行调换位置,计时器就看不到 401 了。

两个容易让人栽跟头的行为

链条中任何位置抛出的错误——无论来自处理器还是任何中间件——都会被 Hono 自身捕获:如果你定义了 app.onError(),错误会交给它处理,否则会返回一个 500。这就是 next() 永远不会抛出异常的原因,也是把它包在 try/catch 里毫无意义的原因。如果你想记录日志,在 await next() 之后仍可以通过 c.error 拿到该错误。

类型会沿着 .use() 链累加,因此位于两个带类型中间件之后的处理器能同时看到两者的变量。每次 .use() 都会返回一个实例,其类型已经包含了此前累积的内容——这正是中间件指南所说的、大多数应用从来不需要预先写出一个合并 Env 类型的原因。

接下来该做什么

自定义中间件不过是一种带两个半段的函数形态,外加一条关于如何结束的规则。从你写下的第一行起就给它加上 Variables 泛型,把每个中间件放进各自的模块,剩下的交给注册顺序来完成。把上面那个计时器搬进 middleware/ 目录,再用一个返回 createMiddleware() 的普通函数把它包起来以接收配置参数:这就是参数化中间件的全部模式。

常见问题

如何只在某一个路由上运行中间件,而不是每个路由?

使用路由方法来注册它,而不是直接调用 app.use()。Hono 允许你通过 app.HTTP_METHOD 挂载中间件,因此 app.post('/posts/*', basicAuth()) 只会作用于该路径上的 POST 请求;而把中间件作为参数放在处理器之前传入,例如 app.get('/echo', echoMiddleware, handler),则可以把它限定在这一个路由上,而不影响应用的其余部分。

中间件会作用于在它之前注册的路由吗?

请把中间件注册在它应当包裹的处理器之上。Hono 按注册顺序执行处理器和中间件,路由文档把这条规则讲得很直白:任何你希望在处理器之前运行的东西,都必须注册在它前面。在 app.use() 调用之前声明的路由会先被匹配,因此之后添加的中间件不会可靠地成为该路由链条的一部分。

Env 泛型中的 Bindings 和 Variables 有什么区别?

Bindings 描述的是运行时注入的平台资源和环境值,通过 c.env 读取,例如 Cloudflare Workers 上的 D1 数据库或某个密钥。Variables 描述的是你自己的代码用 c.set() 写入、用 c.get() 或 c.var 读取的、每次请求独有的值。两者都位于同一个 Env 对象上,通过 new Hono<{ Bindings: ...; Variables: ... }>() 传入。

我能在处理器或中间件之外读取上下文变量吗?

可以,借助内置的 Context Storage 中间件。先从 'hono/context-storage' 引入并注册 app.use(contextStorage()),然后在任意函数内部调用 getContext() 即可取得当前请求的上下文,包括 c.var,以及在 Cloudflare Workers 上 c.env 里的 bindings。tryGetContext() 行为相同,但在 getContext() 会抛出异常的场景下返回 undefined,适合那些可能在请求之外运行的代码。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.