Skip to content

错误处理机制

什么是错误?

在 Go 语言中,错误(error)是一种内置的类型,用于表示程序中的错误状态。Go 的错误处理方式与其他编程语言有所不同,它采用显式的错误处理机制。

异常处理对比

JavaC# 等编程语言中,错误处理通常是通过 try-catch 机制来管理的。当程序在 try 块中遇到错误时,catch 块会捕获该错误,并执行相应的处理逻辑。这种机制为处理异常提供了一种结构化的方法,确保即使在发生错误的情况下,应用程序也不会意外崩溃。

与此不同,Go 语言采用了一种完全不同的错误处理方式。在 Go 中,没有传统意义上的异常处理机制。相反,Go 将错误视为函数的返回值之一。这意味着在调用函数后,开发者需要主动检查是否返回了错误,并根据情况决定如何处理它。这种方法更加强调显式的错误处理,而不是像 try-catch 那样隐式的异常处理。这不仅使代码逻辑更为清晰,还鼓励了更好的错误管理实践。

演示错误

尝试打开一个不存在的文件:

go
package main

import (
	"fmt"
	"os"
)

func main() {
	// 使用 os 包的 Open 函数打开文件
	file, err := os.Open("README.md")
	if err != nil {
		fmt.Println(err)
		return
	}
	// 打印文件名称和打印文件打开成功
	fmt.Println(file.Name(), "open file success.")
}

os 包中有打开文件的功能函数:func Open(name string) (*File, error) 如果文件已经成功打开,那么 Open 函数将返回文件处理。如果在打开文件时出现错误,将返回一个非 nil 错误。

如果一个函数或方法返回一个错误,那么按照惯例,它必须是函数返回的最后一个值。因此,Open 函数返回的值是最后一个值。

处理错误的惯用方法是将返回的错误与nil进行比较。nil 值表示没有发生错误,而非 nil 值表示出现错误。在我们的例子中,我们检查错误是否为 nil。如果它不是 nil,我们只需打印错误并从主函数返回。

运行结果:

txt
open README.md: no such file or directory

程序输出结果是得到了一个错误,说明该文件不存在。

error类型

在 Go语言标准库的内置包 builtinerror 接口来表示错误,源码如下:

go
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

任何实现了 Error() string 方法的类型都满足这个接口。

既然我们支持错误是一种接口,可以在源码中了解更多关于错误的信息。

在上述演示错误案例中,我们仅仅是打印了错误的描述。如果我们想要的是导致错误的文件的实际路径。一种可能的方法是解析错误字符串。这是我们程序的输出:

txt
open README.md: no such file or directory

我们可以解析这个错误消息并从中获取文件路径 "README.md"。但这是一个糟糕的方法。在新版本的语言中,错误描述可能随时更改,我们的代码将会中断。

是否有办法可靠地获取文件名?答案是肯定的,它可以做到,Go 标准库使用不同的方式提供更多关于错误的信息。

断言底层结构类型并从结构字段获取更多信息

如果仔细阅读 Open() 函数的文档,可以看到它返回的是 PathError 类型的错误。

image-16

PathError 是一个结构体,标准库的实现代码如下:

go
// PathError records an error and the operation and file path that caused it.
type PathError struct {
	Op   string
	Path string
	Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

从上面的代码中,我可以理解 PathError 通过声明 Error() string 方法实现了错误接口。该方法连接操作、路径和实际错误并返回它。这样我们就得到了错误信息:

txt
open README.md: no such file or directory

PathError 结构体的路径字段包含导致错误的文件的路径。让我们修改上面写的程序,并打印出路径,修改后的代码如下:

go
package main

import (
	"fmt"
	"os"
)

func main() {
	// 使用 os 包的 Open 函数打开文件
	file, err := os.Open("README.md")
	if err, ok := err.(*os.PathError); ok {
		fmt.Println("File at path", err.Path, "failed to open")
		return
	}
	// 打印文件名称和打印文件打开成功
	fmt.Println(file.Name(), "open file success.")
}

在上面的程序中,我们使用类型断言获得错误接口的基本值。然后自定义错误输出:

txt
File at path README.md failed to open

断言底层数据结构并使用方法获取更多信息

获得更多信息的第二种方法是断言底层类型,并通过调用 struct 类型的方法获取更多信息。

如下 DNSError:

go
// DNSError represents a DNS lookup error.
type DNSError struct {
	// ...
}

// Unwrap
func (e *DNSError) Unwrap() error {
	// ...
}

// Error
func (e *DNSError) Error() string {
	// ...
}

// Timeout
func (e *DNSError) Timeout() bool {
	// ...
}

// Temporary
func (e *DNSError) Temporary() bool {
	// ...
}

从上面的代码中可以看到,DNSErrorstruct 有三个方法,Timeout() boolTemporary() bool 它们返回一个布尔值,表示错误是由于超时还是临时的,Unwrap() error 返回一个 error 类型的值,这个方法用于返回嵌套的错误。如果 DNSError 中包含另一个错误(例如,底层 DNS 查找引发的错误),这个方法将返回那个错误。这个方法的主要目的是实现 Go 1.13 引入的错误处理机制,允许错误链的提取。

让我们编写一个断言 DNSError 类型的程序,并调用这些方法来确定错误是临时的还是超时的。

代码如下:

go
package main

import (
	"fmt"
	"net"
)

func main() {
	addr, err := net.LookupHost("google.com.cn")
	// 断言错误类型
	if err, ok := err.(*net.DNSError); ok {
		if err.Timeout() {
			fmt.Println("operation timed out")
		} else if err.Temporary() {
			fmt.Println("temporary error")
		} else {
			fmt.Println("generic error: ", err)
		}
		return
	}
	fmt.Println(addr)
}

在上面的程序中,我们正在尝试获取一个无效域名的 IP 地址,这是一个无效的域名:google.com.cn

在我们的例子中,错误既不是暂时的,也不是由于超时,因此程序会打印出来:

txt
generic error:  lookup google.com.cn: no such host

创建error

创建一个error有以下两种方式,第一种是使用 errors 包下的 New() 函数,第二种是使用 fmt 包下的 Errorf() 函数,可以得到一个格式化参数的error。

下面是完整的示例:

go
package main

import (
	"errors"
	"fmt"
)

func main() {
	// 第一种方式创建 error 通过 errors 包的 New() 函数
	// res1, err := sum1(10, 20)
	res1, err := sum1(-10, -20)
	if err != nil {
		panic(err)
	}
	fmt.Println(res1)
}

// sum1
func sum1(a, b int) (int, error) {
	if a < 0 && b < 0 {
		return 0, errors.New("参数必须大于0")
	}
	return a + b, nil
}
go
package main

import "fmt"

func main() {
	// 第二种方式创建 error 通过 fmt 包的 Errorf() 函数
	// res2, err := sum2(11, 22)
	res2, err := sum2(-11, -22)
	if err != nil {
		panic(err)
	}
	fmt.Println(res2)
}

// sum2
func sum2(a, b int) (int, error) {
	if a < 0 && b < 0 {
		return 0, fmt.Errorf("参数必须大于0")
	}
	return a + b, nil
}

大部分情况,为了更好的维护性,一般都不会临时创建error,而是会将常用的error当作全局变量使用,例如下方节选自 os\erros.go 文件的代码。

go
// Portable analogs of some common system call errors.
//
// Errors returned from this package may be tested against these errors
// with [errors.Is].
var (
	// ErrInvalid indicates an invalid argument.
	// Methods on File will return this error when the receiver is nil.
	ErrInvalid = fs.ErrInvalid // "invalid argument"

	ErrPermission = fs.ErrPermission // "permission denied"
	ErrExist      = fs.ErrExist      // "file already exists"
	ErrNotExist   = fs.ErrNotExist   // "file does not exist"
	ErrClosed     = fs.ErrClosed     // "file already closed"

	ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
	ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

自定义error

在 Go 语言中,可以通过实现 error 接口中的 Error() 方法来自定义错误。例如 erros 包下的 errorString 就是一个很简单的实现。

go
func New(text string) error {
   return &errorString{text}
}

// errorString结构体
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

因为 errorString 实现太过于简单,表达能力不足,所以很多开源库包括官方库都会选择自定义error,以满足不同的错误需求。

以下是一个自定义错误的示例:

go
type MyError struct {
    Code    int
    Message string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("Code: %d, Message: %s", e.Code, e.Message)
}

func NewMyError(code int, message string) error {
    return &MyError{
        Code:    code,
        Message: message,
    }
}

通过这种方式,我们可以定义更丰富的错误信息。

错误传递

在函数调用中传递错误是Go语言处理错误的常见方式。通过返回值返回错误,并在调用方进行检查:

go
func doSomething() error {
    // 一些操作
    if someCondition {
        return errors.New("发生了错误")
    }
    return nil
}

func main() {
    err := doSomething()
    if err != nil {
        fmt.Println("Error:", err)
    }
}

错误链

在 go1.13 引入了 errors 包中的 Unwrap()Is()As() 函数用于错误链的处理。可以在创建错误时使用fmt.Errorf来包装错误:

go
err := fmt.Errorf("additional context: %w", originalErr)

不过,如果我们使用了占位符 %w 时,将不会使用上述方法,而是使用了 wrapError:

go
type wrapError struct {
	msg string
	err error
}
func(e *wrapError) Error()string{
	return e.msg
}
func(e *wrapError) Unwrap() error{
	return e.err
}

使用这种方式主要是为了错误链,那就让我们看一下如何使用错误链的相关操作。

要是想判断封装的这么多层的错误中,有没有哪一层错误等于我们要的值,可以使用 Is() 这个函数进行判断:

go
package main

import (
    "errors"
    "fmt"
)

// 定义一个自定义错误类型
var ErrNotFound = errors.New("item not found")

func findItem(id int) error {
    // 模拟查找失败的情况
    return fmt.Errorf("findItem: %w", ErrNotFound) // 使用 %w 包装错误
}

func main() {
    err := findItem(42)
    if err != nil {
        // 使用 errors.Is 检查错误
        if errors.Is(err, ErrNotFound) {
            fmt.Println("The item was not found.")
        } else {
            fmt.Println("An unexpected error occurred:", err)
        }
    }
}

然后就是 As() 函数,这个方法跟上文的 Is() 类似,只不过它判断的是类型是否一致。

go
package main

import (
    "errors"
    "fmt"
)

// 定义一个自定义错误类型
type MyError struct {
    Message string
}

func (e *MyError) Error() string {
    return e.Message
}

// 一个函数返回 MyError 类型的错误
func doSomething() error {
    return &MyError{"something went wrong"}
}

func main() {
    err := doSomething()
    if err != nil {
        // 使用 errors.As 检查错误并将其转换为 *MyError 类型
        var myErr *MyError
        if errors.As(err, &myErr) {
            fmt.Println("Caught a MyError:", myErr.Message)
        } else {
            fmt.Println("An unexpected error occurred:", err)
        }
    }
}

接下来是 erros 包中的 Join() 函数的作用是将多个错误合并成一个错误。这对于处理多个错误非常有用,特别是在需要同时报告多个错误的情况下。

go
package main

import (
    "errors"
    "fmt"
)

func main() {
    // 创建几个错误
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    err3 := errors.New("error 3")

    // 使用 Join 函数将错误合并
    combinedErr := errors.Join(err1, err2, err3)

    // 打印合并后的错误
    fmt.Println(combinedErr)
}

panic

panic 是 Go 语言提供的一种用于处理程序中不可恢复错误的机制。当 panic 被触发时,程序会立即停止当前函数的正常执行流程,开始沿着调用堆栈执行被延迟的(deferred)函数。如果 panic 没有被恢复(recover),程序将会崩溃并输出堆栈跟踪信息。

panic触发场景(运行时触发):

  • 数组或切片越界访问
  • 空指针引用
  • 向已关闭的channel发送数据
  • 类型断言失败
  • 死锁
  • 栈溢出

显式触发 panic 通过调用 panic() 函数手动触发,例如:

go
func doSomething() {
    panic("something went terribly wrong")
}

panic 会沿着调用栈向上传播,触发后当前函数会立即停止执行。在传播过程中会执行当前函数中已经注册的 defer 语句,然后返回到调用者继续传播。

go
func example() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    panic("发生panic")
    defer fmt.Println("3") // 这一行不会执行
}
// 输出:
// 2
// 1
// panic: 发生panic

适合使用panic的场景:

  • 初始化程序时遇到无法恢复的错误
  • 程序遇到严重的资源问题
  • 检测到不可能发生的情况

不适合使用panic的场景:

  • 可预期的错误情况(使用error返回值)
  • 正常的业务逻辑控制
  • 用户输入验证

示例代码:

go
func initDatabase() {
    db, err := sql.Open("postgres", connectionString)
    if err != nil {
        panic(fmt.Sprintf("无法连接数据库: %v", err))
    }
    // ... 其他初始化代码
}

recover

recover 是 Go 语言提供的一种用于恢复 panic 的内建函数。它只能在 defer 函数中生效,用于捕获和处理 panic,防止程序崩溃。

使用规则

  • 只能在defer函数中调用
  • 获取panic的值并恢复正常执行
  • 如果没有panic发生,recover返回nil
  • recover后程序可以继续执行

标准用法:

go
func someFunction() {
    defer func() {
        if err := recover(); err != nil {
            // 处理panic
            fmt.Printf("捕获到panic: %v\n", err)
        }
    }()
    
    // 可能发生panic的代码
}

高级用法:

go
func recoverSelectively() {
    defer func() {
        if err := recover(); err != nil {
            switch err.(type) {
            case *CustomError:
                // 处理自定义错误
                fmt.Printf("处理自定义错误: %v\n", err)
            default:
                // 其他类型的panic,继续传播
                panic(err)
            }
        }
    }()
    // ... 业务代码
}

在 goroutine 中使用:

go
func handleGoroutinePanic() {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Printf("goroutine panic: %v\n", err)
            }
        }()
        
        // 可能panic的代码
    }()
}

实践中推荐用法:

  • 在程序的顶层使用recover
  • 记录详细的错误信息
  • 恢复后确保程序状态一致性
  • 虑是否需要重试操作

需要避免的用法:

  • 在不必要的地方使用recover
  • 滥用recover掩盖真正的问题
  • 在循环中重复recover
  • 忽略recover后的错误处理

完整的代码示例:

go
package main

import (
    "fmt"
    "log"
)

// 自定义错误类型
type BusinessError struct {
    Code    int
    Message string
}

func (e *BusinessError) Error() string {
    return fmt.Sprintf("业务错误 [%d]: %s", e.Code, e.Message)
}

// 包装recover的工具函数
func SafelyDo(work func()) {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("检测到panic: %v\n", err)
            // 这里可以添加报警或监控代码
        }
    }()
    
    work()
}

func main() {
    // 示例1:处理自定义业务错误
    SafelyDo(func() {
        processBusinessLogic("")
    })
    
    // 示例2:处理运行时错误
    SafelyDo(func() {
        var slice []int
        slice[0] = 1 // 将触发panic
    })
    
    fmt.Println("程序继续执行...")
}

参考资料

根据 MIT 许可证发布