Go 零基础教程

Go 错误处理进阶

在掌握了基础的 error 接口和 if err != nil 之后,你已经能够处理 Go 语言中 80% 的日常错误了。

但是,随着业务逻辑的复杂化,仅仅返回一句简单的字符串(如 "division by zero")往往是不够的。我们需要知道错误发生时的具体参数是什么?这个错误是底层网络超时引起的,还是数据库连接断开引起的?为了解决这些问题,我们需要掌握自定义错误类型以及 Go 1.13 引入的错误包装 (Error Wrapping) 机制。

1. 打造自定义错误类型 (Custom Error Types)

内置的 errors.New() 只能包含一条简单的文本信息。如果我们需要更结构化的数据,可以通过定义一个 struct 并让它实现 Error() string 方法,来创建专属的自定义错误。

1.1 定义自定义错误结构体

我们来定义一个 MathError,它不仅能说明发生了什么错误,还能记录导致错误的具体输入值:

package main

import (
	"fmt"
	"math"
)

// 1. 定义一个包含上下文信息的结构体
type MathError struct {
	Message string
	Input   float64
}

// 2. 为该结构体实现 Error() string 方法 (使用指针接收者)
func (e *MathError) Error() string {
	return fmt.Sprintf("数学运算错误: %s, 异常输入值: %f", e.Message, e.Input)
}

// 3. 在业务函数中使用该自定义错误
func squareRoot(x float64) (float64, error) {
	if x < 0 {
		// 返回自定义错误对象的指针
		return 0, &MathError{Message: "无法对负数求平方根", Input: x}
	}
	return math.Sqrt(x), nil
}

1.2 提取自定义错误信息 (类型断言)

当调用方拿到一个 error 接口时,如果想提取出里面的 Input 字段,需要使用类型断言 (Type Assertion) 将其还原为具体的 *MathError 类型。

func main() {
	result, err := squareRoot(-4)
	
	if err != nil {
		// 尝试将普通 error 断言为具体的 *MathError
		mathErr, ok := err.(*MathError)
		
		if ok {
			fmt.Printf("捕获到精确的 MathError!详情: %s, 罪魁祸首: %f\n", mathErr.Message, mathErr.Input)
		} else {
			fmt.Println("发生了一个普通错误:", err)
		}
		return
	}
	fmt.Println("计算结果:", result)
}

2. 错误包装与错误链 (Error Wrapping)

在多层调用的系统中,底层函数抛出的错误往往需要在上层增加一些上下文信息(比如:“读取配置文件失败” -> “打开文件失败” -> “权限不足”)。

在 Go 1.13 之前,人们只能把错误转成字符串拼接起来,这会丢失底层错误的原始类型。现在,Go 提供了 fmt.Errorf 配合 %w 动词,像俄罗斯套娃一样把错误“包装”起来。

2.1 使用 %w 包装错误

package main

import (
	"errors"
	"fmt"
)

// 定义一个底层的哨兵错误 (Sentinel Error)
var ErrNegativeValue = errors.New("底层错误: 输入了负数")

func innerFunction(value int) error {
	if value < 0 {
		return ErrNegativeValue // 抛出底层错误
	}
	return nil
}

func outerFunction(value int) error {
	err := innerFunction(value)
	if err != nil {
		// 使用 %w 将内部的 err 包装起来,并附加上下文
		return fmt.Errorf("外层调用失败: %w", err)
	}
	return nil
}

func main() {
	err := outerFunction(-5)
	if err != nil {
		fmt.Println(err) // 输出: 外层调用失败: 底层错误: 输入了负数
	}
}

3. 拆解与检验错误链

错误被包装起来后,我们如何探查最里面到底是不是某个特定的错误呢?标准库的 errors 包提供了两个极其强大的神兵利器。

3.1 核心工具对比表

工具函数核心作用适用场景
errors.Is(err, target)判断错误链中是否包含某个特定的目标错误。用于判断哨兵错误(预定义的全局错误变量)。
errors.Unwrap(err)剥开最外面的一层包装,返回下一层的错误。用于逐层分析错误链(如果没被包装,则返回 nil)。

(补充现实提示:我们在对比错误时,目标错误必须是一个预先定义好的变量(如 ErrNegativeValue),千万不要用 errors.Is(err, errors.New("..."))去比较,因为每次调用 errors.New 都会在内存中生成一个全新的指针,导致对比永远为 false。)

3.2 追踪错误链实战

基于上面的套娃代码,我们在 main 函数中进行追踪:

func main() {
	err := outerFunction(-5)
	
	if err != nil {
		// 1. 使用 errors.Is 穿透包装,精准匹配底层错误
		if errors.Is(err, ErrNegativeValue) {
			fmt.Println("追踪确认:错误链中确实包含了 '输入负数' 这个底层根因。")
		}

		// 2. 使用 errors.Unwrap 手动剥开一层外衣
		unwrappedErr := errors.Unwrap(err)
		if unwrappedErr != nil {
			fmt.Println("剥开外衣后的下一层错误是:", unwrappedErr)
		}
	}
}

4. 错误处理的高级最佳实践

  • 永远不要忽略错误: 绝不要用空白标识符 _ 掩耳盗铃般地丢弃 error。如果不处理,程序迟早会在意想不到的地方崩溃。
  • 追加上下文,但别破坏根因: 当你拦截到一个错误并准备向上抛出时,尽量使用 fmt.Errorf("...: %w", err) 包装它,而不是直接返回它。这能为后续的日志排障提供极大的便利。
  • 在源头处理错误: 错误应该在离它发生最近的、拥有足够上下文的地方被处理(或包装抛出)。
  • 使用哨兵错误 (Sentinel Errors): 将常见的错误(如 var ErrNotFound = errors.New("not found")定义为包级别的全局变量,方便调用方使用 errors.Is 进行精准匹配。