3、错误处理与异常机制

Go语言的错误处理思想及设计包含以下特征:

Go语言没有类似Java或.NET中的异常处理机制,虽然可以使用 defer、panic、recover 模拟,但官方并不主张这样做,Go语言的设计者认为其他语言的异常机制已被过度使用,上层逻辑需要为函数发生的异常付出太多的资源,同时,如果函数使用者觉得错误处理很麻烦而忽略错误,那么程序将在不可预知的时刻崩溃。

Go语言希望开发者将错误处理视为正常开发必须实现的环节,正确地处理每一个可能发生错误的函数,同时,Go语言使用返回值返回错误的机制,也能大幅降低编译器、运行时处理错误的复杂度,让开发者真正地掌握错误的处理。

错误处理基础

错误处理哲学

Go语言的错误处理遵循几个核心原则:

error接口

Go中的所有错误都满足内置的error接口:

type error interface {
    Error() string
}

这个简单的接口只有一个方法,用于返回错误描述字符串。任何实现了Error()方法的类型都可以作为错误使用。

基本错误处理模式

Go的标准错误处理模式是将错误作为函数的最后一个返回值:

这种模式的关键点是:

func readFile(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err // 返回错误
    }
    defer f.Close()
    
    data, err := io.ReadAll(f)
    if err != nil {
        return nil, err
    }
    
    return data, nil // 成功时返回nil错误
}

func main() {
    data, err := readFile("config.json")
    if err != nil {
        fmt.Println("读取文件失败:", err)
        return
    }
    
    // 处理数据...
    fmt.Println("文件内容:", string(data))
}

创建和使用错误

创建简单错误

Go提供了多种创建错误的方式:

// 使用errors.New创建简单错误
import "errors"

err1 := errors.New("数据库连接失败")

// 使用fmt.Errorf创建格式化错误
import "fmt"

name := "config.json"
err2 := fmt.Errorf("找不到文件: %s", name)

自定义错误类型

对于需要携带额外信息的错误,可以创建自定义错误类型:

自定义错误类型的优势:

// 定义自定义错误类型
type QueryError struct {
    Query string
    Err   error
}

// 实现error接口
func (e *QueryError) Error() string {
    return fmt.Sprintf("查询 %q 失败: %v", e.Query, e.Err)
}

// 使用自定义错误
func executeQuery(query string) error {
    // ...
    return &QueryError{
        Query: query,
        Err:   errors.New("数据库连接超时"),
    }
}

// 使用类型断言检查具体错误类型
func handleQuery() {
    err := executeQuery("SELECT * FROM users")
    if err != nil {
        if qErr, ok := err.(*QueryError); ok {
            fmt.Printf("查询错误: %s, 原始错误: %v\n", 
                       qErr.Query, qErr.Err)
        } else {
            fmt.Println("其他错误:", err)
        }
    }
}

错误包装

Go 1.13引入了错误包装功能,通过fmt.Errorf结合%w谓词实现:

错误包装的优势:

package main

import (
	"errors"
	"fmt"
)

type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string {
	return fmt.Sprintf("查询 %q 失败: %v", e.Query, e.Err)
}

func main() {
	// 包装错误
	originalErr := errors.New("数据库连接失败")
	wrappedErr := fmt.Errorf("执行查询时出错: %w", originalErr)

	// 解包错误
	fmt.Println(wrappedErr) // 执行查询时出错: 数据库连接失败

	// 使用errors.Unwrap获取原始错误
	if errors.Unwrap(wrappedErr) == originalErr {
		fmt.Println("原始错误被正确识别")
	}

	// 使用errors.Is检查错误链中是否包含特定错误
	if errors.Is(wrappedErr, originalErr) {
		fmt.Println("错误链中包含原始错误")
	}

	// 使用errors.As获取错误链中的特定类型
	var queryErr *QueryError
	if errors.As(wrappedErr, &queryErr) {
		fmt.Println("找到QueryError类型错误")
	}
}

错误处理策略与模式

仅处理一次错误

一个好的原则是每个错误只处理一次。处理错误意味着:

// 不推荐的方式
func processFile(filename string) error {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        log.Printf("读取文件失败: %v", err) // 记录错误
        return err                      // 又返回错误
    }
    // ...
}

// 推荐的方式
func processFile(filename string) error {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return fmt.Errorf("读取文件 %s 失败: %w", filename, err)
    }
    // ...
}

// 或者如果这是调用链的终点
func handleRequest() {
    err := processFile("config.json")
    if err != nil {
        log.Printf("请求处理失败: %v", err)
        http.Error(w, "内部服务器错误", 500)
        return
    }
    // ...
}

错误处理模式

哨兵错误(Sentinel Errors)

预定义特定错误值用于比较:

var (
    ErrNotFound = errors.New("资源未找到")
    ErrPermission = errors.New("权限不足")
)

func GetResource(id string) (*Resource, error) {
    // ...
    return nil, ErrNotFound
}

// 使用
res, err := GetResource("123")
if err == ErrNotFound {
    // 处理"未找到"情况
}

错误类型检查

通过类型断言或errors.As检查错误类型:

type NotFoundError struct {
    Resource string
}

func (e NotFoundError) Error() string {
    return fmt.Sprintf("资源 %s 未找到", e.Resource)
}

// 使用
if err != nil {
    var notFound NotFoundError
    if errors.As(err, &notFound) {
        // 处理NotFoundError
    }
}

错误行为检查

Go 1.20+新增,检查错误是否实现了特定接口,关注错误能做什么,而非错误是什么:

// 定义行为接口
type Temporary interface {
    Temporary() bool
}

// 实现接口的错误
type NetworkError struct {
    Msg string
    IsTemp bool
}

func (e NetworkError) Error() string {
    return e.Msg
}

func (e NetworkError) Temporary() bool {
    return e.IsTemp
}

// 基于行为处理错误
func handleConnection() {
    for {
        err := connect()
        if err != nil {
            // 检查错误行为
            var temp interface{ Temporary() bool }
            if errors.As(err, &temp) && temp.Temporary() {
                // 临时错误,稍后重试
                time.Sleep(time.Second)
                continue
            }
            // 永久错误,停止尝试
            log.Fatalf("连接失败: %v", err)
        }
        break
    }
}

多错误处理

Go 1.20引入了errors.Join函数,用于组合多个错误:

// 组合多个错误
err1 := errors.New("错误1")
err2 := errors.New("错误2")
err3 := errors.New("错误3")

combinedErr := errors.Join(err1, err2, err3)
fmt.Println(combinedErr) 

// 检查组合错误中是否包含特定错误
if errors.Is(combinedErr, err2) {
    fmt.Println("组合错误包含err2")
}

panic与recover机制

Go语言的类型系统会在编译时捕获很多错误,但有些错误只能在运行时检查,如数组访问越界、空指针引用等,这些运行时错误会引起宕机。

一般而言,当宕机发生时,程序会中断运行,并立即执行在该 goroutine(可以先理解成线程)中被延迟的函数(defer 机制),随后,程序崩溃并输出日志信息,日志信息包括 panic() 的参数以及函数调用的堆栈跟踪信息, panic() 的参数通常是某种错误信息。

虽然Go语言的 panic 机制类似于其他语言的异常,但 panic 的适用场景有一些不同,由于panic 会引起程序的崩溃,因此 panic 一般用于严重错误,如程序内部的逻辑不一致。任何崩溃都表明了我们的代码中可能存在漏洞,所以对于大部分漏洞,我们应该使用Go语言提供的错误机制,而不是 panic

panic 基础

panic是Go中的异常机制,用于处理不可恢复的错误:

func divide(a, b int) int {
    if b == 0 {
        panic("除数不能为零")
    }
    return a / b
}

当调用panic时:

  1. 当前函数执行立即停止
  2. 任何defer语句正常执行
  3. 控制权返回给调用者
  4. 过程递归向上,直到程序崩溃或被recover捕获

recover捕获panic

recover() 是一个Go语言的内建函数,recover允许程序捕获panic并恢复正常执行:

recover仅在延迟函数defer中直接调用才有效,在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果,如果当前的 goroutine 陷入panic,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行

panic/recover应该仅用于以下场景:

由于函数ff()中有recover(),所以当panic发生时,会执行包含了recover()的defer语句,然后退出当前函数ff(),程序继续执行

package main

import "fmt"

func main() {
	ff()
	fmt.Println("CC")
}

func ff() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Printf("has panic : %v\n", err)
		}
	}()
	fmt.Println("AA")
	panic("Test Panic")
	fmt.Println("BB")
}

/*
AA
has panic : Test Panic
CC
*/

小心隐藏的panic

某些操作可能会引发隐藏的panic,应小心处理:

// 可能引发panic的操作
var users []User
fmt.Println(users[0])      // 索引越界

var m map[string]int
m["key"] = 1               // nil map赋值

var p *Person
fmt.Println(p.Name)        // 空指针解引用

// 安全的替代方案
if len(users) > 0 {
    fmt.Println(users[0])
}

m := make(map[string]int)
m["key"] = 1

if p != nil {
    fmt.Println(p.Name)
}

panic和defer

panic() 触发的宕机发生时,panic() 后面的代码将不会被运行

但是在panic()函数前程序执行到的 defer 语句依然会在panic()前执行

package main

import "fmt"

func main() {
	fmt.Println("start")
	defer fmt.Println("AA")
	defer fmt.Println("BB")
	panic("Test Panic")
	defer fmt.Println("CC")
}

/*
start
BB
AA
panic: Test Panic

goroutine 1 [running]:
main.main()
	/Users/yanggang/Desktop/helloworld/main.go:9 +0xe0
exit status 2
*/