3、自定义错误类型:实现error接口、携带额外上下文信息、结构化错误设计
Go 语言内置的 error 接口仅包含一个 Error() string 方法,这在 Web 服务开发中远远不够。生产级错误需要携带状态码、内部错误链、用户可读消息、错误码等上下文信息。本章将系统讲解如何通过自定义错误类型实现结构化错误处理。
3.1 error 接口与自定义类型基础
Go 的 error 是一个预声明接口:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都可以作为错误使用。最简单的自定义错误:
type SimpleError struct {
Message string
}
func (e *SimpleError) Error() string {
return e.Message
}
但这种方式无法区分错误类型,也无法携带结构化数据。Web 服务需要更丰富的错误模型。
3.2 携带 HTTP 状态码与业务错误码
在 Web 服务中,每个错误都应映射到 HTTP 状态码和业务错误码。以下是一个生产级自定义错误类型:
type AppError struct {
Code int // 业务错误码,如 10001
HttpStatus int // HTTP 状态码,如 400
Message string // 用户可读消息
Err error // 内部错误链
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("code=%d, http=%d, msg=%s, err=%v",
e.Code, e.HttpStatus, e.Message, e.Err)
}
return fmt.Sprintf("code=%d, http=%d, msg=%s",
e.Code, e.HttpStatus, e.Message)
}
// 工厂函数,方便创建常见错误
func NewBadRequest(code int, msg string, err error) *AppError {
return &AppError{
Code: code,
HttpStatus: http.StatusBadRequest,
Message: msg,
Err: err,
}
}
func NewNotFound(code int, msg string, err error) *AppError {
return &AppError{
Code: code,
HttpStatus: http.StatusNotFound,
Message: msg,
Err: err,
}
}
func NewInternalError(code int, msg string, err error) *AppError {
return &AppError{
Code: code,
HttpStatus: http.StatusInternalServerError,
Message: msg,
Err: err,
}
}
设计原则:业务错误码(Code)用于客户端精确识别错误类型;HTTP 状态码(HttpStatus)用于网络协议层;Message 用于用户展示;Err 用于内部日志和调试。四者各司其职,互不混淆。
3.3 结构化错误设计:分层上下文
复杂 Web 服务需要错误携带多层上下文信息,包括请求 ID、用户 ID、操作名称、时间戳等。以下是一个更完整的结构化错误模型:
type StructuredError struct {
Code int `json:"code"`
HttpStatus int `json:"http_status"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
Err error `json:"-"`
RequestID string `json:"request_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Stack string `json:"-"`
}
func NewStructuredError(code, httpStatus int, msg string) *StructuredError {
return &StructuredError{
Code: code,
HttpStatus: httpStatus,
Message: msg,
Details: make(map[string]string),
Timestamp: time.Now(),
}
}
func (e *StructuredError) WithDetail(key, value string) *StructuredError {
e.Details[key] = value
return e
}
func (e *StructuredError) WithRequestID(id string) *StructuredError {
e.RequestID = id
return e
}
func (e *StructuredError) WithCause(err error) *StructuredError {
e.Err = err
return e
}
func (e *StructuredError) Error() string {
b, _ := json.Marshal(e)
return string(b)
}
链式调用示例:
err := NewStructuredError(10003, 400, "用户输入无效").
WithDetail("field", "email").
WithDetail("reason", "格式不正确").
WithRequestID("req-abc-123").
WithCause(fmt.Errorf("原始错误: %w", originalErr))
3.4 错误类型对比表
| 特性 | 内置 error | AppError | StructuredError |
|---|---|---|---|
| HTTP 状态码 | ❌ 无 | ✅ 有 | ✅ 有 |
| 业务错误码 | ❌ 无 | ✅ 有 | ✅ 有 |
| 用户消息 | ❌ 无 | ✅ 有 | ✅ 有 |
| 内部错误链 | ❌ 无 | ✅ 支持 | ✅ 支持 |
| 结构化详情 | ❌ 无 | ❌ 无 | ✅ map 字段 |
| 请求追踪 | ❌ 无 | ❌ 无 | ✅ RequestID |
| JSON 序列化 | ❌ 无 | ❌ 无 | ✅ 原生支持 |
| 时间戳 | ❌ 无 | ❌ 无 | ✅ 自动记录 |
3.5 错误类型体系架构
下图展示了自定义错误类型在 Web 服务中的完整架构:
3.6 错误类型断言与类型判断
在中间件或错误处理层,需要根据错误类型执行不同逻辑:
func ErrorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "内部服务器错误",
})
}
}()
next.ServeHTTP(w, r)
})
}
func WriteError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
w.WriteHeader(appErr.HttpStatus)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": appErr.Code,
"message": appErr.Message,
})
return
}
var structErr *StructuredError
if errors.As(err, &structErr) {
w.WriteHeader(structErr.HttpStatus)
json.NewEncoder(w).Encode(structErr)
return
}
// 未知错误类型,返回 500
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "未知错误",
})
}
重要:使用
errors.As 而非类型断言 err.(*AppError)。errors.As 可以穿透错误链(通过 %w 包装的错误),而类型断言只能匹配最外层错误。这在错误包装场景下至关重要。
3.7 错误码与 HTTP 状态码映射策略
业务错误码与 HTTP 状态码并非一一对应。一个 HTTP 状态码可能对应多个业务错误码:
| HTTP 状态码 | 业务错误码范围 | 典型场景 |
|---|---|---|
| 400 Bad Request | 10001 - 10099 | 参数校验失败、JSON 解析错误 |
| 401 Unauthorized | 10101 - 10199 | Token 过期、签名无效 |
| 403 Forbidden | 10201 - 10299 | 权限不足、IP 被限制 |
| 404 Not Found | 10301 - 10399 | 资源不存在、路由未匹配 |
| 409 Conflict | 10401 - 10499 | 数据冲突、唯一约束违反 |
| 429 Too Many Requests | 10501 - 10599 | 频率限制、配额不足 |
| 500 Internal Server Error | 50001 - 50099 | 数据库异常、第三方服务故障 |
| 502 Bad Gateway | 50101 - 50199 | 上游服务不可达 |
| 503 Service Unavailable | 50201 - 50299 | 服务过载、熔断开启 |
3.8 错误链与上下文传播
Go 1.13 引入的 %w 格式化动词和 errors.Is/errors.As 函数,使错误链成为可能。自定义错误类型应支持错误链:
// 支持 Unwrap 方法,使错误链可穿透
func (e *AppError) Unwrap() error {
return e.Err
}
// 使用示例
func GetUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, NewNotFound(10301, "用户不存在", err)
}
return user, nil
}
// 调用方可以穿透错误链
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := GetUser(123)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 识别底层数据库错误
log.Printf("数据库查询无结果: %v", err)
}
WriteError(w, err)
return
}
json.NewEncoder(w).Encode(user)
}
最佳实践:
- 始终在自定义错误中保留
Unwrap()方法,支持错误链穿透 - 使用
errors.Is检查错误链中是否包含特定 sentinel 错误 - 使用
errors.As获取错误链中特定类型的错误实例 - 日志记录时输出完整错误链,便于排查根因
3.9 错误码定义与管理
集中管理错误码,避免散落在代码各处:
// errorcode/codes.go
package errorcode
const (
// 通用错误 10000-10099
Success = 0
UnknownError = 10000
InvalidParams = 10001
InvalidJSON = 10002
MissingField = 10003
// 认证错误 10100-10199
TokenExpired = 10100
InvalidToken = 10101
InsufficientPerm = 10102
// 资源错误 10300-10399
NotFound = 10300
AlreadyExists = 10301
// 系统错误 50000-50099
DatabaseError = 50000
ThirdPartyError = 50001
TimeoutError = 50002
)
// 错误码到 HTTP 状态码的映射
var CodeToHTTP = map[int]int{
Success: 200,
UnknownError: 500,
InvalidParams: 400,
InvalidJSON: 400,
MissingField: 400,
TokenExpired: 401,
InvalidToken: 401,
InsufficientPerm: 403,
NotFound: 404,
AlreadyExists: 409,
DatabaseError: 500,
ThirdPartyError: 502,
TimeoutError: 504,
}
错误码设计建议:错误码采用 5 位数字,前两位表示模块(10=通用,50=系统),后三位表示具体错误。这样既保留了模块信息,又便于扩展。错误码一旦发布,不应修改或删除,只能废弃(deprecated)。
3.10 完整错误处理流程
下图展示了从错误产生到 HTTP 响应的完整流程:
3.11 常见陷阱与避坑指南
- 陷阱 1:忽略错误链 — 自定义错误不实现
Unwrap(),导致errors.Is/errors.As无法穿透。解决方案:始终保留内部错误字段并实现 Unwrap。 - 陷阱 2:错误码重复 — 不同模块使用相同错误码,导致客户端无法区分。解决方案:使用集中式错误码管理,按模块分配范围。
- 陷阱 3:暴露内部错误 — 将数据库错误、堆栈信息直接返回给客户端。解决方案:Message 字段只放用户可读信息,内部错误存入 Err 字段仅用于日志。
- 陷阱 4:类型断言而非 errors.As — 直接使用
err.(*AppError)无法处理被fmt.Errorf("...%w...", err)包装的错误。解决方案:始终使用errors.As。 - 陷阱 5:忽略零值错误 — 返回未初始化的自定义错误指针,导致
err != nil为 true 但内部字段全为空。解决方案:使用工厂函数确保所有字段被正确初始化。
安全提醒:永远不要在错误消息中包含敏感信息,如数据库密码、API Key、内部 IP 地址、文件路径等。这些信息应仅记录在服务器日志中,且日志系统应具备脱敏能力。