2. Go 错误处理基础:error 接口、errors.New 与 fmt.Errorf 的使用、错误链与 Unwrap
在 Go 语言中,错误处理不是异常抛出机制,而是通过返回 error 接口值来传递失败信息。这种设计迫使开发者显式处理每一个可能出错的调用,是 Go 代码健壮性的基石。本章从接口定义出发,逐步深入到错误构建、格式化与错误链拆解,为后续慢查询断言与错误码映射打下基础。
2.1 error 接口:一切错误的根类型
Go 内置的 error 是一个极其简洁的接口,只包含一个方法:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都可以作为错误值传递。这意味着你可以自定义错误结构体,携带额外的上下文信息(如错误码、SQL 语句、耗时等)。
| 特性 | 说明 |
|---|---|
| 接口方法数 | 1 个(Error() string) |
| 零值语义 | nil 表示无错误 |
| 传递方式 | 作为函数返回值,通常放在最后一个返回值 |
| 比较方式 | 与 nil 比较,或使用类型断言/switch 判断具体类型 |
2.2 errors.New:快速创建简单错误
errors.New 接收一个字符串,返回一个包含该字符串的 error 值。这是最基础的错误构造方式:
import "errors"
var ErrNotFound = errors.New("record not found")
func GetUser(id int) (*User, error) {
// 模拟查询失败
return nil, ErrNotFound
}
使用 errors.New 创建的错误值,其 Error() 方法直接返回传入的字符串。注意:每次调用 errors.New 都会分配新的错误实例,但相同字符串的错误值通过 == 比较时并不相等(除非使用包级别变量复用同一个实例)。
var 变量,便于调用方使用 == 或 errors.Is 进行断言,避免字符串硬编码。
2.3 fmt.Errorf:带格式化上下文的错误
当需要将动态值嵌入错误消息时,fmt.Errorf 比 errors.New 更灵活:
import "fmt"
func QueryDB(sql string, args ...interface{}) error {
// 模拟数据库超时
return fmt.Errorf("query timeout: sql=%s, args=%v", sql, args)
}
fmt.Errorf 返回的也是 error 接口,其内部实现与 errors.New 类似,但支持 %s、%d、%v 等格式化动词。在 Go 1.13 之后,fmt.Errorf 还支持 %w 动词用于包装错误(见 2.4 节)。
| 构造方式 | 适用场景 | 是否支持包装 |
|---|---|---|
errors.New("msg") |
静态错误消息,无动态参数 | 否 |
fmt.Errorf("msg %v", val) |
需要嵌入动态值的错误消息 | 否(使用 %v 时) |
fmt.Errorf("msg: %w", err) |
需要包装原始错误,保留错误链 | 是(使用 %w 时) |
2.4 错误链与 Unwrap:从包装中还原根因
在实际系统中,错误往往经过多层函数传递,每一层都可能添加上下文。Go 1.13 引入了错误链机制,通过 %w 动词和 Unwrap 接口实现错误包装与拆解。
2.4.1 使用 %w 包装错误
import "fmt"
func LoadConfig(path string) error {
return fmt.Errorf("load config: %w", ErrNotFound)
}
func InitApp() error {
if err := LoadConfig("/etc/app.yaml"); err != nil {
return fmt.Errorf("init app failed: %w", err)
}
return nil
}
通过 %w 包装后,原始错误 ErrNotFound 被嵌入到新错误中,形成一条错误链:init app failed → load config → record not found。
2.4.2 Unwrap 接口与 errors.Is/errors.As
被包装的错误实现了 Unwrap() error 接口,标准库提供了两个函数来遍历错误链:
- errors.Is(err, target):沿着错误链查找,判断是否存在与 target 相等的错误。
- errors.As(err, target):沿着错误链查找,判断是否存在可以赋值给 target 类型的错误。
import "errors"
func main() {
err := InitApp()
// 使用 errors.Is 检查链中是否包含 ErrNotFound
if errors.Is(err, ErrNotFound) {
fmt.Println("根因是记录未找到")
}
// 使用 errors.As 提取特定类型的错误
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Printf("数据库错误码: %d\n", dbErr.Code)
}
}
== 直接比较包装后的错误,因为包装后的错误是一个新实例。始终使用 errors.Is 或 errors.As 进行错误链断言。
2.5 错误链结构图
下图展示了错误从底层到顶层的包装过程,以及 errors.Is 如何沿着 Unwrap 链回溯查找根因:
2.6 自定义错误类型与错误码
在搜索错误码断言场景中,仅靠字符串消息远远不够。我们需要携带错误码、SQL 语句、耗时等结构化信息。自定义错误类型是实现这一目标的标准方式:
type SQLError struct {
Code int // 业务错误码,如 1001 表示超时
Message string // 人类可读描述
SQL string // 触发错误的 SQL 语句
Err error // 原始底层错误(可选)
}
func (e *SQLError) Error() string {
return fmt.Sprintf("[%d] %s | sql: %s", e.Code, e.Message, e.SQL)
}
func (e *SQLError) Unwrap() error {
return e.Err
}
通过实现 Unwrap() error 方法,自定义错误类型也能融入标准错误链,被 errors.Is 和 errors.As 识别。
2.7 错误处理模式对比
| 模式 | 代码示例 | 适用场景 | 错误链支持 |
|---|---|---|---|
| 哨兵错误 | var ErrNotFound = errors.New("not found") |
预定义的已知错误,调用方需要精确匹配 | 否(本身是叶子节点) |
| 包装错误 | fmt.Errorf("context: %w", err) |
在传递过程中添加上下文,保留根因 | 是(通过 %w) |
| 自定义类型 | type SQLError struct { Code int; ... } |
需要携带结构化字段(错误码、SQL、耗时等) | 是(实现 Unwrap) |
| 透明错误 | return fmt.Errorf("something went wrong") |
对外隐藏内部细节,仅记录日志 | 否(不包装) |
%w 包装底层数据库驱动错误。这样上层断言函数可以通过 errors.As 提取错误码,通过 errors.Is 判断底层驱动错误类型,实现精准的故障分类。
2.8 错误链拆解实战示例
以下代码展示了如何从多层包装的错误中提取根因和结构化信息:
func handleError(err error) {
// 1. 检查是否为自定义 SQLError
var sqlErr *SQLError
if errors.As(err, &sqlErr) {
fmt.Printf("错误码: %d, SQL: %s\n", sqlErr.Code, sqlErr.SQL)
}
// 2. 检查链中是否包含特定哨兵错误
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("根因是上下文超时")
}
// 3. 手动遍历错误链(调试用)
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" → %s\n", e.Error())
}
}
输出示例:
错误码: 1001, SQL: SELECT * FROM users WHERE id = ?
根因是上下文超时
→ query user failed: context deadline exceeded
→ [1001] query timeout | sql: SELECT * FROM users WHERE id = ?
→ context deadline exceeded
通过错误链机制,我们既保留了完整的调用上下文,又能精确提取底层根因和结构化错误码,这正是搜索错误码断言系统的核心数据基础。