Go 中的错误处理

系列 - Go 语言
警告
本文最后更新于 2021-04-07,文中内容可能已过时。

在 Go 中,错误被视为一种值,并且被设计成普通的接口,而不是像其他语言中的异常。这种设计鼓励程序员明确地处理每一个可能的错误,并使代码更加清晰和预测。

error 是 Go 中的一个基本接口,用于表示一个错误条件,其定义如下:

1
2
3
type error interface {
    Error() string
}

只要一个类型提供了这个 Error() string 方法,那么它就实现了 error 接口,可以被视为一个错误。这种方法允许开发者创建各种错误类型,只要它们都实现了该方法。

Go 提供了一些内建的工具来创建和操作错误。

  • errors.New: 这是创建新错误的最简单方法。给定一个字符串描述,它会返回一个新的错误,这个错误会返回给定的字符串作为它的 Error() 方法的输出。

    1
    
    err := errors.New("my error description")
    
  • fmt.Errorf: 这是一个更灵活的创建错误的方式,允许你像使用 fmt.Printf 那样格式化错误消息。

    1
    2
    
    val := 42
    err := fmt.Errorf("my error with value: %d", val)
    

这些内建的方法为错误处理提供了基础,但 Go 的生态系统还提供了更多的工具和模式来帮助开发者更好地处理、传播和记录错误。

在 Go 中,错误处理是编写健壮和可维护代码的核心组成部分。Go 不使用传统的异常处理机制,而是选择明确地返回错误作为函数的返回值。这种方法鼓励开发者更加明确地处理错误,但也产生了几种常见的错误处理模式。

这是 Go 中最基本也是最常见的错误处理模式。当函数返回一个错误时,你会立即检查这个错误,然后决定如何处理它。

1
2
3
4
5
file, err := os.Open("filename.txt")
if err != nil {
    log.Fatalf("failed opening file: %s", err)
}
defer file.Close()

这种方法的优势在于它很直接、明确。你总是知道在什么情况下会检查错误,以及当发生错误时会发生什么。

defer 语句允许你延迟函数的执行,通常用于资源的清理操作,如文件关闭、解锁资源等。与错误处理结合,你可以确保在函数退出时始终执行某些操作,无论函数是正常退出还是因为错误而返回。

一个常见的例子是确保已打开的文件在函数结束时关闭:

1
2
3
4
5
file, err := os.Open("filename.txt")
if err != nil {
    log.Fatalf("failed opening file: %s", err)
}
defer file.Close()

此外,有些库提供了支持错误处理的延迟函数,这允许你在 defer 语句中处理错误。

Go 支持多值返回,这使得函数可以同时返回结果和错误。这是 Go 错误处理的核心模式,因为它允许调用者明确地处理错误,同时还可以接收函数的结果。

例如,一个函数可能会这样定义和调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

result, err := divide(5, 0)
if err != nil {
    log.Println("Error:", err)
    return
}
fmt.Println("Result:", result)

这种方法的优势是它强制调用者处理错误,因为他们必须接收和检查返回的错误值。

哨兵错误是预先声明的特定错误,通常用作特定函数调用失败时的返回值。它们是已命名的值,全局可见,并由包的使用者直接检查。

例如,当你到达文件或输入流的末尾时,Go 的 io 包会返回 io.EOF 错误。哨兵错误是明确的,调用者可以使用 if err == io.EOF 来检查它。

优点:

  • 易于使用,因为它是一个已知的值。

缺点:

  • 过度使用可能导致 API 与具体错误值紧密耦合,限制了将来的变更。
  • 可能导致错误处理代码过于分散。

以下是 Go 标准库中的一些哨兵错误类型的示例:

  • io.EOF: 这可能是最知名的哨兵错误。当在输入结束时尝试从 io.Reader 读取数据时,它表示没有更多的数据可读。
  • os.ErrExist and os.ErrNotExist: 当文件或目录存在或不存在时,这些错误会被返回,例如在尝试创建一个已存在的文件或查找一个不存在的文件时。
  • io.ErrShortWrite: 当只有部分数据被写入 io.Writer 时,返回此错误。
  • io.ErrUnexpectedEOF: 当在读取更多的数据之前输入结束时,返回此错误。

自定义错误类型是一种特殊的错误,它们是实现了 error 接口的自定义类型。与哨兵错误不同,这些错误类型可以携带更多的上下文信息。

例如,你可以定义一个表示网络错误的类型,它包含一个状态码和一个错误消息:

1
2
3
4
5
6
7
8
type NetworkError struct {
    Code    int
    Message string
}

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

优点:

  • 可以提供关于错误的更多上下文。
  • 更加灵活,易于修改和扩展。

缺点:

  • 需要更多的代码来定义和处理。

以下是 Go 标准库中的一些自定义错误类型的示例:

  • net.DNSError: 当查找域名时,此错误表示 DNS 查询的问题。

    1
    2
    3
    4
    5
    6
    7
    8
    
    type DNSError struct {
        Name    string
        Server  string
        IsTimeout bool
        // ... 其他字段
    }
    
    func (e *DNSError) Error() string { /*...*/ }
    
  • os.SyscallError: 当系统调用失败时,此错误提供有关调用和原因的详细信息。

    1
    2
    3
    4
    5
    6
    
    type SyscallError struct {
        Syscall string
        Err     error
    }
    
    func (e *SyscallError) Error() string { return e.Syscall + ": " + e.Err.Error() }
    

    os.SyscallError 源码链接

  • json.SyntaxError: 此错误表示在解析 JSON 时发生的语法问题。

    1
    2
    3
    4
    5
    6
    
    type SyntaxError struct {
        Offset int64
        // contains filtered or unexported fields
    }
    
    func (e *SyntaxError) Error() string { /*...*/ }
    

这些自定义错误类型允许库提供关于发生的错误更多的上下文。调用者可以通过类型断言来检查和处理这些特定类型的错误,并获取错误的额外字段或方法。

错误包装与展开是 Go 1.13 中引入的特性,使得开发者可以更灵活地处理、检查和传递错误信息。

Wrap errors 主要用于以下几个目的:

  • 记录错误:确保错误信息被记录到日志中。
  • 确保应用完整性:在处理错误的同时,确保应用程序的完整性达到 100%。
  • 防止错误重复报告:避免在多处重复报告同一错误。

在 Go 中,你可能想要添加上下文到一个错误中,使其更具有描述性。为了实现这个目的,你可以使用 fmt.Errorf 函数中的 %w 格式符来“包装”一个错误。

示例:

1
2
3
4
5
6
func someFunction() error {
    if err := anotherFunction(); err != nil {
        return fmt.Errorf("an error occurred: %w", err)
    }
    return nil
}

在上面的例子中,如果 anotherFunction() 返回一个错误,那么我们会使用 %w 将这个错误“包装”在一个新的错误中,并添加一些额外的上下文。

错误包装(error wrapping)是指将错误包装在一个容器中,该容器还会提供原始错误。这在 Golang 中被称为 error chaining。错误包装通常用于以下两种情况:

  • 添加额外的上下文信息到错误中
  • 将错误标记为特定的错误
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Custom error type
type MyError struct {
	message string
}

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

func returnDirectError() error {
	return errors.New("this is a direct error")
}

func returnCustomError() error {
	return &MyError{message: "this is a custom error"}
}

func returnFmtErrorWithW(err error) error {
	return fmt.Errorf("this is a wrapped error: %w", err)
}

func returnFmtErrorWithV(err error) error {
	return fmt.Errorf("this is a non-wrapped error: %v", err)
}
Option Extra context Marking an error Source error available
直接返回错误
自定义错误类型
fmt.Errorf with %w
fmt.Errorf with %v
  1. Option:列出了不同的错误处理和创建方法。
  2. Extra context:描述了是否可以为错误添加额外的上下文。例如,给出详细的错误信息或额外的状态数据。
  3. Marking an error:描述了是否可以将错误标记为某种特定类型。这在后续处理中可能非常有用,因为它允许我们基于错误的类型或标记采取特定的行动。
  4. Source error available:描述了原始错误是否可用,即是否可以从包装或标记的错误中提取原始错误。
  1. errors.Is

这个函数允许你检查一个错误值是否与一个特定的错误相匹配,即使它可能已经被包装在其他错误中。

示例:

1
2
3
4
5
6
var myErr = errors.New("my error")

err := fmt.Errorf("an error occurred: %w", myErr)
if errors.Is(err, myErr) {
    fmt.Println("The error is myErr!")
}

在上面的例子中,即使我们的 err 是一个包装了 myErr 的新错误,errors.Is 也能正确地检测出其原始错误是 myErr

  1. errors.As

这个函数用于检查包装的错误是否可以赋值给一个特定的错误类型。它是类型安全的,并且在错误链中查找匹配的错误。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type MyCustomError struct {
    Msg string
}

func (e *MyCustomError) Error() string {
    return e.Msg
}

func main() {
    var customErr = &MyCustomError{"this is a custom error"}
    err := fmt.Errorf("an error occurred: %w", customErr)

    var target *MyCustomError
    if errors.As(err, &target) {
        fmt.Println("The error is of type MyCustomError:", target.Msg)
    }
}

在上面的例子中,我们首先定义了一个自定义错误类型 MyCustomError。然后我们使用 %w 创建了一个包装了这个自定义错误的新错误。最后,我们使用 errors.As 来检查这个包装的错误是否为 MyCustomError 类型,并成功地获取了它的原始值。

pkg/errors 是 Go 社区中一个非常受欢迎的错误处理包。尽管 Go 1.13 中对标准库的 errors 包进行了扩展,加入了类似的功能,但 pkg/errors 仍然有其特色和用途。以下是对 pkg/errors 的简要介绍:

主要功能

  1. 创建新的错误

    • errors.New:基础的创建新错误的函数,类似于标准库中的同名函数。
    • errors.Errorf:带有格式化功能的创建错误的函数,可以像 fmt.Errorf 那样使用。
  2. 错误包装(Wrap errors)

    • errors.Wraperrors.Wrapf:这两个函数都可以将现有的错误包装起来,同时添加描述信息。最重要的是,它们还会记录当前的堆栈信息,这对于后期调试是非常有价值的。
  3. 获取错误原因和堆栈信息

    • errors.Cause:这个函数可以从包装过的错误中提取出原始的错误(或称为“根错误”)。
    • 使用 %+vfmt 函数(如 fmt.Println)一同使用,可以打印出错误的详细堆栈信息。

使用场景与建议

  • 当你希望除了错误消息外,还保留错误发生的上下文和堆栈信息时,使用 pkg/errors 会非常有帮助。

  • 在应用代码的更低层(如数据访问层或第三方服务接口)使用 errors.Wraperrors.Wrapf 来包装返回的错误。这样,在更上层,你可以利用堆栈信息更容易地识别和定位错误发生的位置。

  • 在应用的顶层或主函数中,你可以使用 errors.Cause 来检查错误是否匹配预期的 sentinel errors 或特定的错误类型。

最佳实践

  1. 使用基本错误构造:在应用的代码中,如果需要构造新的错误,使用 errors.Newerrors.Errorf
  2. 在跨包调用时直接返回错误:当你的函数或方法调用其他包中的函数或方法时,如果出现错误,通常应直接返回该错误,而不是进行额外的包装。
  3. 保存堆栈信息:当你需要与其他库或标准库协作时,考虑使用 errors.Wraperrors.Wrapf 来保存关于错误来源的堆栈信息。
  4. 避免冗余的错误记录:不要在产生错误的每个地方都进行日志记录。相反,将错误信息直接返回,并在更上层进行处理和记录。
  5. 记录堆栈详情:在程序的主函数或工作的 goroutine 的入口点,使用 %+v 格式描述符来记录完整的错误堆栈详情。
  6. 获取并比较根错误:当需要检查一个特定的基础错误时,使用 errors.Cause 来获取最底层的原因,并与已知的 sentinel error 进行比较。

在 Go 语言中,panicrecover 是两个用于处理运行时错误和异常情况的内建函数。它们为开发者提供了一种处理非预期错误的机制,但在常规的错误处理中,我们更推荐使用 error 类型。下面来详细介绍这两个函数:

panic 是一个内建函数,它中止当前函数的执行,并开始在当前的 Goroutine 中进行 panic。当函数 F 调用 panic 时,F 的执行会立即停止,同时 F 中的任何 defer 函数都会执行。然后 F 返回到调用它的函数,在那里也执行相同的过程,直到当前的 Goroutine 中所有函数都返回,此时程序会终止。除非此 panic 被 recover 捕获,否则程序将显示 panic 信息并退出。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    testPanic()
    fmt.Println("This will not be printed!")
}

func testPanic() {
    defer fmt.Println("Defer in testPanic")
    panic("Panic in testPanic")
    fmt.Println("This will not be executed!")
}

在 Go 语言中,panic 是用来处理非常特殊的错误情况的,它不同于常规的错误处理方式,如通过 error 类型来返回错误。以下是对您提到的两种情境的进一步讨论:

  1. 程序性错误 (Programmer Errors): 不想让进程继续执行

    • 这些是由于编程错误引起的,而不是预期的运行时错误。例如,传递一个无效的 HTTP 状态码或尝试注册一个已经存在的数据库驱动。
    • 这些错误通常是由于程序员的疏忽,或者在某些情况下,是对 API 的误用。
    • 对于这类错误,你期望调用者立即知道并修复错误,而不是运行时尝试恢复或忽略它。这就是为什么使用 panic 的原因,因为它会导致程序崩溃(除非使用 recover 捕获)。
  2. 强依赖 (Critical Dependencies): 程序启动

    • 这些是程序必须要满足的条件,如果不满足,程序就不能正确运行。例如,如果一个 web 服务启动时不能连接到其数据库,那么它可能会 panic,因为它不能在没有数据库的情况下正常工作。
    • 在这种情况下,程序无法继续执行,并且最好停止运行,而不是尝试继续在一个已知不稳定或不可靠的状态下运行。

recover 也是一个内建函数,它可以让你从 panic 的状态中恢复。如果与一个 defer 语句一同使用,可以捕获到 panic 的输入参数,从而允许程序从 panic 中恢复。

recover 只有在与 defer 一同使用时才有效。在正常的执行过程中调用 recover,它将返回 nil。但如果当前的 Goroutine 由于 panic 而崩溃,调用 recover 将捕获 panic 的输入参数,并恢复正常的执行。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
    handlePanic()
    fmt.Println("Program recovered and continues to execute!")
}

func handlePanic() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:", r)
        }
    }()
    panic("A severe error occurred!")
    fmt.Println("This will not be executed!")
}

尽管 panicrecover 为处理异常情况提供了一种机制,但在 Go 社区中,我们推荐使用 error 类型来处理错误,并保留 panic 用于真正的异常情况,例如程序的不可恢复的状态。

[!question] 为什么 recover 只有在与 defer 一同使用时才有效?

recover() 函数设计的初衷是为了从 panic 中恢复正常执行,并给予程序一个处理错误或清理资源的机会。它的工作方式与 panic 有关,只有当程序处于 panic 状态时,recover() 才能捕获到该 panic

为什么 recover 只在 defer 中有效有以下原因:

  1. 执行流程:当一个函数中出现 panic,该函数的执行会立即停止,并运行任何 defer 语句。如果没有在 defer 中调用 recover(),那么 panic 会继续在调用堆栈中传播,直到程序终止或者在某个 defer 中调用了 recover()

  2. 栈展开:当 panic 发生时,Go 会开始展开栈,即它会停止当前函数的执行并开始执行该函数的所有 defer 语句。如果在这个过程中没有调用 recover()panic 会继续向上在调用堆栈中展开。

  3. 恢复的时机:由于 recover 是为了捕获和处理 panic 而设计的,所以它只在 panic 进行时有意义。在普通函数中调用 recover() 会返回 nil,因为没有 panic 可以恢复。而 defer 保证了其内部的代码在函数退出或发生 panic 时都会执行,因此它为 recover 提供了正确的时机来捕获 panic

为了简单总结,recover 之所以只在 defer 中有效,是因为 defer 提供了在 panic 时执行代码的机制,这使得 recover 有机会在正确的时机捕获和处理 panic

当我们谈论“不处理错误”时,我们实际上是指在程序中遇到错误后,我们选择不对其采取任何措施。这在 Go 中并不是推荐的做法,因为 Go 鼓励明确地处理所有可能出现的错误。但在某些情况下,开发人员可能会选择不处理错误,原因可能有以下几点:

  1. 预期的错误:在某些情况下,错误可能是预期之内的,开发者可能已经知道这种错误会发生,并且知道它对程序的运行没有实质性的影响。
  2. 日志记录而不是处理:在某些情况下,开发人员可能选择记录错误而不是处理它,这样他们可以后续分析它,而不是立即对其采取措施。
  3. 忽略不重要的错误:有时,某些错误可能不会对程序的核心功能产生影响,因此可以被安全地忽略。
  4. 疏忽:这并不是一个好的理由,但有时开发人员可能会不小心遗漏错误处理。

忽略错误的示例:

1
2
  // 安全地忽略错误并添加为什么忽略的注释
  _ = err

panicrecover 使用指南:

  1. 限制使用:在绝大多数场景中,建议使用 error 类型,而不是 panicrecover 来处理错误。
  2. 异常情况:只有在程序无法继续运行的场景下才使用 panic,如初始化失败或不应出现的错误。
  3. 不替代常规错误:如文件未找到时,不应使用 panic,而应返回一个 error
  4. 中间件和框架:在可能的顶部位置使用 recover 来捕捉 panic,以确保整体程序稳定性。
  5. defer 中的 recover:仅在 defer 中可以捕获 panic
  6. 提供详尽信息:捕获 panic 后,记录详细信息,如堆栈信息。
  7. 资源清理:使用 defer 确保资源,如文件、网络连接等,被正确释放。
  8. 重新抛出 panic:有时可能需要重新抛出,但需明确原因并理解可能的后果。
  9. 避免在库中使用:编写供他人使用的库时,尽量不要使用 panic
  10. 文档说明:确保在文档中明确标明什么情况下会出现 panic

Go 错误处理最佳实践:

  1. 限制哨兵错误:过多的哨兵错误可能导致代码紧耦合。如需使用,务必清晰定义。
  2. 提供明确上下文:使用错误包装为错误提供明确上下文,方便错误追踪。
  3. 返回不透明错误:隐藏实现细节,返回简单的 error,增强 API 的灵活性。
  4. 使用 error:常规错误处理优先选择 error,而不是 panicrecover
  5. 返回具体错误类型:提供详细的错误信息,帮助调用者更准确地处理错误。
  6. 遵循标准接口:无论使用哪个库,都应遵循标准的 error 接口。
  7. 明确处理:不应忽略函数返回的错误。如需忽略,使用空标识符明确表示。

总的来说,正确和一致地处理错误是保证 Go 程序健壮性和可维护性的关键。遵循这些最佳实践可以帮助你构建更加健壮、可靠和易于维护的 Go 程序。

相关内容