错误处理:error 接口、错误包装与 sentinel error
本课所属:第二模块:复合数据类型与面向对象 | 课时:m02-l05 | 难度:入门 学完本课你将能够:掌握 Go 语言中错误处理的标准范式,理解错误链机制,并正确使用 panic/recover 处理不可恢复的异常。
一、学习目标
- 能解释
error接口的定义及其在 Go 错误处理中的核心地位。 - 能使用
fmt.Errorf配合%w进行错误包装,并利用errors.Is和errors.As检查错误链。 - 能区分哨兵错误(Sentinel Error)与自定义错误类型,并理解
errors.Join的使用场景。 - 能正确理解
panic与recover的作用边界,避免滥用导致程序崩溃。
二、知识讲解
2.1 error 接口与自定义错误类型
error 是 Go 语言中用于表示错误状态的标准接口。它是预声明标识符(在 builtin 文档中列出,但不需要 import),仅包含一个方法:
type error interface {
Error() string
}
为什么这样设计? Go 语言没有像 Java 或 Python 那样的异常类层次结构。错误被视为一种普通的值,可以像其他数据一样被传递、存储和比较。这种设计使得错误处理显式且可控,避免了隐式异常捕获带来的代码流混乱。
自定义错误类型
虽然 errors.New 可以创建简单的错误,但在生产环境中,我们通常需要携带更多信息(如 HTTP 状态码、错误码、堆栈信息等)。为此,我们需要定义实现 error 接口的自定义结构体。
为了支持更精细的错误判断,自定义错误类型可以实现 Is(error) bool 方法。当实现此方法时,errors.Is 在检查该错误类型时会优先调用此方法,从而允许自定义错误与哨兵错误进行逻辑匹配,而不仅仅是指针相等比较。
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("code=%d, msg=%s", e.Code, e.Msg)
}
// 可选:实现 Is 方法以支持 errors.Is 的逻辑匹配
func (e *MyError) Is(target error) bool {
// 例如,判断是否匹配特定的哨兵错误
return target == ErrSpecificError
}
2.2 错误包装与错误链
在多层函数调用中,底层函数返回的错误往往需要向上传递。如果直接返回,调用者将丢失原始的上下文信息(例如“数据库查询失败”背后的具体原因是“连接超时”还是“SQL 语法错误”)。
错误包装(Error Wrapping)
Go 1.13 引入了错误包装机制。使用 fmt.Errorf 配合 %w 动词,可以将原始错误包装在一个新的错误中,同时保留原始错误的引用。
// Go 1.13 引入 %w
err := fmt.Errorf("failed to process: %w", originalErr)
Go 1.20 的改进
从 Go 1.20 开始,fmt.Errorf 允许在同一个格式字符串中使用多个 %w,从而支持更复杂的错误链构建。
检查错误链:errors.Is 与 errors.As
- errors.Is(err, target error) bool:沿 err 的包装链向上查找,判断链中是否存在与 target 相等的错误。它主要用于检查哨兵错误(Sentinel Error)。
- errors.As(err, target interface{}) bool:沿 err 的包装链查找第一个可以赋值给 target 所指向类型的错误,并将该错误写入 target。target 必须是一个指向某种错误类型或 error 接口的非 nil 指针。它主要用于提取自定义错误中的具体字段。
哨兵错误(Sentinel Error)
哨兵错误是预定义的、全局唯一的错误变量,通常用于表示特定的业务错误状态。由于 errors.Is 对指针类型使用指针相等比较,因此哨兵错误通常定义为包级变量。
errors.Join(Go 1.20+)
当需要同时返回多个错误时(例如批量处理中部分失败),可以使用 errors.Join。它返回一个包含所有错误的复合错误,errors.Is 可以分别命中其中的任意一个错误。
2.3 panic 与 recover 的边界
panic 用于触发程序崩溃,recover 用于恢复。它们通常用于处理不可恢复的异常(如空指针解引用、数组越界),而不是业务逻辑错误。
recover 的两个硬边界
1. 必须直接调用:recover 必须在被 defer 的函数中直接调用才有效。如果 recover 被封装在另一个普通函数中调用,它将返回 nil,无法捕获 panic。
2. 不跨 goroutine:panic 只沿当前 goroutine 的调用栈展开。其他 goroutine 无法 recover 本 goroutine 的 panic。因此,每个可能 panic 的 goroutine 都必须自带 defer/recover。
三、示例代码
示例 1:错误包装与检查
本示例演示如何使用 fmt.Errorf 包装错误,并使用 errors.Is 和 errors.As 检查错误链。
package main
import (
"errors"
"fmt"
)
// 哨兵错误:表示记录未找到
var ErrRecordNotFound = errors.New("record not found")
// 自定义错误类型:数据库错误
type DatabaseError struct {
Code int
Msg string
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("db error: code=%d, msg=%s", e.Code, e.Msg)
}
// queryDatabase 模拟数据库查询,可能返回哨兵错误或自定义错误
func queryDatabase(id int) error {
if id == 404 {
return ErrRecordNotFound
}
if id == 500 {
return &DatabaseError{Code: 500, Msg: "internal server error"}
}
return nil
}
func main() {
// 场景 1:检查哨兵错误
err := queryDatabase(404)
if errors.Is(err, ErrRecordNotFound) {
fmt.Println("Caught sentinel error: record not found")
}
// 场景 2:检查自定义错误类型
err = queryDatabase(500)
var dbErr *DatabaseError
if errors.As(err, &dbErr) {
fmt.Printf("Caught custom error: code=%d\n", dbErr.Code)
}
// 场景 3:错误包装与链式检查
wrappedErr := fmt.Errorf("failed to query: %w", err)
// 正确的做法:使用 errors.As 检查包装链中是否有 DatabaseError
var targetDBErr *DatabaseError
if errors.As(wrappedErr, &targetDBErr) {
fmt.Printf("Wrapped error contains DatabaseError: code=%d\n", targetDBErr.Code)
}
}
运行结果(预期输出):
Caught sentinel error: record not found
Caught custom error: code=500
Wrapped error contains DatabaseError: code=500
示例 2:panic 与 recover 的边界
本示例演示 recover 的正确用法、直接调用要求以及不跨 goroutine 的特性。
package main
import (
"fmt"
"sync"
)
// safeFunc 演示 recover 在 defer 中直接调用有效
func safeFunc() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in safeFunc: %v\n", r)
}
}()
panic("panic in safeFunc")
}
// recoverInNormalFunc 演示 recover 不在 defer 中直接调用
func recoverInNormalFunc() interface{} {
return recover() // 返回 nil,因为当前没有 panic 正在展开
}
// goroutinePanic 演示 panic 不跨 goroutine
func goroutinePanic() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in main goroutine: %v\n", r)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in child goroutine: %v\n", r)
}
}()
panic("panic in child goroutine")
}()
wg.Wait()
}
func main() {
fmt.Println("=== Test 1: Direct defer recover ===")
safeFunc()
fmt.Println("\n=== Test 2: Recover in normal function ===")
// 调用 recoverInNormalFunc 不会捕获任何 panic,因为它不是 defer
// 这里我们不调用 panic,所以 recover 返回 nil
r := recoverInNormalFunc()
fmt.Printf("Recover result: %v\n", r)
fmt.Println("\n=== Test 3: Goroutine panic ===")
goroutinePanic()
}
运行结果(预期输出):
=== Test 1: Direct defer recover ===
Recovered in safeFunc: panic in safeFunc
=== Test 2: Recover in normal function ===
Recover result: <nil>
=== Test 3: Goroutine panic ===
Recovered in child goroutine: panic in child goroutine
四、常见错误与避坑指南
-
错误写法:使用
errors.Is(err, &MyError{})检查自定义错误。 原因:errors.Is对指针类型使用指针相等比较。&MyError{}每次求值都创建新指针,与被包装的错误指针不相等。 正确做法:使用errors.As(err, &target)检查类型,或定义全局哨兵错误变量并使用errors.Is。 -
错误写法:在普通函数中调用
recover()试图捕获 panic。 原因:recover只有在被defer直接调用的函数中才有效。如果recover被封装在另一个函数中,它将返回nil。 正确做法:确保defer func() { recover() }()直接写在可能 panic 的函数中。 -
错误写法:在
if条件中直接使用复合字面量而不加括号,如if err == &MyError{Code: 500}。 原因:Go 语法规定,复合字面量出现在if/for/switch的条件位置时必须加括号,否则会被解析为语句块,导致语法错误。 正确做法:写成if err == (&MyError{Code: 500}),但更推荐定义变量后比较。
五、最佳实践
- 优先使用哨兵错误:对于常见的、可预测的业务错误(如“用户不存在”、“权限不足”),定义包级哨兵错误变量,便于调用者使用
errors.Is进行精确判断。 - 使用
errors.As提取自定义错误:当错误需要携带额外信息(如错误码、详情)时,定义自定义错误类型,并使用errors.As在调用处提取这些信息,而不是依赖字符串匹配。 - 避免滥用
panic:panic仅用于处理不可恢复的异常(如程序内部逻辑错误、空指针)。业务逻辑错误应通过返回error来处理。 - 每个 goroutine 自带 recover:如果 goroutine 可能 panic,必须在 goroutine 内部使用
defer/recover捕获,防止整个程序崩溃。 - 使用
errors.Join处理批量错误:在批量处理场景中,如果希望收集所有错误而不是在第一个错误处停止,使用errors.Join返回复合错误。
六、练习题
- 概念题:解释
errors.Is和errors.As的区别,并说明在什么场景下分别使用它们。 - 编码题:定义一个自定义错误类型
ValidationError,包含Field和Message字段。编写一个函数validate,当输入为空时返回ValidationError。在主函数中,使用errors.As提取错误信息并打印。 - 进阶/调试题:分析以下代码,指出存在的问题并修复。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type DBError struct {
Code int
}
func (e *DBError) Error() string {
return fmt.Sprintf("db error: %d", e.Code)
}
func query(id int) error {
if id == 404 {
return ErrNotFound
}
if id == 500 {
return &DBError{Code: 500}
}
return nil
}
func main() {
err := query(404)
// 问题 1:这里无法正确检查 ErrNotFound
if errors.Is(err, &DBError{}) {
fmt.Println("Found DBError")
}
// 问题 2:这里无法正确检查 DBError
if errors.Is(err, ErrNotFound) {
fmt.Println("Found NotFound")
}
}
七、练习参考答案
参考答案(点击展开 / 收起)
第 1 题
errors.Is 用于检查错误链中是否存在与目标错误相等的错误,适用于哨兵错误(全局唯一变量)的检查。errors.As 用于沿错误链查找第一个可以赋值给目标指针类型的错误,适用于提取自定义错误中的具体字段。
第 2 题
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: field=%s, msg=%s", e.Field, e.Message)
}
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Message: "cannot be empty"}
}
return nil
}
func main() {
err := validate("")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
}
}
第 3 题
问题分析:
1. errors.Is(err, &DBError{}) 无法匹配,因为 &DBError{} 是新建指针,与 query 返回的指针不相等。
2. errors.Is(err, ErrNotFound) 应该匹配 ErrNotFound,但代码中 err 是 query(404) 返回的 ErrNotFound,所以这一行应该能匹配。但题目要求修复代码中的逻辑错误,即使用 errors.Is 检查自定义错误类型是错误的。
修复后代码:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type DBError struct {
Code int
}
func (e *DBError) Error() string {
return fmt.Sprintf("db error: %d", e.Code)
}
func query(id int) error {
if id == 404 {
return ErrNotFound
}
if id == 500 {
return &DBError{Code: 500}
}
return nil
}
func main() {
err := query(404)
// 修复 1:使用 errors.Is 检查哨兵错误
if errors.Is(err, ErrNotFound) {
fmt.Println("Found NotFound")
}
// 修复 2:使用 errors.As 检查自定义错误类型
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Printf("Found DBError: code=%d\n", dbErr.Code)
}
}
八、本课小结
error是 Go 的标准错误接口,自定义错误需实现Error() string。- 使用
fmt.Errorf配合%w进行错误包装,利用errors.Is检查哨兵错误,errors.As提取自定义错误。 errors.Join(Go 1.20+)用于组合多个错误。panic和recover仅用于不可恢复的异常,且recover必须在defer中直接调用,不跨 goroutine。
下一课预告:函数与方法:深入理解 Go 的面向对象编程基础。