Hono 前后端分离系列 05:参数校验和统一错误响应
后端接口不能相信前端。
这句话听起来有点冷,但它是前后端分离项目的底线。
前端表单可以校验,按钮可以禁用,页面可以限制输入。
但真正进入后端的请求,可能来自:
1 2 3 4 5 6
| 浏览器控制台 脚本 Postman 爬虫 错误版本的前端 恶意请求
|
所以 Hono API 必须做服务端校验。
而且不只是校验,还要把错误格式统一下来。否则前端处理错误会变成一堆 if else。
一、不要直接信任 c.req.json()
最危险的写法是这样:
1 2 3 4 5 6 7 8 9 10
| app.post('/api/users', async (c) => { const body = await c.req.json()
await createUser({ email: body.email, name: body.name, })
return c.json({ ok: true }) })
|
这段代码默认了很多事情:
1 2 3 4 5
| body 一定是 JSON email 一定存在 email 一定是字符串 name 一定合法 不会多传奇怪字段
|
这些默认都不可靠。
正确做法是先把输入变成可信数据,再进入业务逻辑。
二、使用 @hono/zod-validator
Hono 官方文档推荐可以使用 @hono/zod-validator 简化 Zod 校验。
安装:
1
| pnpm add zod @hono/zod-validator
|
定义 schema:
1 2 3 4 5 6
| import { z } from 'zod'
export const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(2).max(30), })
|
在路由里使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { createUserSchema } from '../schemas/user'
const users = new Hono()
users.post( '/', zValidator('json', createUserSchema), async (c) => { const input = c.req.valid('json')
return c.json({ data: { id: 'u_1', email: input.email, name: input.name, }, }, 201) } )
|
关键是这一行:
1
| const input = c.req.valid('json')
|
它拿到的是已经校验过的数据。
业务逻辑应该从这里开始,而不是从裸 body 开始。
三、query 和 param 也要校验
很多人只校验 body,忽略 query 和 param。
分页接口:
1
| GET /api/users?page=1&pageSize=20
|
也应该校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const listUsersQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), keyword: z.string().trim().optional(), })
users.get( '/', zValidator('query', listUsersQuerySchema), async (c) => { const query = c.req.valid('query')
return c.json({ data: { items: [], page: query.page, pageSize: query.pageSize, total: 0, }, }) } )
|
路径参数也一样:
1 2 3 4 5 6 7 8 9 10 11 12
| const idParamSchema = z.object({ id: z.string().min(1), })
users.get( '/:id', zValidator('param', idParamSchema), async (c) => { const { id } = c.req.valid('param') return c.json({ data: { id } }) } )
|
前后端分离项目里,参数来源越多,越要统一校验方式。
四、统一错误响应
校验失败时,不能让每个接口各自发挥。
推荐统一错误结构:
1 2 3 4 5 6 7
| { "error": { "code": "VALIDATION_ERROR", "message": "请求参数错误", "details": [] } }
|
可以封装一个错误响应工具:
1 2 3 4 5 6 7 8 9 10 11 12 13
| export function errorResponse( code: string, message: string, details?: unknown ) { return { error: { code, message, details, }, } }
|
然后在 zValidator 里处理失败结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| users.post( '/', zValidator('json', createUserSchema, (result, c) => { if (!result.success) { return c.json( errorResponse( 'VALIDATION_ERROR', '请求参数错误', result.error.flatten() ), 400 ) } }), async (c) => { const input = c.req.valid('json') return c.json({ data: input }, 201) } )
|
这样前端可以稳定处理:
1 2 3 4
| if (!res.ok) { const body = await res.json() toast.error(body.error.message) }
|
五、用 HTTPException 处理业务错误
校验错误是输入错误。
业务错误是另一类问题:
1 2 3 4 5
| 用户不存在 邮箱已注册 余额不足 没有权限 订单状态不允许取消
|
Hono 提供了 HTTPException,可以用来抛出 HTTP 语义明确的错误。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { HTTPException } from 'hono/http-exception'
async function getUserOrThrow(id: string) { const user = null
if (!user) { throw new HTTPException(404, { message: '用户不存在', }) }
return user }
|
再用 app.onError 统一兜底:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| app.onError((err, c) => { if (err instanceof HTTPException) { return c.json( errorResponse('HTTP_ERROR', err.message), err.status ) }
console.error(err)
return c.json( errorResponse('INTERNAL_SERVER_ERROR', '服务器内部错误'), 500 ) })
|
不要在每个 route 里写一遍 try catch。
统一错误处理才能让项目保持干净。
六、前端应该怎么配合
前端也要做表单校验,但目的不同。
前端校验是为了用户体验:
后端校验是为了系统安全和数据正确。
两者不能互相替代。
如果前后端都用 TypeScript,可以把 schema 放到共享包里,或者通过 Hono RPC / OpenAPI 生成客户端类型。这样能减少重复定义。
但即使共享类型,后端仍然要做运行时校验。
TypeScript 类型只存在于编译期,请求进来时已经没有类型保护了。
七、最后的建议
参数校验和错误响应是 API 质量的地基。
我的建议是:
1 2 3 4 5 6
| 所有 body 都校验 重要 query 和 param 也校验 业务逻辑只接收校验后的 input 错误响应结构统一 业务错误用明确状态码 未知错误不要把堆栈返回给前端
|
一个接口能不能长期维护,往往不是看它成功时写得多漂亮,而是看它失败时是否可预测。
前后端分离最需要的就是可预测。
参考资料