14·类型系统进阶

自定义错误与错误包装

custom-errorwraperrors-iserrors-as

第14课:自定义错误与错误包装

学习目标

  • 理解为什么标准错误有时不够用,并能够创建自己的自定义错误类型。
  • 掌握在 Go 中构建“错误链”的方法,学会使用 fmt.Errorf 配合 %w 动词包装错误。
  • 熟练运用 errors.Iserrors.As 函数来检查和处理错误链。
  • 明白在何时应该选择创建自定义错误,而不是使用简单的字符串错误。

核心概念

在上一课我们学习了基础的 error 接口和错误处理流程。但在实际项目中,一个简单的字符串错误信息(如 errors.New("something failed"))往往无法满足需求。

为什么需要自定义错误?

  1. 携带更多上下文信息:标准错误只包含一个消息字符串。自定义错误可以是一个结构体,包含错误码、操作名称、时间戳等额外数据,便于程序做出更精细的响应。
  2. 区分错误类型:不同的错误需要不同的处理逻辑。例如,数据库连接错误和文件权限错误是完全不同类型的问题,程序需要能够识别它们。
  3. 构建错误链:当低层函数返回一个错误时,高层函数通常希望在保留原始错误的同时,加上自己的描述信息(比如“从数据库读取用户失败:[原始错误]”),这被称为“错误包装”。

创建自定义错误类型

任何实现了 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
			//    原始错误: 数据校验失败
		}
	}
}

实践练习

  1. 基础练习:创建一个名为 ValidationError 的自定义错误类型,它包含一个 Field string 字段,表示验证失败的字段名。编写一个 validateAge(age int) error 函数,当年龄小于0或大于150时,返回一个 ValidationError

    • 预期输出:调用 validateAge(-5) 应能捕获到一个错误,并打印出类似“字段 'age' 验证失败”的信息。
  2. 中级练习:在上一练习的基础上,修改 validateAge 函数,让它在返回 ValidationError 之前,先使用 fmt.Errorf 包装一个底层错误 errors.New("值超出有效范围")。然后在 main 函数中,分别使用 errors.Is 检查它是否是原始错误,并使用 errors.As 提取出 ValidationError 来访问 Field 字段。

    • 预期输出:程序应能同时验证两种检查均成功。
  3. 综合练习:模拟一个并发安全的操作。编写一个函数 processItem(id int, shouldFail bool) error。如果 shouldFailtrue,函数应返回一个自定义的 ProcessingError。然后,使用一个 goroutine 池并发执行多个 processItem,并使用 sync.WaitGroup 等待所有任务完成。在主协程中,收集所有错误,并使用 errors.As 来统计 ProcessingError 类型的错误数量。

    • 预期输出:主协程应能正确统计出并发任务中失败的数量。

常见错误

  1. 忘记实现 Unwrap 方法:如果你的自定义错误类型包装了另一个错误,但没有实现 Unwrap() error 方法,那么 errors.Iserrors.As 将无法遍历到被包装的内部错误。
  2. 错误地使用 %v 代替 %w:在 fmt.Errorf 中,%v 会格式化错误的字符串表示,但不会创建错误链。只有 %w 才会真正“包装”错误,使其可以被 errors.Iserrors.As 检查。
  3. errors.As 中使用非指针类型errors.As 的第二个参数必须是一个指向目标类型的指针。例如,检查 *FileError 时,应传递 &fileErr,其中 fileErr 的类型是 *FileError
  4. 滥用自定义错误:不是所有错误都需要自定义类型。对于简单的、无需程序特殊处理的错误,使用 errors.Newfmt.Errorf 创建的简单错误完全足够。过度设计会增加代码复杂度。

小结

  • 自定义错误:通过定义结构体并实现 Error() string 方法,可以创建携带丰富上下文信息的错误类型,使错误处理更精细。
  • 错误包装:使用 fmt.Errorf("...: %w", err) 将新错误与旧错误关联起来,形成错误链,有助于在调用栈中传递和保留根本原因。
  • 链式检查
    • errors.Is(err, target) 用于判断错误链中是否包含某个特定的错误值。
    • errors.As(err, &target) 用于在错误链中查找第一个能匹配到目标类型的错误,并将其赋值给 target,从而访问自定义错误的字段。
  • 最佳实践:自定义错误适用于需要程序根据不同错误原因采取不同行动,或需要为调用者提供更多诊断信息的场景。简单场景下,保持简单。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「包与模块」 以巩固所学知识。