第14课:自定义错误与错误包装
学习目标
- 理解为什么标准错误有时不够用,并能够创建自己的自定义错误类型。
- 掌握在 Go 中构建“错误链”的方法,学会使用
fmt.Errorf配合%w动词包装错误。 - 熟练运用
errors.Is和errors.As函数来检查和处理错误链。 - 明白在何时应该选择创建自定义错误,而不是使用简单的字符串错误。
核心概念
在上一课我们学习了基础的 error 接口和错误处理流程。但在实际项目中,一个简单的字符串错误信息(如 errors.New("something failed"))往往无法满足需求。
为什么需要自定义错误?
- 携带更多上下文信息:标准错误只包含一个消息字符串。自定义错误可以是一个结构体,包含错误码、操作名称、时间戳等额外数据,便于程序做出更精细的响应。
- 区分错误类型:不同的错误需要不同的处理逻辑。例如,数据库连接错误和文件权限错误是完全不同类型的问题,程序需要能够识别它们。
- 构建错误链:当低层函数返回一个错误时,高层函数通常希望在保留原始错误的同时,加上自己的描述信息(比如“从数据库读取用户失败:[原始错误]”),这被称为“错误包装”。
创建自定义错误类型
任何实现了 Error() string 方法的类型都是一个 error。最简单的方式是定义一个新的结构体。
错误包装 (Error Wrapping)
Go 1.13 引入了错误包装的概念。我们可以在一个错误之上“包裹”另一个错误,形成一个链。被包裹的错误称为“包装错误”或“原因”。使用 fmt.Errorf 函数并配合 %w 动词可以轻松实现。
if err != nil;
return fmt.Errorf("无法打开文件 %s: %w", filename, err)
}
检查错误链
对于被包装过的错误,简单的 == 比较已经失效。我们需要使用标准库中 errors 包提供的两个强大函数:
errors.Is:检查错误链中是否存在特定的错误值。常用于判断一个错误是否是某个 sentinel error(如os.ErrNotExist)。errors.As:在错误链中查找并尝试将错误赋值给特定的类型。常用于提取我们自定义的错误类型,以便访问其附加字段。
代码示例
让我们通过一个模拟文件操作的场景来实践这些概念。
package main
import (
"errors"
"fmt"
"os"
)
// 1. 定义一个自定义错误类型
type FileError struct {
Path string
Op string // 操作,如 "read", "write"
Err error // 包裹的底层错误
}
// 实现 error 接口
func (e *FileError) Error() string {
return fmt.Sprintf("文件操作失败 [操作: %s, 路径: %s]: %v", e.Op, e.Path, e.Err)
}
// 实现 Unwrap 方法,以支持 errors.Is 和 errors.As 遍历错误链
func (e *FileError) Unwrap() error {
return e.Err
}
// 2. 模拟一个会返回自定义错误和包装错误的函数
func readFile(path string) ([]byte, error) {
// 模拟文件不存在的情况,返回一个被包装的 sentinel error
if path == "missing.txt" {
return nil, &FileError{
Path: path,
Op: "read",
Err: os.ErrNotExist, // 包裹了一个特定的错误值
}
}
// 模拟一个普通错误
if path == "corrupted.txt" {
return nil, &FileError{
Path: path,
Op: "read",
Err: errors.New("数据校验失败"),
}
}
return []byte("文件内容"), nil
}
func main() {
// 测试场景1:检查文件是否存在
_, err := readFile("missing.txt")
if err != nil {
// 使用 errors.Is 检查错误链中是否包含 os.ErrNotExist
if errors.Is(err, os.ErrNotExist) {
fmt.Println("✅ 错误被捕获:文件不存在。")
// 输出:✅ 错误被捕获:文件不存在。
}
}
fmt.Println("---")
// 测试场景2:提取自定义错误类型
_, err = readFile("corrupted.txt")
if err != nil {
// 声明一个指向自定义错误类型的指针
var fileErr *FileError
// 使用 errors.As 检查错误链,并将错误赋值给 fileErr
if errors.As(err, &fileErr) {
fmt.Printf("✅ 提取到自定义错误:\n")
fmt.Printf(" 操作: %s\n", fileErr.Op)
fmt.Printf(" 路径: %s\n", fileErr.Path)
fmt.Printf(" 原始错误: %v\n", fileErr.Err)
// 输出:
// ✅ 提取到自定义错误:
// 操作: read
// 路径: corrupted.txt
// 原始错误: 数据校验失败
}
}
}
实践练习
-
基础练习:创建一个名为
ValidationError的自定义错误类型,它包含一个Field string字段,表示验证失败的字段名。编写一个validateAge(age int) error函数,当年龄小于0或大于150时,返回一个ValidationError。- 预期输出:调用
validateAge(-5)应能捕获到一个错误,并打印出类似“字段 'age' 验证失败”的信息。
- 预期输出:调用
-
中级练习:在上一练习的基础上,修改
validateAge函数,让它在返回ValidationError之前,先使用fmt.Errorf包装一个底层错误errors.New("值超出有效范围")。然后在main函数中,分别使用errors.Is检查它是否是原始错误,并使用errors.As提取出ValidationError来访问Field字段。- 预期输出:程序应能同时验证两种检查均成功。
-
综合练习:模拟一个并发安全的操作。编写一个函数
processItem(id int, shouldFail bool) error。如果shouldFail为true,函数应返回一个自定义的ProcessingError。然后,使用一个 goroutine 池并发执行多个processItem,并使用sync.WaitGroup等待所有任务完成。在主协程中,收集所有错误,并使用errors.As来统计ProcessingError类型的错误数量。- 预期输出:主协程应能正确统计出并发任务中失败的数量。
常见错误
- 忘记实现
Unwrap方法:如果你的自定义错误类型包装了另一个错误,但没有实现Unwrap() error方法,那么errors.Is和errors.As将无法遍历到被包装的内部错误。 - 错误地使用
%v代替%w:在fmt.Errorf中,%v会格式化错误的字符串表示,但不会创建错误链。只有%w才会真正“包装”错误,使其可以被errors.Is和errors.As检查。 - 在
errors.As中使用非指针类型:errors.As的第二个参数必须是一个指向目标类型的指针。例如,检查*FileError时,应传递&fileErr,其中fileErr的类型是*FileError。 - 滥用自定义错误:不是所有错误都需要自定义类型。对于简单的、无需程序特殊处理的错误,使用
errors.New或fmt.Errorf创建的简单错误完全足够。过度设计会增加代码复杂度。
小结
- 自定义错误:通过定义结构体并实现
Error() string方法,可以创建携带丰富上下文信息的错误类型,使错误处理更精细。 - 错误包装:使用
fmt.Errorf("...: %w", err)将新错误与旧错误关联起来,形成错误链,有助于在调用栈中传递和保留根本原因。 - 链式检查:
errors.Is(err, target)用于判断错误链中是否包含某个特定的错误值。errors.As(err, &target)用于在错误链中查找第一个能匹配到目标类型的错误,并将其赋值给target,从而访问自定义错误的字段。
- 最佳实践:自定义错误适用于需要程序根据不同错误原因采取不同行动,或需要为调用者提供更多诊断信息的场景。简单场景下,保持简单。