错误信息怎么写,才能帮未来的自己

0阅读10分钟

凌晨两点,线上报警响了。你打开日志,看到一行:Error: failed

这是你自己写的代码。三个月前的你。你盯着这行日志,开始从头翻调用栈、查 git log、猜当时到底哪个分支出了问题。十五分钟过去了,你还没定位到根因——不是因为问题复杂,而是因为错误信息根本没给你任何线索。

这种场景我经历过不止一次。每次都会提醒自己:错误信息不是代码的附属品,它是排障的第一现场。写得好,几分钟就能找到方向;写得差,就只能盲猜。

一条好的错误信息该回答什么问题

Nielsen Norman Group 在错误信息设计指南里提过一个框架:好的错误信息至少要回答三个问题——发生了什么、为什么发生、下一步该怎么办 1。这个框架对开发者同样适用,只不过我们的「用户」经常是半年后回来看日志的自己。

我习惯把它拆成四个要素:

要素回答的问题示例
动作哪个操作失败了?「读取文章分类配置时失败」
上下文在什么条件下失败的?「文件路径: config/meta.json, 请求 ID: req_abc123」
原因可能的根因是什么?「文件不存在」或「JSON 格式不合法」
下一步接下来该怎么排查?「请确认环境变量 CONFIG_PATH 是否正确配置」

四个要素不一定要全部出现在每条日志里,但写错误信息时先过一遍这四个维度,能避免大部分「写了一句等于没写」的问题。

从「file error」到有用信息:一个真实的改进过程

我之前维护过一个构建脚本,负责读取内容目录的 meta.json 配置文件。最初的错误处理是这样的:

// 改之前:只知道失败了,不知道为什么
try {
  const data = await fs.readFile(filePath)
  return JSON.parse(data.toString())
} catch (err) {
  throw new Error('file error')
}

某天配置文件路径改了,构建直接崩了,日志里就一行 file error。我花了二十分钟才确认是路径问题——因为错误信息完全没告诉我哪个文件、什么错误。

改进后的版本:

// 改之后:动作 + 上下文 + 原因 + 下一步
async function loadMetaConfig(filePath: string) {
  try {
    const raw = await fs.readFile(filePath, 'utf-8')
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code
    throw new Error(
      `读取文章分类配置失败 [文件: ${filePath}]` +
      (code === 'ENOENT'
        ? ' — 文件不存在,请检查 CONFIG_PATH 环境变量是否指向正确目录'
        : code === 'EACCES'
          ? ' — 权限不足,请检查当前用户是否有读取权限'
          : ` — 系统错误码: ${code}`)
    )
  }
 
  try {
    return JSON.parse(raw)
  } catch (err) {
    throw new Error(
      `解析配置文件 JSON 失败 [文件: ${filePath}] — ` +
      `请检查 JSON 语法是否正确(常见原因:多余逗号、缺少引号)`
    )
  }
}

改动不大,但排障体验完全不同。同样是路径写错,现在日志直接告诉你文件路径和可能原因,不用再去翻代码确认是哪个 readFile 出的问题。

API 错误:用户看到的和开发者看到的要分开

做过 API 的同学应该都见过这种响应:

{
  "error": "something went wrong"
}

这种信息对用户和开发者都没用。对用户来说不知道发生了什么,对开发者来说不知道从哪里查起。

2024 年 IETF 发布了 RFC 9457「Problem Details for HTTP APIs」2,定义了 API 错误响应的标准结构。核心思路是把错误信息分成机器可读和人类可读两层:

{
  "type": "https://api.example.com/errors/quota-exceeded",
  "title": "请求配额已用尽",
  "status": 429,
  "detail": "当前 API Key 本小时已使用 1000 次请求,达到速率限制上限",
  "instance": "/api/v1/articles?page=42",
  "retryAfter": 1800
}

把这个和常见的 bare status code 对比一下:

维度裸状态码RFC 9457 结构化响应
人类可读性只知道「400 Bad Request」知道「哪个字段格式不对」
机器可处理只能按 HTTP 状态码分支可按 type URI 做精确匹配
文档关联type 字段指向文档页面
定位问题盲猜instance 指向具体资源
重试指引可携带 retryAfter 等扩展字段

在代码层面,错误处理也应该区分面向用户的提示和面向开发者的日志:

// 面向用户的提示:简洁、可操作、不带技术细节
function getUserMessage(error: AppError): string {
  switch (error.code) {
    case 'INVALID_EMAIL':
      return '邮箱格式不正确,请检查后重新输入'
    case 'QUOTA_EXCEEDED':
      return '今日免费额度已用完,明天再试试,或升级套餐'
    default:
      return '出了点问题,请稍后重试(错误编号:' + error.requestId + ')'
  }
}
 
// 面向开发者的日志:完整上下文、方便排查
function logError(error: AppError, context: RequestContext) {
  logger.error({
    message: error.message,
    code: error.code,
    requestId: context.requestId,
    userId: context.userId,
    path: context.path,
    queryParams: context.queryParams,
    stack: error.stack,
    timestamp: new Date().toISOString(),
  })
}

这两层的关注点不一样。用户需要知道「我该怎么办」,开发者需要知道「哪里出了问题」。把堆栈信息直接甩给用户,既不友好也不安全;只给用户一句「出错了」而不在日志里留足上下文,排查时就会抓瞎。

错误处理的决策流程

写错误处理代码时,我一般遵循下面这个决策流程:

流程图画布 · 115%
Mermaid 流程图加载中...

关键节点有三个:第一,判断错误是否可恢复——可恢复的就给用户可操作的提示,不可恢复的要确保日志完整;第二,判断能否提供下一步建议——能提供的就写出来,不能提供的至少给个请求 ID;第三,检查是否有敏感信息——密钥、token、用户密码这些绝不能出现在日志或响应里。

这个流程看起来步骤多,但实际写代码时,大部分判断是在一瞬间完成的。关键是把「写错误信息之前先想清楚受众和目的」变成一种条件反射,而不是每次都等到线上出事了才回头补。

我见过一种反模式是:错误处理代码写了一大堆 if-else,每个分支返回不同的错误信息,但这些信息本身都是「操作失败」的变体。代码看起来很「完善」,但排查者看完依然不知道问题出在哪。错误处理的价值不在于覆盖了多少分支,而在于每条输出的信息能不能让下一个读它的人少走弯路。

常见错误信息模式对比

下面这张表汇总了我在代码审查中经常看到的反模式:

反模式示例问题改进方向
万能错误Error: failed零信息量写清动作和上下文
只说结果Invalid input不知道哪个字段指出具体字段和期望格式
泄露内部NullPointerException at line 42对用户无意义且暴露实现用户看提示,开发者看日志
怪罪用户You entered the wrong format对抗性语气中性描述 + 正确示例
吞掉错误catch(e) { /* 空 */ }错误消失,无法排查至少记录日志
状态码滥用返回 200 OK + { error: "..." }监控工具无法识别使用正确的 HTTP 状态码
信息过载把整个请求体塞进错误响应响应膨胀、可能泄露数据只记录关键上下文字段

对应的,好的错误信息应该长这样:

// ❌ 反模式:万能错误
throw new Error('failed')
 
// ✅ 改进:动作 + 目标 + 原因
throw new Error(`数据库查询失败 [表: articles, 条件: categoryId=${categoryId}] — 连接超时(30s),请检查数据库服务状态`)
 
// ❌ 反模式:只说结果
throw new Error('Invalid input')
 
// ✅ 改进:具体字段 + 期望格式
throw new Error(
  `参数校验失败 [字段: publishDate] — ` +
  `期望格式: ISO 8601 (如 2026-06-25),实际值: "tomorrow"`
)
 
// ❌ 反模式:泄露内部实现
res.status(500).json({ error: err.stack })
 
// ✅ 改进:分层响应
res.status(500).json({
  type: 'https://api.example.com/errors/internal-error',
  title: '服务器内部错误',
  detail: '文章发布服务暂时不可用,请稍后重试',
  requestId: context.requestId,
})
// 堆栈信息写入日志系统,不返回给客户端

错误码设计:给机器一个识别入口

如果你的 API 有多个消费方(前端、移动端、第三方集成),纯文本错误信息不够用——他们需要程序化地判断错误类型并做相应处理。这时候错误码就有价值了。

错误码不一定要用数字,字符串形式可读性更好:

// 错误码定义 — 字符串格式,自文档化
const ErrorCodes = {
  // 认证类
  AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
  AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
 
  // 资源类
  ARTICLE_NOT_FOUND: 'ARTICLE_NOT_FOUND',
  ARTICLE_ALREADY_PUBLISHED: 'ARTICLE_ALREADY_PUBLISHED',
 
  // 校验类
  VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
  VALIDATION_FORMAT_INVALID: 'VALIDATION_FORMAT_INVALID',
 
  // 限流类
  RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
  QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
} as const
 
// 消费方可以精确匹配
if (error.code === ErrorCodes.AUTH_TOKEN_EXPIRED) {
  await refreshToken()
  retry()
}

对比一下不同错误码设计方式的优劣:

设计方式示例可读性可扩展性适用场景
纯数字10001差,需要查表好,空间大内部系统、性能敏感
字符串枚举AUTH_TOKEN_EXPIRED好,自文档化中,需要维护常量API 对外错误码(推荐)
HTTP 状态码401, 404好,标准化差,粒度粗REST API 粗分类
层级编码AUTH.001.TOKEN好,但复杂大型系统、多团队

对多数项目来说,字符串枚举是个好平衡——既能被程序精确匹配,又能在日志里一眼看懂含义。

错误码的设计还有个容易踩的坑:枚举值不够稳定。如果你的错误码会在版本间变化,消费方每次升级 SDK 都得检查错误处理逻辑有没有受影响。所以在设计错误码时,应该把它当作 API 合约的一部分——新增可以,删除或重命名要谨慎,最好有文档说明每个错误码的含义和出现场景。

写错误信息前的检查清单

每次写错误处理代码,我都会过一遍这个清单。不一定每条都要满足,但至少大部分应该覆盖到:

  • 描述了失败的动作 — 能看出是哪个操作出了问题,而不是一个笼统的「失败」
  • 包含了关键上下文 — 文件路径、请求 ID、用户 ID、参数摘要等定位信息
  • 说明了可能原因 — 不是猜,而是根据错误类型给出最可能的原因
  • 提供了下一步建议 — 告诉排查者接下来该检查什么、改什么
  • 区分了受众 — 用户看到的提示和开发者看到的日志是分开处理的
  • 没有泄露敏感信息 — 密钥、token、密码、完整用户数据不在错误响应或日志中出现
  • 使用了正确的 HTTP 状态码 — 错误场景返回 4xx/5xx,不用 200 伪装成功
  • 错误码可被程序匹配 — 消费方能用 error.code 做分支处理
  • 错误信息可被搜索 — 日志中的错误信息是固定模板,方便 grep 和告警规则匹配
  • 保留了原始错误对象catch(e) 后把 e.stack 传给日志系统,不只取 e.message
  • 考虑了国际化 — 面向用户的错误提示通过 i18n 处理,不硬编码字符串
  • 错误粒度合理 — 不过于笼统(「出错了」),也不过于琐碎(每行代码一个 try-catch)
  • 没有吞掉错误 — 即使 catch 块不抛出,也至少记录了日志
  • 有对应的监控告警 — 关键错误路径有告警规则,不只是靠人去翻日志

一些容易忽略的细节

错误信息的语言问题。如果你的项目面向国际用户,错误信息最好用英文写或者走 i18n 系统。我见过不少项目里中文和英文错误信息混在一起,排查时想 grep 都找不到完整结果。一个简单的做法是:日志里的错误信息统一用英文,面向用户的提示走翻译系统。

错误链的保留。JavaScript 的 Error 对象支持 cause 属性,可以把原始错误传递下去:

try {
  await publishArticle(article)
} catch (err) {
  throw new Error(
    `发布文章失败 [articleId: ${article.id}] — 推送通知服务不可用`,
    { cause: err }
  )
}
// 日志里可以看到完整的错误链:外层是「发布失败」,cause 里是「通知服务连接超时」

这样不会丢失原始错误的堆栈和上下文,同时外层的错误信息对排查者更有指向性。

错误信息和日志级别的关系。很多项目里,所有错误都直接用 logger.error,结果日志系统里到处都是 ERROR 级别告警,真正的关键问题反而被淹没了。合理的做法是根据错误严重程度分级:

// 可预期的业务错误:用 warn,不需要告警
logger.warn({
  message: '用户登录失败',
  code: 'AUTH_INVALID_CREDENTIALS',
  userId: user.id,
  // 不记录密码等敏感字段
})
 
// 不可预期的系统错误:用 error,触发告警
logger.error({
  message: '数据库连接池耗尽',
  activeConnections: pool.size,
  waitingRequests: pool.waiting,
  lastQuery: sanitizeQuery(lastQuery),
})
 
// 调试信息:用 debug,开发环境才输出
logger.debug({
  message: '文章查询参数',
  filters,
  pagination,
  executionTime: Date.now() - startTime,
})

错误信息和测试的关系。写单测时,可以针对错误信息的关键片段做断言——不是断言完整文案,而是断言关键信息是否包含:

await expect(loadMetaConfig('/nonexistent/path.json'))
  .rejects.toThrow(/文件不存在/)
 
await expect(loadMetaConfig('/bad-format.json'))
  .rejects.toThrow(/JSON 格式/)

这种测试既不会因为文案微调就挂掉,又能确保错误信息的核心要素不丢失。

前端场景:表单校验和错误边界的写法

前端也有大量需要写错误信息的地方,表单校验是最常见的场景。很多人写表单校验提示时习惯用英文模板直接翻译,结果就是用户看到一句「请输入有效的值」——完全不知道到底该怎么填。

// ❌ 翻译腔,用户不知道具体要求
const validate = (value: string) => {
  if (!value) return '此字段为必填项'
  if (value.length < 6) return '输入长度不符合要求'
  return ''
}
 
// ✅ 说清哪个字段、什么要求、给个示例
const validate = (value: string) => {
  if (!value) return '密码不能为空'
  if (value.length < 6) return '密码至少 6 个字符,当前输入了 ' + value.length + ' 个'
  if (!/[A-Z]/.test(value)) return '密码需要包含至少一个大写字母'
  return ''
}

区别在于:改进后的提示告诉用户差在哪里、差多少、怎么改。尤其是「当前输入了 3 个」这种信息,看似微小,但能让用户立刻明白差距有多大。

React 的错误边界(Error Boundary)也是个值得注意的地方。默认的 fallback UI 如果只写一句「出错了」,对用户毫无帮助,对开发者也毫无线索:

// ❌ 默认 fallback:用户懵、开发者也懵
class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false }
 
  static getDerivedStateFromError() {
    return { hasError: true }
  }
 
  render() {
    if (this.state.hasError) {
      return <h1>出错了</h1>
    }
    return this.props.children
  }
}
 
// ✅ 改进:记录错误上下文、给用户可操作的反馈
function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error
  resetErrorBoundary: () => void
}) {
  // 错误详情发送到日志系统
  useEffect(() => {
    logger.error({
      message: error.message,
      stack: error.stack,
      url: window.location.href,
      userAgent: navigator.userAgent,
    })
  }, [error])
 
  return (
    <div role="alert">
      <h2>页面加载遇到了问题</h2>
      <p>请尝试刷新页面,如果问题持续出现,请联系支持团队并提供以下编号:</p>
      <code>{generateErrorId()}</code>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  )
}

这里的关键是:用户看到的是可操作的提示(刷新、重试、联系支持),开发者通过日志系统拿到完整的错误堆栈和上下文。错误 ID 是两边的桥梁——用户报障时提供编号,开发者用编号在日志里精确定位。


错误信息写得好不好,本质上反映的是写代码的人有没有想过「半年后的自己怎么排查这个问题」。这不是什么高深的架构知识,就是一个习惯。养成这个习惯之后,你会发现自己凌晨被报警叫醒时,能少花很多时间在无意义的猜测上。

参考资料

Footnotes

  1. Nielsen Norman Group, Error-Message Guidelines

  2. IETF, RFC 9457: Problem Details for HTTP APIs

评论 0

0 / 1000