Hono 前后端分离系列 09:测试、日志和可观测性
wxk1991 Lv5

Hono 前后端分离系列 09:测试、日志和可观测性

能跑起来的 API,不等于能上线。

能上线的 API,也不等于能维护。

前后端分离项目一旦进入真实使用,就会出现这些问题:

1
2
3
4
5
前端说接口偶尔 500
用户说提交后没反应
日志里看不出是哪次请求
本地无法复现线上问题
改了一个接口,另一个页面坏了

这时你需要的不只是代码,而是测试、日志和可观测性。

Hono 本身很轻,但它提供的 app.request()、Testing Helper、中间件机制,很适合把这些基础设施补起来。


一、先测试 route 行为

Hono 应用可以直接通过 app.request() 测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { describe, expect, it } from 'vitest'
import app from '../src/app'

describe('health', () => {
it('returns ok', async () => {
const res = await app.request('/api/health')

expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ok: true,
})
})
})

这类测试很适合覆盖:

  • 状态码
  • 响应结构
  • 参数校验
  • 鉴权失败
  • 权限不足
  • 常见业务错误

不要只测成功路径。

失败路径才是 API 稳定性的关键。


二、使用 testClient 做类型安全测试

Hono 还提供 Testing Helper。

官方文档里提到,testClient() 可以根据 Hono routes 返回类型化客户端,类似 Hono Client。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { testClient } from 'hono/testing'
import { describe, expect, it } from 'vitest'
import app from '../src/app'

const client = testClient(app)

describe('users', () => {
it('lists users', async () => {
const res = await client.api.users.$get({
query: {
page: '1',
pageSize: '20',
},
})

expect(res.status).toBe(200)
})
})

需要注意的是,Hono 文档提醒,类型推断依赖 route 链式定义方式。也就是说,如果你希望测试客户端获得完整类型提示,路由组织方式要配合。

这点在项目早期就要想好。


三、测试错误响应

前后端分离项目里,错误响应必须稳定。

比如参数错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
it('returns validation error', async () => {
const res = await client.api.users.$post({
json: {
email: 'bad-email',
name: '',
},
})

expect(res.status).toBe(400)

const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})

比如未登录:

1
2
3
4
it('returns 401 without token', async () => {
const res = await app.request('/api/private/me')
expect(res.status).toBe(401)
})

比如无权限:

1
2
3
4
5
6
7
8
9
it('returns 403 for normal user', async () => {
const res = await app.request('/api/admin/users', {
headers: {
Authorization: `Bearer ${normalUserToken}`,
},
})

expect(res.status).toBe(403)
})

这些测试比“接口能返回 200”更有价值。


四、日志要带 requestId

线上排查问题时,最怕日志散。

一个请求经过多个中间件、多个 service、多个数据库操作,如果没有 requestId,你很难串起来。

Hono 提供了 Request ID middleware。

你也可以自己写一个简单版本:

1
2
3
4
5
6
7
8
9
app.use('*', async (c, next) => {
const requestId =
c.req.header('X-Request-Id') ?? crypto.randomUUID()

c.set('requestId', requestId)
c.header('X-Request-Id', requestId)

await next()
})

错误响应里也带上:

1
2
3
4
5
6
7
return c.json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: '服务器内部错误',
},
requestId: c.get('requestId'),
}, 500)

这样前端报错时,可以直接把 requestId 打出来。

1
2
3
console.error('API failed', {
requestId: body.requestId,
})

排查效率会高很多。


五、日志不要只打印字符串

不要只写:

1
console.log('create user failed')

至少要结构化:

1
2
3
4
5
console.error('create_user_failed', {
requestId: c.get('requestId'),
userId: c.get('user')?.id,
error: err instanceof Error ? err.message : String(err),
})

如果部署在 Cloudflare Workers,console.log 会进入平台日志。

如果部署在 Node.js,可以接 Pino、Winston 或其他日志系统。

关键不是用哪个库,而是日志字段要稳定:

1
2
3
4
5
6
7
requestId
userId
route
method
status
duration
error.code

六、记录耗时

Hono 有 Timing middleware,可以给响应加 Server-Timing 信息。

你也可以写一个基础耗时日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.use('*', async (c, next) => {
const startedAt = Date.now()

await next()

const duration = Date.now() - startedAt

console.log('request_finished', {
method: c.req.method,
path: new URL(c.req.url).pathname,
status: c.res.status,
duration,
requestId: c.get('requestId'),
})
})

慢接口要能被发现。

不要等用户说“页面很卡”,你才开始猜。


七、onError 是最后防线

Hono 的 app.onError() 很适合做统一兜底。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.onError((err, c) => {
const requestId = c.get('requestId')

console.error('unhandled_error', {
requestId,
error: err instanceof Error ? err.stack : String(err),
})

return c.json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: '服务器内部错误',
},
requestId,
}, 500)
})

生产环境不要把 stack 直接返回给前端。

前端只需要知道请求失败和 requestId。

堆栈应该留在服务端日志里。


八、最后的建议

Hono API 要能长期维护,至少做到:

1
2
3
4
5
6
7
核心接口有测试
错误路径有测试
每次请求有 requestId
错误响应带 requestId
日志结构化
慢请求能看见
onError 统一兜底

前后端分离项目里,前端报错截图经常只是一条线索。

真正能定位问题的,是后端日志、请求 ID、状态码、错误码和测试覆盖。

不要等线上出问题才补这些。


参考资料