2、Go 错误接口与自定义错误:error 接口详解、fmt.Errorf、errors.New、自定义错误结构体
Go 语言通过内置的 error 接口实现了简洁而强大的错误处理机制。理解该接口的设计哲学以及三种常见的错误构造方式,是编写健壮、可维护 Go 代码的基础。本章将深入剖析 error 接口、标准库提供的错误工厂函数,以及如何通过自定义结构体构建携带丰富上下文的领域错误。
2.1 error 接口:一切错误的契约
Go 的 error 是一个预声明接口,定义在 builtin 包中。任何实现了该接口的类型都可以作为错误值传递。
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
该接口仅包含一个方法 Error() string,返回错误的描述字符串。这意味着:
- 极简主义:接口只要求一个方法,任何类型只要实现了
Error() string即满足契约。 - 值语义:错误是值,可以赋值、比较、传递。零值
nil表示没有错误。 - 可组合性:基于该接口,可以构建错误链、包装、断言等高级模式。
nil 错误值通过非 nil 接口变量返回。例如 return (*MyError)(nil) 会导致调用方判断 err != nil 为 true,引发逻辑错误。始终使用 return nil 返回无错误状态。
2.2 标准错误工厂函数
Go 标准库提供了两种最常用的错误构造方式:errors.New 和 fmt.Errorf。它们适用于不同场景,且内部实现均基于 error 接口。
| 函数 | 包 | 签名 | 特点 | 适用场景 |
|---|---|---|---|---|
errors.New |
errors |
func New(text string) error |
创建仅包含静态文本的错误;每次调用返回不同实例(指针比较不相等) | 预定义哨兵错误、简单错误常量 |
fmt.Errorf |
fmt |
func Errorf(format string, a ...any) error |
支持格式化占位符;Go 1.13+ 支持 %w 包装错误 |
动态错误消息、错误链包装 |
2.2.1 errors.New:静态哨兵错误
errors.New 接收一个字符串常量,返回一个实现了 error 接口的内部类型 errorString。该类型不包含任何额外字段,仅存储消息文本。
package errors
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
func New(text string) error {
return &errorString{text}
}
典型用法是定义包级别的哨兵错误,供调用方使用 == 或 errors.Is 判断:
var (
ErrNotFound = errors.New("resource not found")
ErrPermission = errors.New("permission denied")
)
func GetUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrNotFound
}
// ...
}
2.2.2 fmt.Errorf:动态格式化与错误包装
fmt.Errorf 在 errors.New 的基础上增加了格式化能力。Go 1.13 引入的 %w 动词使其能够包装已有错误,形成错误链。
// 简单格式化
err := fmt.Errorf("user %d not found in database", userID)
// 包装错误(Go 1.13+)
if err := readConfig(); err != nil {
return fmt.Errorf("read config: %w", err)
}
%w 包装的错误可以通过 errors.Is 和 errors.As 解包和断言。使用 %v 或 %s 则仅保留消息文本,丢失原始错误类型信息。在需要保留错误链的上下文中,务必使用 %w。
2.3 自定义错误结构体:携带领域上下文
当错误需要携带结构化信息(如错误码、HTTP 状态码、内部原因、调用栈等)时,自定义结构体是最佳选择。通过实现 Error() string 方法,任何结构体都可以成为合法的错误类型。
2.3.1 基础自定义错误
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field '%s' (value=%v): %s",
e.Field, e.Value, e.Message)
}
// 使用
func ValidateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Value: age,
Message: "must be between 0 and 150",
}
}
return nil
}
2.3.2 带错误码的领域错误
在微服务或 API 开发中,错误码是客户端识别错误类型的标准方式。自定义结构体可以同时包含机器可读的错误码和人类可读的消息。
type DomainError struct {
Code int // 业务错误码,如 1001
Message string // 用户友好消息
Err error // 内部根因(可选)
}
func (e *DomainError) 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)
}
// 解包支持:使 errors.As 能够找到内部错误
func (e *DomainError) Unwrap() error {
return e.Err
}
Unwrap() error 方法(Go 1.13+),以便 errors.Is 和 errors.As 能够遍历错误链。这保持了与标准错误检查机制的兼容性。
2.4 三种错误构造方式对比
| 维度 | errors.New | fmt.Errorf | 自定义结构体 |
|---|---|---|---|
| 消息灵活性 | 静态字符串 | 动态格式化 | 完全可控 |
| 携带结构化数据 | 否 | 否(仅文本) | 是(字段、错误码等) |
| 错误链支持 | 无 | 通过 %w |
通过 Unwrap() |
| 类型断言 | 仅 errors.Is |
仅 errors.Is |
errors.As 可提取字段 |
| 适用层级 | 包级哨兵 | 中间层包装 | 领域边界/API 层 |
| 性能开销 | 低 | 中(格式化) | 中(结构体分配) |
2.5 错误构造与断言流程
下面的 SVG 图展示了从错误构造到调用方断言处理的完整流程,涵盖三种构造方式及其对应的断言方法。
2.6 自定义错误结构体设计模式
在领域驱动设计(DDD)中,错误是领域语言的一部分。以下模式帮助你在不同层级中正确使用自定义错误。
2.6.1 分层错误设计
| 层级 | 错误类型 | 携带信息 | 示例 |
|---|---|---|---|
| 基础设施层 | errors.New 或 fmt.Errorf |
技术细节(SQL 错误、网络超时) | fmt.Errorf("db query: %w", sql.ErrNoRows) |
| 应用服务层 | 自定义结构体(含错误码) | 业务错误码、用户消息、内部根因 | &DomainError{Code: 1001, Message: "用户不存在"} |
| 接口层(HTTP/gRPC) | 自定义结构体(含状态码) | HTTP 状态码、gRPC 状态、序列化字段 | &HTTPError{Status: 404, Body: ...} |
2.6.2 实现 Unwrap 保持链兼容
为了让自定义错误能够被标准库的 errors.Is 和 errors.As 遍历,必须实现 Unwrap() error 方法。如果错误包含多个根因(如验证错误列表),可以实现 Unwrap() []error(Go 1.20+)。
// 单根因
func (e *DomainError) Unwrap() error {
return e.Err
}
// 多根因(Go 1.20+)
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
// 拼接所有错误消息
}
func (m *MultiError) Unwrap() []error {
return m.Errors
}
2.7 安全断言:避免类型断言陷阱
在错误处理中,类型断言是提取自定义错误字段的关键操作。但直接使用 e.(*CustomError) 存在风险,尤其是在错误被包装后。
- 优先使用
errors.As:它会遍历整个错误链,找到第一个匹配类型的错误。即使错误被多层fmt.Errorf("%w")包装,也能正确提取。 - 避免直接类型断言:
if e, ok := err.(*DomainError); ok { ... }仅检查最外层错误。如果错误被包装过,断言会失败。 - 使用
errors.Is判断哨兵:对于errors.New创建的静态错误,使用errors.Is而非==直接比较,以支持错误链。
// 推荐:使用 errors.As 遍历链
var domainErr *DomainError
if errors.As(err, &domainErr) {
fmt.Println("错误码:", domainErr.Code)
}
// 不推荐:直接类型断言,无法处理包装
if e, ok := err.(*DomainError); ok {
// 如果 err 被 fmt.Errorf("%w", err) 包装过,这里永远不会执行
}
2.8 本章小结
- error 接口是 Go 错误处理的基石,仅要求实现
Error() string方法。 - errors.New 适用于静态哨兵错误,fmt.Errorf 适用于动态消息和错误包装,自定义结构体适用于携带结构化领域信息。
- 自定义错误应实现
Unwrap() error以保持与标准错误检查机制的兼容性。 - 在断言错误时,优先使用
errors.Is和errors.As,避免直接类型断言,确保错误链的完整遍历。 - 在领域边界处,使用带错误码的自定义结构体,将技术错误转换为业务可理解的错误信息。