用 HTTP 状态码还是自定义状态码? Go 错误处理的优雅解决方案

115 阅读5分钟

用 HTTP 状态码还是自定义状态码? Go 错误处理的优雅解决方案

在开发 REST API 时,你是否遇到过这样的问题:

  • 错误信息杂乱无章,难以定位问题
  • 用户看到的错误提示晦涩难懂
  • 生产环境暴露了敏感信息
  • 错误处理代码重复且难以维护

本文将介绍一种优雅的错误处理解决方案,让你的代码更加清晰、可维护,同时提供更好的用户体验。

错误处理的痛点

在传统的错误处理方式中,我们常常会遇到以下问题:

  1. 错误信息不统一

    • 有的使用数字状态码
    • 有的使用字符串描述
    • 有的直接返回底层错误
  2. 错误处理代码重复

    • 每个接口都要写错误处理逻辑
    • 日志记录分散在各处
    • HTTP 状态码映射混乱
  3. 用户体验差

    • 错误提示不友好
    • 缺乏上下文信息
    • 难以定位问题

传统错误处理方案

让我们先看看传统的错误处理方式:

// 方式一:直接返回错误
func findUser(ctx *gin.Context) {
    user, err := db.FindUser()
    if err != nil {
        ctx.JSON(500, gin.H{"error": err.Error()})
        return
    }
    ctx.JSON(200, user)
}

// 方式二:使用状态码
func findUser(ctx *gin.Context) {
    user, err := db.FindUser()
    if err != nil {
        ctx.JSON(500, gin.H{
            "code": 1001,
            "msg": "数据库查询失败"
        })
        return
    }
    ctx.JSON(200, user)
}

这些方案虽然简单直接,但存在一些明显的缺陷:

  1. 安全性问题

    • 方式一直接将底层错误暴露给用户,可能泄露敏感信息
    • 数据库错误可能包含表结构、SQL 语句等内部信息
  2. 沟通复杂性

    • 错误状态码 400 是 http 状态码还是自定义状态码?
    • 必须对响应体反序列化,才能知道状态
    • 重复定义,http 状态码 200 与自定义状态码 0 都表示成功
  3. 可维护性差

    • 数字错误码(如 1001)缺乏语义,难以理解
    • 开发者需要查阅文档才能理解错误含义
    • 错误码定义者也可能忘记具体含义

优雅的错误处理方案

让我们先看看优雅的错误处理方案是如何使用的:

var ErrBadRequest = reason.NewError("ErrBadRequest", "请求参数有误")

func (u *UserAPI) getUser(ctx *gin.Context, _ *struct{}) (*user.UserOutput, error) {
    return u.core.GetUser(in.ID)
}

// package user
func (u *Core) GetUser(id int64) (*UserOutput, error) {
	// 参数校验
	if err != nil {
		return nil, ErrBadRequest.With(err.Error(), "xx 参数应在 10~100 之间")
	}
	// 正确处理逻辑...
}

还记得上一篇文章提到的 web.WarpH 函数吗? 其响应错误实际是调用的 web.Fail(err),此方法会判断错误是否是 reason.Error 类型,如果是,则按照其定义的 http 状态码,reason, msg 等信息返回给客户端。

类似

HTTP Status Code: 400 (默认所有错误都是 400)

{
    "reason": "用于程序识别的错误",
    "msg": "告诉用户的错误信息描述",
    "details":[
      "某字段传输有误",
      "你可以这样修复",
      "查看文档获取更多信息"
    ]
}

错误码设计思考

传统方案中使用数字错误码(如1001表示数据库错误)存在明显缺点:缺乏语义性,需查阅文档理解,定义者也易忘记含义。

因此,我们采用字符串作为错误码,优势明显:

  1. 自解释性强

    • ErrBadRequest1001直观明了
    • 错误码即文档
    • 便于代码审查和调试
  2. 扩展性好

    • 可用模块前缀区分
    • 避免错误码冲突
    • 快速定位问题源

在和前端的对接过程中,某些接口出现错误了,前端会一头雾水,找服务端排查,有些可能只是参数问题,如果在响应的错误中有帮助解决的方案呢?能否简化对接复杂度?

为避免用户看到技术性错误,或者开发者缺乏上下文信息。我们将错误信息分为四个属性:

type Error struct {
	Reason     string
	Msg        string
	Details    []string
	HTTPStatus int
}

每个字段的作用:

  1. reason 字段

    • 使用大驼峰英语描述错误原因
    • 用于程序内部判断错误类型
    • 支持错误码映射到 HTTP 状态码
  2. msg 字段

    • 使用开发者母语描述错误
    • 面向用户,提供友好的提示
  3. details 字段

    • 提供错误扩展信息
    • 面向开发者,帮助调试
    • 可在生产环境调用 web.SetRelease() 隐藏,避免泄露敏感信息
  4. HTTPStatus

    • http 响应状态码
    • 默认为 400
    • 常用状态码 200,400,401 基本就够了,保持简单,少即是多

使用文档

  1. 使用预定义的错误类型

    • reason.ErrBadRequest: 请求参数错误
    • reason.ErrStore: 数据库错误
    • reason.ErrServer: 服务器错误
  2. 错误信息处理

    • 使用 SetMsg() 方法修改用户友好提示
    • 使用 Withf() 方法添加开发者帮助,增加错误上下文

采用一个 reason 原则,即 reason 相同则是同一个错误。

   // e1 和 e2 是不是同一个错误!
   e1 = NewError("e1", "e1")
   e2 = NewError("e2", "e1")
// e3 与 e2 是相同的错误
e2 := NewError("e2", "e2").SetHTTPStatus(200).With("e2-1")
e3 := fmt.Errorf("e3:%w", e2)
if !errors.Is(e3, e2) {
   t.Fatal("expect e3 is e2, but not")
}
// 将错误转换为 *reason.Error 结构体
   var e5 *reason.Error
	if !errors.As(e4, &e5) {
		t.Fatal("expect e4 as e5, but not")
	}

总结

通过合理的分层和封装,我们实现了:

  1. 统一的错误处理流程
  2. 友好的用户提示
  3. 详细的开发者信息
  4. 安全的错误暴露

这种设计既保证了开发效率,又提升了用户体验。如果你正在寻找一个优雅的错误处理解决方案,不妨试试这个方案。

关于 goddd

本文介绍的错误处理是 goddd 项目中的一个核心组件。goddd 是一个基于 DDD(领域驱动设计)理念的 Go 项目目标,它提供了一系列工具和最佳实践,帮助开发者构建可维护、可扩展的应用程序。

如果你对本文介绍的内容感兴趣,欢迎访问 goddd 项目 了解更多细节。项目提供了完整的示例代码和详细的文档,可以帮助你快速上手。