Errors层设计
要点
- 前面的路由代码里,出错时直接
return c.json({ error: 'xxx' }, 400),格式全凭手感——有人写error,有人写message,有人返回字符串 - Errors 层的职责是把「出错之后怎么办」标准化:统一错误格式、错误码、日志记录,让所有接口在出错时表现得一模一样
- Hono 提供
app.onError()和app.notFound()两个全局入口,集中处理所有未捕获异常和 404 - 自定义错误类继承 Hono 的
HTTPException,把错误码、HTTP 状态码、用户消息绑定在一起 - 错误码用 5 位数字按模块分组,前端根据 code 做判断,不依赖字符串匹配
- zValidator 校验失败时通过第三个参数回调,把校验错误转换成统一格式
- 错误日志只在全局处理器里记录一次,路由代码不需要写
console.error
1. 从一个散落的错误处理说起
前面的路由代码里,出错时大致是这样处理的:
// src/routes/users.ts
app.get('/:id', async (c) => {
const user = await db.select().from(usersTable).where(eq(usersTable.id, Number(c.req.param('id'))))
if (!user) return c.json({ error: '用户不存在' }, 404)
return c.json(user)
})
app.post('/', async (c) => {
const body = await c.req.json()
if (!body.email) return c.json({ message: '邮箱不能为空' }, 400)
if (!body.password) return c.json({ message: '密码不能为空' }, 400)
})第一个接口返回 { error: '...' },第二个返回 { message: '...' }。前端要先判断字段名,再决定怎么展示。如果还有接口返回纯字符串,错误处理逻辑就得写三套。
更麻烦的是,每个路由都在自己处理错误。想改错误响应格式时,得挨个文件翻找。根源在于:错误处理散落在各个路由里,没有统一的规则和出口。
2. Errors 层的职责
Errors 层负责四件事:
- 自定义错误类:项目里有哪些错误类型,各自对应什么 HTTP 状态码和错误码
- 错误码体系:用数字编码标识错误,前端根据 code 做判断
- 全局错误处理器:通过
app.onError()和app.notFound()统一拦截异常和 404 - 统一错误响应格式:所有错误返回相同结构的 JSON
目录结构:
src/errors/
index.ts // 统一导出入口
http-exception.ts // 自定义错误类和工厂函数
error-codes.ts // 错误码常量
error-handler.ts // 全局错误处理函数3. 统一错误响应格式
不管什么错误——参数校验失败、资源不存在、权限不足、服务器异常——前端收到的 JSON 结构完全一致:
// response.json
{ "code": 10001, "message": "用户不存在", "details": null }| 字段 | 类型 | 说明 |
|---|---|---|
code | number | 业务错误码,前端据此做判断和国际化 |
message | string | 人类可读的错误描述 |
details | Record<string, string[]> | null | 附加信息,如表单校验错误 |
对比成功响应(来自 12 篇的 ApiSuccess):{ "code": 0, "data": {...}, "message": "ok" }。成功时 code 为 0,失败时为非零值。前端判断逻辑很简洁:code === 0 走正常流程,否则展示 message。
4. 错误码体系
错误码用 5 位数字,按千位分组:
// src/errors/error-codes.ts
export const ErrorCodes = {
// 通用 10000-10999
UNKNOWN_ERROR: 10000,
VALIDATION_ERROR: 10001,
UNAUTHORIZED: 10002,
FORBIDDEN: 10003,
NOT_FOUND: 10004,
CONFLICT: 10006,
TOO_MANY_REQUESTS: 10007,
// 用户模块 11000-11999
USER_NOT_FOUND: 11001,
USER_EMAIL_EXISTS: 11002,
USER_PASSWORD_WRONG: 11003,
// 文章模块 12000-12999
POST_NOT_FOUND: 12001,
} as const分组规则:10xxx 是通用错误,11xxx 是用户相关,12xxx 是文章相关,以此类推。前端拿到 code 之后可以做国际化:
// 前端代码
const errorMessages: Record<number, string> = {
10002: '请先登录',
11002: '该邮箱已被注册',
}
function handleError(res: ApiError) {
showToast(errorMessages[res.code] || res.message)
}用数字 code 而不是字符串匹配,好处是 code 不会拼错、不会被重命名影响、可以在前后端之间作为契约传递。
5. 自定义错误类
Hono 内置了 HTTPException,能在 throw 时同时携带 HTTP 状态码和错误信息。基于它做子类,把错误码也带进去:
// src/errors/http-exception.ts
import { HTTPException } from 'hono/http-exception'
import type { StatusCode } from 'hono/utils/http-status'
import { ErrorCodes } from './error-codes'
type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes]
export class AppException extends HTTPException {
readonly code: ErrorCode
readonly details?: Record<string, string[]>
constructor(options: {
status: StatusCode
code: ErrorCode
message: string
details?: Record<string, string[]>
}) {
super(options.status, { message: options.message })
this.code = options.code
this.details = options.details
}
}路由里抛出错误就变得简洁:
// src/routes/users.ts
import { AppException, ErrorCodes } from '../errors'
app.get('/:id', async (c) => {
const user = await findUser(c)
if (!user) {
throw new AppException({
status: 404,
code: ErrorCodes.USER_NOT_FOUND,
message: '用户不存在',
})
}
return c.json(user)
})不需要 return c.json({...}, 404),直接 throw 就行。错误会被全局处理器接住。
常用工厂函数
每次都写 new AppException({ status, code, message }) 有点啰嗦。封装成工厂函数:
// src/errors/http-exception.ts
export function badRequest(message: string, details?: Record<string, string[]>) {
return new AppException({ status: 400, code: ErrorCodes.VALIDATION_ERROR, message, details })
}
export function unauthorized(message = '请先登录') {
return new AppException({ status: 401, code: ErrorCodes.UNAUTHORIZED, message })
}
export function forbidden(message = '没有操作权限') {
return new AppException({ status: 403, code: ErrorCodes.FORBIDDEN, message })
}
export function notFound(message = '资源不存在') {
return new AppException({ status: 404, code: ErrorCodes.NOT_FOUND, message })
}
export function conflict(message: string, code: ErrorCode = ErrorCodes.CONFLICT) {
return new AppException({ status: 409, code, message })
}路由里的代码因此更短:
import { notFound, conflict, ErrorCodes } from '../errors'
app.get('/:id', async (c) => {
const user = await findUser(c)
if (!user) throw notFound('用户不存在')
return c.json(user)
})6. 全局错误处理器:app.onError()
路由里 throw 的错误,最终由 app.onError() 统一接住:
// src/errors/error-handler.ts
import type { ErrorHandler } from 'hono'
import { AppException } from './http-exception'
import { ErrorCodes } from './error-codes'
export const errorHandler: ErrorHandler = (err, c) => {
// AppException:路由主动抛出的业务错误
if (err instanceof AppException) {
return c.json(
{ code: err.code, message: err.message, details: err.details ?? null },
err.status,
)
}
// Hono 内置的 HTTPException:中间件抛出的标准 HTTP 错误
if (err.name === 'HTTPException') {
return c.json(
{ code: ErrorCodes.UNKNOWN_ERROR, message: err.message, details: null },
err.status,
)
}
// 其他未预期的错误:不向用户暴露内部细节
console.error('[UnhandledError]', err)
return c.json(
{ code: ErrorCodes.UNKNOWN_ERROR, message: '服务器内部错误', details: null },
500,
)
}处理逻辑分三层:
AppException:业务错误,message 是给用户看的,直接返回HTTPException:Hono 中间件抛出的标准错误,按统一格式包装- 其他错误:数据库挂了、代码 bug 等。不要把
err.message直接返回给前端——里面可能包含数据库表名、SQL 语句、文件路径等敏感信息。给用户只展示「服务器内部错误」,具体信息写到日志里
挂到 app 上:
// src/index.ts
import { errorHandler } from './errors/error-handler'
app.onError(errorHandler)注册之后,所有路由的异常都会被它接住,不需要在每个路由里写 try-catch。
7. 404 处理:app.notFound()
用户访问不存在的路径时,Hono 默认返回纯文本的 404 Not Found。用 app.notFound() 自定义:
// src/errors/error-handler.ts
import type { NotFoundHandler } from 'hono'
import { ErrorCodes } from './error-codes'
export const notFoundHandler: NotFoundHandler = (c) => {
return c.json(
{ code: ErrorCodes.NOT_FOUND, message: `路径 ${c.req.path} 不存在`, details: null },
404,
)
}同样挂到 app 上:
// src/index.ts
app.onError(errorHandler)
app.notFound(notFoundHandler)访问任何不存在的路径都会返回统一 JSON:{ "code": 10004, "message": "路径 /api/nonexistent 不存在", "details": null }。前端展示逻辑和其他错误完全一样。
8. 与 zValidator 校验错误的配合
zValidator 校验失败时,默认返回 Zod 自己的结构({ success, error: { issues } }),和统一错误格式不匹配。需要自定义处理——zValidator 的第三个参数是校验失败时的回调:
// src/routes/users.ts
app.post(
'/',
zValidator('json', createUserSchema, (result, c) => {
if (!result.success) {
const details: Record<string, string[]> = {}
for (const issue of result.error.issues) {
const field = issue.path.join('.')
if (!details[field]) details[field] = []
details[field].push(issue.message)
}
return c.json(
{ code: ErrorCodes.VALIDATION_ERROR, message: '请求参数校验失败', details },
400,
)
}
}),
async (c) => {
const body = c.req.valid('json')
// ...
},
)转换后的响应:
// response.json
{
"code": 10001,
"message": "请求参数校验失败",
"details": {
"email": ["邮箱格式不正确"],
"password": ["密码至少 8 位"]
}
}details 把每个字段的校验错误收集到一起,前端可以精确地在对应表单字段下方展示错误提示。
抽出转换逻辑
每个路由都写一遍 result.error.issues 的转换很重复。抽成工具函数:
// src/errors/error-handler.ts
import type { ValidationError } from '@hono/zod-validator'
export function formatValidationErrors(error: ValidationError) {
const details: Record<string, string[]> = {}
for (const issue of error.issues) {
const field = issue.path.join('.')
if (!details[field]) details[field] = []
details[field].push(issue.message)
}
return details
}路由里用法变成:
zValidator('json', createUserSchema, (result, c) => {
if (!result.success) {
return c.json(
{
code: ErrorCodes.VALIDATION_ERROR,
message: '请求参数校验失败',
details: formatValidationErrors(result.error),
},
400,
)
}
})9. 错误日志记录
错误日志只在全局处理器里记录一次,路由代码不需要写 console.error。可以在 errorHandler 里补充更完整的日志:
// src/errors/error-handler.ts
export const errorHandler: ErrorHandler = (err, c) => {
const requestId = c.get('requestId') // 假设有中间件设置了请求 ID
if (err instanceof AppException) {
console.warn(JSON.stringify({
level: 'warn', requestId, code: err.code,
message: err.message, path: c.req.path,
}))
return c.json(
{ code: err.code, message: err.message, details: err.details ?? null },
err.status,
)
}
console.error(JSON.stringify({
level: 'error', requestId, error: err.message,
stack: err.stack, path: c.req.path,
}))
return c.json(
{ code: ErrorCodes.UNKNOWN_ERROR, message: '服务器内部错误', details: null },
500,
)
}几个要点:
- 业务错误用
warn,未预期错误用error。级别不同,告警阈值不同。业务错误是正常的,不应该触发告警 - 日志里带
requestId。前端报错时附上这个 ID,运维能在日志系统里精确定位 - 日志用 JSON 格式。方便 Cloudflare Logs、Datadog 等日志系统解析和检索
- 不要把
err.stack返回给前端。堆栈只写在日志里
10. Errors 层的统一导出
和 Types 层一样,Errors 层也需要 index.ts 做统一导出:
// src/errors/index.ts
export { AppException, badRequest, unauthorized, forbidden, notFound, conflict } from './http-exception'
export { ErrorCodes } from './error-codes'
export { errorHandler, notFoundHandler, formatValidationErrors } from './error-handler'路由文件只需要从一个路径引入:
// src/routes/users.ts
import { notFound, conflict, ErrorCodes } from '../errors'index.ts 只做 re-export。新增错误类型时在对应文件里定义,然后在 index.ts 加一行导出。
总结
回顾一下这篇的要点:
- 错误处理散落在路由里会导致格式不统一、重复代码多、改一处漏一处
- Errors 层统一做四件事:自定义错误类、错误码体系、全局错误处理器、统一响应格式
- 错误响应格式固定为
{ code, message, details },和成功响应{ code: 0, data, message }对称 - 错误码用 5 位数字按模块分组,前端根据 code 做判断和国际化
AppException继承 Hono 的HTTPException,throw 一次就够,全局处理器负责转成统一格式app.onError()接住所有未处理异常,app.notFound()接住所有 404,都返回 JSON- zValidator 校验失败时通过第三个参数回调,把 Zod issues 转换成统一格式
- 错误日志只在全局处理器里记录一次,路由代码不写
console.error
下一篇我们看中间件层的设计,讨论如何把认证、日志、CORS 这些横切关注点组织成可复用的中间件模块。