EasyAudioEncode/docs/3_error.md
2025-12-25 17:01:46 +08:00

216 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 用 HTTP 状态码还是自定义状态码? Go 错误处理的优雅解决方案
在开发 REST API 时,你是否遇到过这样的问题:
- 错误信息杂乱无章,难以定位问题
- 用户看到的错误提示晦涩难懂
- 生产环境暴露了敏感信息
- 错误处理代码重复且难以维护
本文将介绍一种优雅的错误处理解决方案,让你的代码更加清晰、可维护,同时提供更好的用户体验。
## 错误处理的痛点
在传统的错误处理方式中,我们常常会遇到以下问题:
1. 错误信息不统一
- 有的使用数字状态码
- 有的使用字符串描述
- 有的直接返回底层错误
2. 错误处理代码重复
- 每个接口都要写错误处理逻辑
- 日志记录分散在各处
- HTTP 状态码映射混乱
3. 用户体验差
- 错误提示不友好
- 缺乏上下文信息
- 难以定位问题
## 传统错误处理方案
让我们先看看传统的错误处理方式:
```go
// 方式一:直接返回错误
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缺乏语义难以理解
- 开发者需要查阅文档才能理解错误含义
- 错误码定义者也可能忘记具体含义
## 优雅的错误处理方案
让我们先看看优雅的错误处理方案是如何使用的:
```go
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.WrapH` 函数吗? 其响应错误实际是调用的 `web.Fail(err)`,此方法会判断错误是否是 `reason.Error` 类型,如果是,则按照其定义的 http 状态码reason, msg 等信息返回给客户端。
类似
HTTP Status Code: 400 (默认所有错误都是 400)
```json
{
"reason": "用于程序识别的错误",
"msg": "告诉用户的错误信息描述",
"details":[
"某字段传输有误",
"你可以这样修复",
"查看文档获取更多信息"
]
}
```
### 错误码设计思考
传统方案中使用数字错误码如1001表示数据库错误存在明显缺点缺乏语义性需查阅文档理解定义者也易忘记含义。
因此,我们采用字符串作为错误码,优势明显:
1. 自解释性强
- `ErrBadRequest`比`1001`直观明了
- 错误码即文档
- 便于代码审查和调试
2. 扩展性好
- 可用模块前缀区分
- 避免错误码冲突
- 快速定位问题源
在和前端的对接过程中,某些接口出现错误了,前端会一头雾水,找服务端排查,有些可能只是参数问题,如果在响应的错误中有帮助解决的方案呢?能否简化对接复杂度?
为避免用户看到技术性错误,或者开发者缺乏上下文信息。我们将错误信息分为四个属性:
```go
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 相同则是同一个错误。
```go
// e1 和 e2 是不是同一个错误!
e1 = NewError("e1", "e1")
e2 = NewError("e2", "e1")
```
```go
// 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")
}
```
```go
// 将错误转换为 *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](https://github.com/ixugo/goddd) 项目中的一个核心组件。goddd 是一个基于 DDD领域驱动设计理念的 Go 项目目标,它提供了一系列工具和最佳实践,帮助开发者构建可维护、可扩展的应用程序。
如果你对本文介绍的内容感兴趣,欢迎访问 [goddd 项目](https://github.com/ixugo/goddd) 了解更多细节。项目提供了完整的示例代码和详细的文档,可以帮助你快速上手。