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进行精准匹配。