2. Go 错误处理基础:error接口、errors.Is/As、fmt.Errorf与%w
Go 语言的错误处理哲学强调“显式而非隐式”。与多数语言使用异常抛出/捕获不同,Go 将错误视为普通值,通过函数返回值传递。这种设计让错误处理路径清晰可见,但也要求开发者掌握一套精确的错误断言与传播机制。本章聚焦于 Go 错误处理的核心抽象——error 接口,以及标准库提供的错误包装、判断与断言工具。
2.1 error 接口:一切错误的契约
Go 语言内置的 error 接口是所有错误类型的共同契约,定义极其简洁:
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都可以作为错误值传递。这意味着错误本质上是一个携带字符串描述信息的值,可以像其他值一样被赋值、比较、返回。
error 时,调用方必须显式检查。编译器不会强制检查,但良好的代码风格要求每次调用后都处理错误。
2.1.1 自定义错误类型
在实际项目中,仅靠字符串描述往往不够。我们需要携带结构化信息,例如错误码、上下文、堆栈等。以下是一个典型的自定义错误类型:
type MyError struct {
Code int
Message string
Op string // 记录操作名称
}
func (e *MyError) Error() string {
return fmt.Sprintf("[%s] code=%d: %s", e.Op, e.Code, e.Message)
}
自定义错误类型的优势在于:调用方可以通过类型断言获取结构化字段,实现精细化错误处理。
2.2 errors.Is 与 errors.As:错误链上的断言工具
当错误被多次包装后,直接使用 == 比较或类型断言会失效。Go 1.13 引入的 errors.Is 和 errors.As 解决了这个问题,它们能够沿着错误包装链(wrapping chain)进行递归查找。
| 函数 | 用途 | 返回值 | 典型场景 |
|---|---|---|---|
errors.Is(err, target) |
判断错误链中是否存在与 target 相等的错误 | bool |
检查是否为特定哨兵错误(如 io.EOF) |
errors.As(err, target) |
将错误链中第一个匹配类型的错误赋值给 target | bool |
提取自定义错误类型中的结构化字段 |
2.2.1 errors.Is:哨兵错误匹配
哨兵错误(sentinel error)是包级别定义的特定错误值,如 io.EOF、sql.ErrNoRows。使用 errors.Is 可以安全地跨包装层进行匹配:
var ErrNotFound = errors.New("resource not found")
func findResource(id int) error {
// ... 查找逻辑
return fmt.Errorf("find resource %d: %w", id, ErrNotFound)
}
func main() {
err := findResource(42)
if errors.Is(err, ErrNotFound) {
// 即使 err 被 fmt.Errorf 包装,也能正确匹配
fmt.Println("资源未找到,执行降级逻辑")
}
}
2.2.2 errors.As:提取结构化错误
当需要从错误链中获取自定义错误类型的字段时,使用 errors.As:
type NetworkError struct {
Code int
Message string
}
func (e *NetworkError) Error() string {
return fmt.Sprintf("network error %d: %s", e.Code, e.Message)
}
func callService() error {
// 模拟网络错误
return &NetworkError{Code: 503, Message: "service unavailable"}
}
func handleError(err error) {
var netErr *NetworkError
if errors.As(err, &netErr) {
// 成功提取 NetworkError 类型,可以访问 Code 和 Message
fmt.Printf("网络错误码: %d, 详情: %s\n", netErr.Code, netErr.Message)
}
}
errors.As 的第二个参数必须是指向目标类型的指针(&netErr),且目标类型必须是接口或指针类型。如果传入 nil 或非指针,会导致 panic。
2.3 fmt.Errorf 与 %w:错误包装的标准化方式
Go 1.13 之前,包装错误通常需要手动拼接字符串,丢失了原始错误的类型信息。引入 %w 动词后,fmt.Errorf 可以创建包含原始错误的包装错误,且该包装错误支持 errors.Is 和 errors.As 的链式遍历。
2.3.1 %w 的基本用法
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// 使用 %w 包装原始错误
return fmt.Errorf("read config file %s: %w", path, err)
}
// ... 处理 data
return nil
}
func main() {
err := readConfig("/etc/app/config.yaml")
if err != nil {
// 可以判断底层错误是否为 os.ErrNotExist
if errors.Is(err, os.ErrNotExist) {
fmt.Println("配置文件不存在,使用默认配置")
} else {
fmt.Println("读取配置失败:", err)
}
}
}
2.3.2 %w 与 %v 的区别
| 动词 | 行为 | 是否支持 errors.Is/As | 适用场景 |
|---|---|---|---|
%w |
包装错误,创建错误链 | 是 | 需要保留原始错误类型供上层断言 |
%v |
仅格式化字符串,不保留错误链 | 否 | 仅需日志记录,无需错误断言 |
%w 包装错误,以便上层调用方能够通过 errors.Is 或 errors.As 进行精确的错误处理。仅在顶层(如 main 函数或 HTTP handler)使用 %v 或直接打印错误。
2.4 错误链与断言流程
下图展示了错误从底层到顶层的传播路径,以及 errors.Is 和 errors.As 在错误链上的遍历方式:
2.5 错误断言实战模式
在领域边界防御中,错误断言通常遵循以下模式:
- 定义领域错误码:在包级别定义哨兵错误或错误类型,作为领域边界契约。
- 包装时保留链:在内部实现中使用
fmt.Errorf + %w包装错误,不丢失原始信息。 - 断言时使用标准工具:在边界层(如 HTTP handler、gRPC interceptor)使用
errors.Is和errors.As进行精确判断。 - 避免直接比较:不要使用
==比较包装后的错误,也不要使用类型断言直接转换,除非确定错误未被包装。
fmt.Errorf("package.funcName: %w", err),便于日志追踪和错误链分析。同时,避免在同一个错误上多次使用 %w 包装同一类型的错误,以免造成冗余链。
2.6 常见错误与避坑指南
| 错误模式 | 问题描述 | 正确做法 |
|---|---|---|
使用 %v 代替 %w |
丢失错误链,上层无法断言 | 在需要保留错误链的场景使用 %w |
| 直接比较包装后的错误 | err == io.EOF 在包装后永远为 false |
使用 errors.Is(err, io.EOF) |
| 类型断言未检查 ok | 对非目标类型断言导致 panic | 使用 errors.As 或带 ok 检查的类型断言 |
| 重复包装同一错误 | 错误链过长,影响性能 | 只在关键边界层包装一次 |
| 忽略 sentinel error 的导出 | 外部包无法引用哨兵错误 | 将哨兵错误定义为导出变量(首字母大写) |