3. 自定义错误类型:实现 error 接口、结构体错误、带上下文的错误类型
Go 语言内置的 error 接口只要求实现一个 Error() string 方法,这在业务系统中远远不够。真实场景下,我们需要区分错误种类、携带额外字段、支持错误链,甚至附加调用栈信息。本章将系统讲解如何通过自定义错误类型来满足这些需求。
3.1 error 接口的本质与自定义约束
Go 的 error 是一个极其简洁的接口:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都可以作为 error 使用。这意味着我们可以用结构体、指针、甚至函数来构造错误。自定义错误的核心价值在于:
- 类型区分:通过类型断言或类型 switch 识别错误种类
- 携带数据:附加错误码、HTTP 状态码、用户消息等字段
- 错误链:包装底层错误,保留原始原因
- 行为扩展:实现临时性、重试性等接口标记
设计原则:自定义错误类型应当只包含与错误处理相关的字段,不要将业务逻辑或持久化字段混入错误结构体中。
3.2 结构体错误:最基础的自定义方式
结构体错误是最常见的自定义错误形式。通过定义结构体字段,我们可以携带结构化信息,而不仅仅是字符串。
// 基础结构体错误
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 验证失败: %s (当前值: %v)", e.Field, e.Message, e.Value)
}
// 使用示例
func ValidateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Value: age,
Message: "年龄必须在 0-150 之间",
}
}
return nil
}
使用结构体错误时,调用方可以通过类型断言获取详细信息:
if err := ValidateAge(-5); err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("验证失败字段: %s\n", ve.Field)
fmt.Printf("错误消息: %s\n", ve.Message)
}
}
3.3 带上下文的错误类型:包装与链式追踪
业务系统中,错误往往需要逐层包装,保留原始错误的同时添加当前层级的上下文信息。Go 1.13 引入的 %w 格式化动词和 errors.Is/errors.As 函数为错误链提供了标准支持。
// 带上下文的业务错误
type BusinessError struct {
Code int // 业务错误码
Message string // 用户可见消息
Err error // 原始错误(可选)
StackTrace string // 调用栈(可选)
}
func (e *BusinessError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// Unwrap 方法使 errors.Is/As 可以遍历错误链
func (e *BusinessError) Unwrap() error {
return e.Err
}
// 工厂函数:包装底层错误
func NewBusinessError(code int, message string, err error) *BusinessError {
return &BusinessError{
Code: code,
Message: message,
Err: err,
StackTrace: getStackTrace(), // 自定义堆栈捕获
}
}
关键点:实现
Unwrap() error 方法后,errors.Is 和 errors.As 会自动遍历整个错误链,这是 Go 标准错误包装机制的核心。
3.4 错误类型对比与选型
| 错误类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| errors.New / fmt.Errorf | 简单错误,无需额外字段 | 零依赖,简洁 | 无法携带结构化数据 |
| 结构体错误 | 需要区分错误类型、携带字段 | 类型安全,可扩展 | 需要手动实现 Error() |
| 带上下文的包装错误 | 错误链追踪、多层包装 | 保留原始错误,支持 Unwrap | 需要额外实现 Unwrap() |
| 哨兵错误 | 预定义错误常量 | 可用 errors.Is 精确匹配 | 无法携带动态数据 |
3.5 实战:构建业务错误体系
下面是一个完整的业务错误体系示例,整合了错误码、用户消息、内部错误和堆栈信息:
// 错误码定义
const (
ErrCodeSuccess = 0
ErrCodeInvalidParam = 1001
ErrCodeUnauthorized = 2001
ErrCodeForbidden = 2003
ErrCodeNotFound = 3004
ErrCodeInternal = 5000
)
// 业务错误结构体
type AppError struct {
Code int // 业务错误码
Message string // 用户可见消息
Internal error // 内部原始错误
Stack string // 调用栈
Metadata map[string]interface{} // 额外元数据
}
func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("code=%d message=%s internal=%v", e.Code, e.Message, e.Internal)
}
return fmt.Sprintf("code=%d message=%s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Internal
}
// 工厂方法
func NewAppError(code int, message string, internal error) *AppError {
return &AppError{
Code: code,
Message: message,
Internal: internal,
Stack: captureStack(2), // 跳过当前帧
}
}
// 便捷方法:带元数据
func (e *AppError) WithMetadata(key string, value interface{}) *AppError {
if e.Metadata == nil {
e.Metadata = make(map[string]interface{})
}
e.Metadata[key] = value
return e
}
// 使用示例
func GetUser(ctx context.Context, userID string) (*User, error) {
if userID == "" {
return nil, NewAppError(ErrCodeInvalidParam, "用户 ID 不能为空", nil)
}
user, err := db.FindUser(ctx, userID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, NewAppError(ErrCodeNotFound, "用户不存在", err)
}
return nil, NewAppError(ErrCodeInternal, "查询用户失败", err).
WithMetadata("userID", userID)
}
return user, nil
}
注意:在生产环境中,堆栈捕获(captureStack)会带来性能开销。建议仅在开发或调试模式下启用,或使用
runtime.Caller 按需捕获关键帧。
3.6 自定义错误类型架构图
下图展示了自定义错误类型在业务系统中的层级关系与流转路径:
3.7 最佳实践与避坑指南
- 优先使用 errors.Is/As:不要直接使用
==比较错误,除非是未包装的哨兵错误。 - 避免过度包装:每层包装都会增加开销,只在需要添加上下文时包装。
- 统一错误码规范:错误码应当在整个项目中保持一致,建议使用枚举或常量组。
- 区分用户错误与系统错误:用户可见消息不应暴露内部实现细节(如 SQL 语句、堆栈信息)。
- 考虑使用第三方库:如
github.com/pkg/errors(已归档)或github.com/cockroachdb/errors提供更丰富的错误链功能。
总结:自定义错误类型是 Go 业务系统的基石。通过结构体错误携带字段、通过 Unwrap 方法支持错误链、通过错误码实现分类,我们可以构建出既灵活又健壮的错误处理体系。下一节将在此基础上,深入探讨如何将错误类型与 HTTP 响应、日志系统、监控告警等基础设施集成。