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 层负责四件事:

  1. 自定义错误类:项目里有哪些错误类型,各自对应什么 HTTP 状态码和错误码
  2. 错误码体系:用数字编码标识错误,前端根据 code 做判断
  3. 全局错误处理器:通过 app.onError()app.notFound() 统一拦截异常和 404
  4. 统一错误响应格式:所有错误返回相同结构的 JSON

目录结构:

src/errors/
  index.ts              // 统一导出入口
  http-exception.ts     // 自定义错误类和工厂函数
  error-codes.ts        // 错误码常量
  error-handler.ts      // 全局错误处理函数

3. 统一错误响应格式

不管什么错误——参数校验失败、资源不存在、权限不足、服务器异常——前端收到的 JSON 结构完全一致:

// response.json
{ "code": 10001, "message": "用户不存在", "details": null }
字段类型说明
codenumber业务错误码,前端据此做判断和国际化
messagestring人类可读的错误描述
detailsRecord<string, string[]> | null附加信息,如表单校验错误

对比成功响应(来自 12 篇的 ApiSuccess):{ "code": 0, "data": {...}, "message": "ok" }。成功时 code0,失败时为非零值。前端判断逻辑很简洁: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(&#123;...&#125;, 404),直接 throw 就行。错误会被全局处理器接住。

常用工厂函数

每次都写 new AppException(&#123; status, code, message &#125;) 有点啰嗦。封装成工厂函数:

// 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,
  )
}

处理逻辑分三层:

  1. AppException:业务错误,message 是给用户看的,直接返回
  2. HTTPException:Hono 中间件抛出的标准错误,按统一格式包装
  3. 其他错误:数据库挂了、代码 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:&#123; "code": 10004, "message": "路径 /api/nonexistent 不存在", "details": null &#125;。前端展示逻辑和其他错误完全一样。

8. 与 zValidator 校验错误的配合

zValidator 校验失败时,默认返回 Zod 自己的结构(&#123; success, error: &#123; issues &#125; &#125;),和统一错误格式不匹配。需要自定义处理——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,
  )
}

几个要点:

  1. 业务错误用 warn,未预期错误用 error。级别不同,告警阈值不同。业务错误是正常的,不应该触发告警
  2. 日志里带 requestId。前端报错时附上这个 ID,运维能在日志系统里精确定位
  3. 日志用 JSON 格式。方便 Cloudflare Logs、Datadog 等日志系统解析和检索
  4. 不要把 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 层统一做四件事:自定义错误类、错误码体系、全局错误处理器、统一响应格式
  • 错误响应格式固定为 &#123; code, message, details &#125;,和成功响应 &#123; code: 0, data, message &#125; 对称
  • 错误码用 5 位数字按模块分组,前端根据 code 做判断和国际化
  • AppException 继承 Hono 的 HTTPException,throw 一次就够,全局处理器负责转成统一格式
  • app.onError() 接住所有未处理异常,app.notFound() 接住所有 404,都返回 JSON
  • zValidator 校验失败时通过第三个参数回调,把 Zod issues 转换成统一格式
  • 错误日志只在全局处理器里记录一次,路由代码不写 console.error

下一篇我们看中间件层的设计,讨论如何把认证、日志、CORS 这些横切关注点组织成可复用的中间件模块。