Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

如何在Go中处理错误

#go语言#错误处理#编程实践#软件开发

本文最初由社区贡献者 Christoph Berger 发布在 JetBrains Go Guide 中,后来迁移到了 JetBrains Go 博客。我们还于 2026 年 8 月对其进行了更新,以反映 Go 语言的最新变化。

错误处理是 Go 与 Java、C++、JavaScript、Python 等其他流行语言的区别之一。在 Go 中,错误就是值。其他语言往往把错误处理移出代码流程之外,而 Go 则将错误视为程序流程中的自然组成部分。如果函数遇到错误,它会将该错误与其他返回值一起返回。调用方有责任检查这个错误并进行相应处理。

一个典型的 Go 包或应用在运行时可能会遇到各种类型的错误,包括逻辑错误、I/O 错误、网络错误、数据校验错误等等。每一类错误都可能需要特定的处理方式。Go 提供了一系列工具和技术来处理不同类型的错误。

本文将探讨 Go 错误处理的几个方面。你将学到错误处理技巧与最佳实践、如何应对特定类型的错误,以及如何避免错误处理中的常见误区。

在开始之前

本指南中的所有示例都直接内嵌在文中,因此仅阅读代码片段就足以理解内容。不过,如果你想边读边自己动手试验和修改代码,我们有一个 代码示例仓库,其中收录了 GoLand 博客上多篇文章的代码。本指南的代码位于 error-handling 目录中。

你可以使用自己选择的 IDE,也可以安装 GoLand IDE。GoLand 提供免费试用;如果你刚开始接触 GoLand,这正是一个体验它的好机会!

接下来,fork 或 clone 包含本指南代码的仓库。

请按照以下步骤在 GoLand 中打开代码:

  1. 启动 GoLand。
  2. 如果是全新安装,你将看到欢迎界面。点击 Open 按钮。
  3. 在打开的文件选择器对话框中,导航到你之前克隆的仓库,选择 error-handling 文件夹,然后点击 Open

一切就绪!在按本指南学习时,请将 IDE 放在手边。## Go 中常见的错误处理技术

如前所述,Go 中所有错误处理都基于“错误即值”的概念。在 Go 中,错误是一种值,和其他任何值一样。错误值的类型是内建类型 error。但这个类型到底是什么?幸运的是,GoLand 让检查 Go 自身源码变得很容易。

Project 窗格中,向下滚动到 External Libraries 部分。展开 Go SDK <installed version>,然后展开 builtin.go(因为 error 是内建类型):

[LOADING...]

如果无法展开 builtin.go,请选择 Project 窗格中的三点菜单,然后选择 Tree Appearance,并确保 Show Members 已勾选:

[LOADING...]

向下滚动直到在 builtin.go 下方看到 error 类型,然后点击它。builtin.go 文件会在编辑区中打开并显示 error 类型:

$ go
type error interface {

  Error() string

}

error 类型是一个只包含单个函数 Error() string 的接口。在这里使用接口类型,可以让自定义类型通过实现 error 接口轻松创建自定义错误类型。

下面来看看如何处理错误。

返回错误

大多数情况下,当函数遇到错误时,它自身并不具备正确处理错误所需的上下文,因此必须将错误返回给调用方。

例如,示例代码(readfile.go)中的 func ReadFile()

$ go
func ReadFile(path string) ([]byte, error) {

    if path == "" {

       // Create an error with errors.New()

       return nil, errors.New("path is empty")

    }

    f, err := os.Open(path)

    if err != nil {

       // Wrap the error.

       // If the format string uses %w to format the error,

       // fmt.Errorf() returns an error that has the

       // method "func Unwrap() error" implemented.

       return nil, fmt.Errorf("open failed: %w", err)

    }

    defer f.Close()

    buf, err := io.ReadAll(f)

    if err != nil {

       return nil, fmt.Errorf("read failed: %w", err)

    }

    return buf, nil

}

ReadFile() 会检查接收到的路径;如果路径为空,它会创建一个新的错误并返回。由于 ReadFile() 本应返回的数据不存在,所以它返回 nil 值:

$ go
    if path == "" {

       return nil, errors.New("path is empty")

    }

按照惯例,如果函数返回错误值,它总是返回值列表中的最后一个(最右侧)值:

$ go
func ReadFile(path string) ([]byte, error) {

调用 ReadFile() 时,它会返回文件内容以及一个错误值:成功时为 nil,失败时为非 nil。通常,返回的错误值会赋给名为 err 的变量(参见随附仓库中的 main.go):

$ go
_, err := ReadFile("no/file")

if err != nil {

    fmt.Println("Error:", err)

}

由于本指南专门探讨错误处理,此处不需要 ReadFile() 返回的文件内容,因此返回值被赋给空白标识符(_)。

现在,调用方可以检查错误是否为非 nil,并据此处理错误。

Panic 与 recover

刚接触 Go 的开发者可能会怀念其他语言提供的 try...catch 机制。不过,Go 中确实有满足类似目的的机制:panicrecover。但请注意!与 try...catch 不同,panicrecover 不是(也不应该是)处理错误的标准方式。只有在错误确实出乎意料且无法处理时,才应该使用 panic。在这种情况下,最好让应用尽早崩溃并重启。稍后的最佳实践部分会介绍更多相关内容。

一个“不应该发生”的错误的例子是:将正则表达式作为字面量字符串进行编译却失败了。由于正则表达式在编译时是已知的,开发者本应确保它是有效表达式,这样在运行时就不会编译失败。为了强制执行这一点,regexp 包提供了一个名为 MustCompile() 的函数。前缀 Must 表示:如果函数无法编译给定的正则表达式,它就会 panic。

为了演示这一点,verifypath.go 文件中有一个函数,用于验证给定路径是否有效。然而,开发者输入的正则表达式不正确——缺少一个右括号:

$ go
func isValidPath(p string) bool {

    pathRe := regexp.MustCompile(`(invalid regular expression`)

    return pathRe.MatchString(p)

}

如果不加任何预防措施就调用此函数,应用会立即崩溃:

panic: regexp: Compile(`(invalid regular expression`): error parsing regexp: missing closing ): `(invalid regular expression`

goroutine 1 [running]:
regexp.MustCompile({0x1005ca16d, 0x1b})
        /opt/homebrew/opt/go/libexec/src/regexp/regexp.go:319 +0xac
main.isValidPath({0x1005c76af, 0xd})
        /Users/you/dev/JetBrains/jetbrains-go-code-samples/awesomeProject/error-handling/verifypath.go:6 +0x30
main.main()
        /Users/you/dev/JetBrains/jetbrains-go-code-samples/awesomeProject/error-handling/main.go:20 +0xb0

Process finished with the exit code 2

堆栈跟踪显示,verifypath.go 的第 6 行是 panic 的源头。

在某些情况下,应用崩溃是不可接受的。例如,HTTP 服务器必须持续运行而不能中断。如果在处理某个请求时发生 panic,其他所有请求仍应尽可能继续处理。为此,net/http 包使用了 Go 的恢复技术。

下面是针对会 panic 的 isValidPath() 函数,恢复机制可行的两种场景。

它向调用方添加一个延迟函数调用

isValidPath() 的调用方在函数体开头附近设置一个延迟函数调用:

$ go
defer func() {

    // deferred code ...

}() // <- Don't forget the parens, this is an actual function call!

无论包含函数是通过正常的 return 退出,还是因 panic 而退出,延迟函数都会自动执行。

在延迟函数中调用 recover()

延迟函数可以确认自己是被正常返回调用还是被 panic 调用。它只需要调用 recover() 并检查返回的错误即可(参见 func main() 末尾的 main.go):

$ go
defer func() {

    // Is this func invoked from a panic?

    if r := recover(); r != nil {

       // Yes: recover from the panic

       fmt.Println("Recovering")

       // ...

    }

}()

如果错误为 nil,则延迟函数是由于正常返回而被调用的,因此无需恢复。

如果延迟函数是由 panic 触发的,recover() 会返回导致 panic 的错误。此时延迟函数可以做任何从 panic 中恢复所需的操作。

记录错误

如果函数能够处理从被调用函数收到的错误,它可能希望将错误信息写入日志文件。

在 Go 中记录错误非常简单,这要归功于标准库中的 log 包以及从 Go 1.21 开始提供的 slog 包。

下面是在上一节的延迟函数中使用 log 包的示例:

$ go
if r := recover(); r != nil {

    log.Printf("Recovering from error `%v`\n", r)

}

log.Printf()fmt.Printf() 的直接替代品,它会写入标准日志记录器的输出。如需格式化错误类型,可使用 %v 动词,它按默认格式打印值。

一个附注: 如果你在为库编写代码,请考虑不要记录任何日志。库的使用者对使用哪个日志记录器以及向 stdoutstderr 输出什么内容会有不同的看法。因此,几乎总是只返回错误,让库的使用者自行记录他们想要的日志会更好。

使用错误包装

错误经常会在多个函数组成的调用链中“向上冒泡”。换句话说,一个函数收到错误后,通过返回值将其传回调用方;调用方可能同样如此,依此类推,直到调用链上游的某个函数处理或记录该错误。在“冒泡”过程中,每个涉及的函数都可以在把错误交还给调用方之前,向错误添加有价值的上下文信息。在传递错误时保留这条链的做法称为“错误包装”。你在添加上下文的同时,把原始错误保留在新错误的内部,之后可以解开包装,以检查或匹配底层错误。

只有在无法添加任何有用信息时,函数才应该原样传递错误:

$ go
if err != nil {

    // Only do that if no additional context can be added!

    return err

}

在所有其他情况下,它应添加适当的上下文信息。但是,简单地将新错误消息与原始错误消息拼接在一起是不可行的:

$ go
// WRONG!

if err != nil {

    return errors.New("open failed:" + err.Error())

}

这样做只会保留原始错误消息,而将错误本身压平为一个普通字符串。类型和结构化细节丢失后,调用方将无法再解开包装并检查它。

相反,你应该使用错误包装。可以使用 fmt.Errorf() 和特殊的格式化动词 %w 将一个错误“包装”到另一个错误上。参见 readfile.go 文件中的 ReadFile() 函数:

$ go
f, err := os.Open(path)

if err != nil {

    return nil, fmt.Errorf("open failed: %w", err)

}

如稍后所示,os.Open() 返回的错误类型包含额外信息。包装错误会保留所有这些额外信息。

解开被包装的错误

函数返回的错误可能包含一个或多个被包装的错误。打印或记录收到的错误时,也会包含所有被包装错误的错误消息。然而,有时你需要知道某种特定类型的错误是否嵌套在多层的错误之中。

例如,看一下如何在 func main() 中处理 ReadFile() 的错误:

$ go
_, err := ReadFile("no/file")

log.Println("err = ", err)

// Unwrap the error returned by os.Open()

log.Println("errors.Unwrap(err) = ", errors.Unwrap(err))

这段代码会输出以下内容:

Reading a single file: err =  open failed: open no/file: no such file or directory
Reading a single file: errors.Unwrap(err) =  open no/file: no such file or directory

被包装的错误消息是 open failed: open no/file: no such file or directory,而解开包装后的错误只包含 open no/file: no such file or directory,不包含添加到被包装错误上的 open failed: 消息。

这样,你可以逐个解开错误,直到到达错误链的末端。

测试特定错误类型

偶尔,你需要知道被包装的错误链中是否有某个错误属于特定类型。

例如,os.Open 返回一个类型为 fs.PathError 的错误,它不仅记录错误本身,还记录了导致错误的操作和路径。如果你能发现错误链中包含这个错误,就可以利用这些附加信息进行故障排除。

为实现这一点,errors 包提供了三个函数:Is()As(),以及 Go 1.26 中引入的 AsType()

errors.Is()

函数 func Is(err, target error) bool:如果错误 errtarget 相同,或其包装链中包含 target,则返回 true

ReadFile() 的例子中,你可以验证返回的错误是 fs.ErrNotExist,或包装了 fs.ErrNotExist

$ go
_, err := ReadFile("no/file")

log.Println("err is fs.ErrNotExist:", errors.Is(err, fs.ErrNotExist))

这会输出:

err is fs.ErrNotExist: true

errors.As()

你可能还想访问路径信息。为此,你不仅要确保错误包装了 fs.PathError,还要能访问这个 PathError 及其所有方法。

为此,可以使用函数 As(err error, target any) bool。和 Is() 类似,As()err 是或包装了一个与 target 同类型的错误时返回 true;同时,它会解开该错误并将其赋值给 target

这需要定义一个类型为 fs.PathError 的变量,并将指向该变量的指针传递给 As()

$ go
target := &fs.PathError{}

if errors.As(err, &target) {

    log.Printf("err as PathError: path is '%s'\n", target.Path)

    log.Printf("err as PathError: op is '%s'\n", target.Op)

}

这将记录出错时的路径和操作:

err as PathError: path is 'no/file'

err as PathError: op is 'open'

errors.AsType()

Go 1.26 新增了 AsType(),这是 As() 的泛型、类型安全替代方案。其函数签名为 func AsType[E error](err error) (E, bool)

与预先声明 target 变量并传递指针不同,AsType() 将你要查找的错误类型作为类型参数,并返回两个值:匹配的错误(类型为 E)和一个表示是否找到匹配项的布尔值。这样可以将匹配到的错误整洁地限定在 if 块作用域内:

$ go
if target, ok := errors.AsType[*fs.PathError](err); ok {

    log.Printf("err as PathError: path is '%s'\n", target.Path)

    log.Printf("err as PathError: op is '%s'\n", target.Op)

}

As() 的示例一样,这会记录出错时的路径和操作:

err as PathError: path is 'no/file'

err as PathError: op is 'open'

AsType() 相比 As() 有一些优势。由于你在调用中直接指定错误类型,编译器会帮你检查,因此“在需要指针的地方传值”这类错误会在编译期被捕获,而不会像 As() 在收到不合适的目标时那样触发运行时 panic。AsType() 还避免了 As() 内部依赖的反射,因此速度稍快。

As() 并未废弃,因此现有代码仍然可以正常工作。不过,对于新代码,推荐使用 AsType()。当你需要依次测试多个错误类型时,它尤其方便,因为每个匹配到的错误都保持在自己的分支作用域内:

$ go
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {

    log.Println("path error at:", pathErr.Path)

} else if linkErr, ok := errors.AsType[*os.LinkError](err); ok {

    log.Println("link error during:", linkErr.Op)

}

合并错误

通常,错误在返回给各自调用方的过程中被逐一包装。有时,一个函数需要收集多个错误并将它们合并为一个。

readfiles.go 中的 ReadFiles()(注意是复数)函数为例。这个函数读取多个文件,并返回成功读取到的所有文件内容。如果有一个或多个文件读取失败,ReadFiles() 会收集这些错误并将它们合并为一个。

为此,errors 包提供了 Join() 函数(Go 1.20 中引入)。下面看看 ReadFiles() 如何使用 Join() 函数:

$ go
func ReadFiles(paths []string) ([][]byte, error) {
    var errs error
    var contents [][]byte

    if len(paths) == 0 {
       // Create a new error with fmt.Errorf() (but without using %w):
       return nil, fmt.Errorf("no paths provided: paths slice is %v", paths)
    }

    for _, path := range paths {
       content, err := ReadFile(path)
       if err != nil {
        errs = errors.Join(errs, fmt.Errorf("reading %s failed: %w", path, err))
          continue
       }
       contents = append(contents, content)
    }

    return contents, errs
}

如果在 for 循环内部发生错误,循环不会中断。相反,该错误会被合并到变量 errs 中,循环继续执行,并在出现更多错误时继续合并。

最后,ReadFiles() 既返回成功读取的内容,也返回合并后的错误消息。

处理合并后的错误

你可能会认为,合并后的错误可以像单个错误一样解开。可惜并非如此。合并错误实际上是一个错误切片,即 []error。而 Unwrap() 函数返回的是单个错误;如果对合并错误调用 Unwrap(),它会返回 nil

$ go
_, err = ReadFiles([]string{"no/file/a", "no/file/b", "no/file/c"})
log.Println("joined errors = ", err)

log.Println("errors.Unwrap(err) = ", errors.Unwrap(err))

第二行日志输出:

errors.Unwrap(err) =  <nil>

幸运的是,有一种方法可以解开合并错误中的错误切片。合并错误类型本身就提供了返回错误切片的 Unwrap() []error 方法,可以帮助你做到这一点。

要访问这个 Unwrap() 方法,只需使用类型断言确认错误变量实现了该方法。然后就可以安全地调用它:

$ go
e, ok := err.(interface{ Unwrap() []error })

if ok {

    log.Println("e.Unwrap() = ", e.Unwrap())

}

这会打印完整的合并错误集合:

Reading multiple files: e.Unwrap() =  [reading no/file/a failed: open failed: open no/file/a: no such file or directory
reading no/file/b failed: open failed: open no/file/b: no such file or directory reading no/file/c failed: open failed: open no/file/c: no such file or directory]

基于 context 的错误处理

context 包常用于控制请求超时,或在请求到达时取消多个 goroutine。如果使用可取消的 context,你可以检查并处理导致取消的错误。

从 Go 1.20 开始,你可以使用 WithCancelCause context 在取消 context 时发送自定义错误消息。下面是一个基本示例:

$ go
    parent := context.Background()

    ctx, cancel := context.WithCancelCause(parent)
    defer cancel(nil)             // Set the cause to Canceled
    cancel(fmt.Errorf("myError")) // Set the cause to myError

    fmt.Println(ctx.Err())          // Output: context.Canceled
    fmt.Println(context.Cause(ctx)) // Output: myError

(构造 goroutine 和取消场景会很快变得复杂。完整示例见 readfiles_concurrent.go。)

context 函数 WithCancelCause() 返回一个 context 和一个接收 error 类型参数的 cancel 函数。调用 cancel 时,可以传入自定义错误消息作为参数。所有能访问该 context 的相关方都可以通过 context.Cause(ctx) 获取这个自定义错误。## Go 错误处理的最佳实践

了解了上述错误处理技巧后,我们来看一下在 Go 中使用错误时的一些最佳实践。

使用 defer 函数

函数可以通过多条路径退出,既包括 return 语句,也包括 panic。当函数分配了文件、网络连接或 goroutine 等资源时,应使用 defer() 函数在函数退出时清理所有打开的資源。

ReadFile() 函数包含一个延迟调用,用于关闭已打开的文件:

    f, err := os.Open(path)

    if err != nil {

        return nil, fmt.Errorf("open failed: %w", err)

    }

    defer f.Close()

请注意,defer f.Close() 位于错误检查之后。如果 os.Open() 失败,它会返回一个 nil 文件和不为 nil 的错误,因此没有需要关闭的对象。如果在错误检查之前就 defer close,则可能会在 nil 文件上调用它。

提供明确的错误信息

没有什么比在日志文件中看到诸如 ERROR: EPIC FAIL 这样莫名其妙的错误信息、却完全不知道错误发生时的上下文更让人沮丧的了。

你可能会好奇:是的,这类消息在现实世界中确实存在。这类消息的问题在于,即使是应当最了解自己代码的开发者,也可能无法说出某条特定消息出现的具体原因:

“你看,这段特定的代码被很多地方调用,我们真的无法判断是什么精确触发了这个错误。日志文件中没有足够的上下文。”

因此,当函数遇到错误时,不应将该错误原封不动地向上传递。相反,如果存在有助于排查错误的任何上下文信息,都应该通过将该错误包装到一个新错误中,把信息附加到错误上。(参见前面关于使用错误包装的章节。)

仅在必要时使用 panicrecover

Go 新手常常不喜欢 Go 那种冗长的错误处理方式,想通过让函数 panic 而不是处理错误来减少代码输入。在顶层,panic 会被 recover 并处理。然而,这种方法不符合 Go 的习惯用法,而且有很多缺点。首先,也是最关键的,通过这种方式无法添加有用的上下文信息(见上一节)。此外,由于 panic 在正常的调用/返回流程之外展开调用栈,因此在顶层函数与引发 panic 的函数之间调用链上的任何函数都不会包含错误处理代码。读者如何能看出这些函数中的某一个可能会观察到错误?作为比较,Java 提供了 throws 关键字,用来列出函数可能抛出的所有异常。Go 没有这种特性。无法判断一个函数调用的某个子函数是否 panic。Go 标准的错误处理方式能让错误流清晰可见。

Go 将错误视为程序流程的正常组成部分,因为它们确实如此。如果发生错误,就应当处理该错误或将其传递给调用者,直到调用链上某一层函数处理该错误,或将其写入日志文件用于排查。

在检查函数时,你希望能立刻看到它可能遇到哪些错误,以及它如何将错误沿调用链向上传递。

调用 panic 应仅限于那些本不该发生的意外错误。例如,在“Panic and recover”一节中看到的硬编码正则表达式字符串就是一个例子。硬编码的正则表达式应是经过精心编写和验证的,绝不能在运行时失败。

还有一些类别的错误根本无法处理,例如内存不足(out-of-memory)的情况。如果所需内存无法分配,应用程序没有有意义的继续运行方式,此时应该 panic。

另一方面,运行时的用户输入通常并不可靠。由用户输入、无效或缺失文件、网络超时或其他可预见的失败来源造成的任何错误,都可以且应当作为错误来处理。

使用遵循错误处理最佳实践的库和包

当多个第三方包提供相同或类似的功能时,请选择遵循错误处理最佳实践的那个包。

如果你选择了一个 API 看起来很花哨、但错误处理却很脆弱的包,这对你没什么好处。任何掩盖错误而不是正确向上返回错误的包,或者任何不为错误提供上下文的包,都会使排查问题变成撞运气式的调试噩梦。

所以,不妨看一看包内部的代码,确认它是否包含健壮且具备妥善错误处理的代码。这种预防性措施从长远来看会得到回报。

在适当的地方创建自定义错误类型

因为 error 是一个接口,因此只要实现 Error() string,就可以构建带有额外功能的自定义错误类型。你在“测试特定错误类型”一节中已经看到了一个示例:os.Open 返回了 fs.PathError

该错误是一个结构体,实现了 Error()Unwrap()Timeout() 方法,并提供 PathOpErr 字段来捕获详细的错误信息:

type PathError struct {
  Op   string
  Path string
  Err  error
}

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

func (e *PathError) Unwrap() error { return e.Err }

// Timeout reports whether this error represents a timeout.func (e *PathError) Timeout() bool {
  t, ok := e.Err.(interface{ Timeout() bool })
  return ok && t.Timeout()
}

同样,你也可以创建自己的错误类型。唯一必须实现的方法是 Error(),但如果同时实现 Unwrap() 方法,包函数 errors.Unwrap() 就可以解包你的错误。

处理特定类型的错误

由于特定性质,某些类型的错误需要特殊处理。这些类型包括网络错误、I/O 错误和系统错误。

网络错误

连接失败的网络需要特殊处理。网络错误可能由永久性故障或临时性问题引起。处理网络错误的代码需要区分这两种情况。

以打开新的 TCP 连接为例。该任务可能会因为网络暂时中断而失败,也可能因为连接另一端的系统正在重启或过载、暂时无法接受新连接而失败。

在这种情况下,你会希望稍后重试连接。例如,net.Dial() 函数就支持这一点:它会返回一个特定的错误类型 net.OpError,该类型提供了一个名为 Temporary() 的方法,用于测试错误是否预计会最终消失。

借助 Temporary() 方法,你可以实现像下面这样简单的重试算法,或使用更复杂的策略,如指数退避

func connectToTCPServer() error {
    var err error
    var conn net.Conn
    for retry := 3; retry > 0; retry-- {
       conn, err = net.Dial("tcp", "127.0.0.1:12345")
       if err != nil {
          // Check if err is a net.OpError
          opErr := &net.OpError{}
          if errors.As(err, &opErr) {
             log.Println("err is net.OpError:", opErr.Error())
             // test if the error is temporary
             if opErr.Temporary() {
                log.Printf("Retrying...\n")
                continue
             }
             retry = 0
          }
       }
    }
    if err != nil {
       return fmt.Errorf("connect failed: %w", err)
    }
    defer conn.Close()
    // send or receive data
    return nil
}

I/O 错误

在读取或写入大量数据之后发生的 I/O 错误,从中恢复的代价可能很高。从开始直到错误发生点为止已处理的所有数据,可能都需要重新读取或写入。

为支持更高效的恢复,标准库中大多数与 I/O 相关的函数和方法不仅会返回错误,还会返回成功处理的字节数。一个典型例子是 io.ReaderRead() 函数:

type Reader interface {
	Read(p []byte) (n int, err error)
}

错误恢复流程可以利用这一信息,从中断的位置继续执行 I/O 操作。

重要提示io 包提供了哨兵错误值 io.EOF(定义为 errors.New("EOF")),用于表示输入流的读取已成功(!)结束。每个实现 io.Reader 接口的类型都应当遵循已文档化的返回错误语义:

……一个 Reader 如果在输入流末尾返回非零字节数,可能会返回 err == EOFerr == nil。下一次 Read 应当返回 0, EOF

Go 错误处理中应避免的常见错误

虽然 Go 的错误处理乍看之下可能有些不同寻常,但它使用起来既合逻辑又直接。不过,这并不意味着你在处理错误时不会犯错。下面是一些需要避免的错误。

忽略错误

开发者在任何编程语言中能犯的最大错误就是忽略错误。没有及早捕获错误,很容易导致后续错误,而这些后续错误可能比原始错误在得到妥善处理的情况下更难排查。

因此,避免错误处理失误的第一条规则是:绝不把返回的错误值赋给空标识符。

此外,要留意那些唯一返回值就是错误值的函数。Go 并不阻止你完全忽略单个返回值,但你可以使用 linter 来检测被忽略的错误返回值。(GoLand 甚至会在编辑器中直接高亮未处理的错误,方便你避免这类错误。)

趣味事实:你知道吗,fmt.Println() 也会返回一个错误值?

要点是,不要这样写:

WriteString(w, s)

而应该这样写:

n, err := WriteString(w, s)
// error handling here, see below

传播错误时未添加额外的上下文

通常,即使不是总是,一个函数在从另一个函数处收到错误时,也可以为该错误添加有价值的上下文信息。

所以,每当你发现自己写出下面这样的代码:

n, err := WriteString(w, s)
if err != nil {
    return err
}

退一步,看看是否可以加入上下文信息。在大多数情况下,你都可以做到。即使是函数名本身也可能是很有价值的信息,因为这样你可以追溯到导致错误的函数调用链:

n, err := WriteString(w, s)
if err != nil {
    return fmt.Errorf("after writing %d characters: %w", n, err)
}

现在你只是需要在键盘上多敲几下,但将来却可以为你节省大量时间。

过度泛化错误

在编写错误信息时,尽可能具体。把你能获得的所有上下文信息都包含进去。

像 “database error” 这样的错误信息可能有无数种不同的成因。这样的信息实际上毫无意义,也没有任何帮助。

在错误信息中尽可能多地加入信息。可以考虑创建能携带附加信息的自定义错误类型;可以参考 os.PathError 类型

使用了不正确的错误类型

特定错误值的类型可能看起来像是一个无关紧要的细节。毕竟,每个错误都实现了 type error interface{ Error() string },所以归根结底,错误不过是被美化了的字符串类型,对吗?

错。自定义错误类型可以包含额外信息,并通过 errors.Is()errors.As()errors.AsType() 进行高级错误检查。

因此,当你要将错误返回给调用方时,务必使用适合给定错误上下文的错误类型。

不记录错误

错误信息对于排查问题必不可少。无论应用是能处理某个错误,还是该错误会迫使应用终止,应用都应记录该错误,以便事后分析。

一般来说,如果某个函数注意到了错误,它要么处理该错误,要么将错误返回给调用方。

如果函数能够处理该错误,或者由于某种原因无法返回该错误(也许因为它是 main() 函数),那么该函数应当始终记录错误及其所有上下文信息。

每个出现的错误都代表着一个修复 bug 或改进代码的机会;不要让它在不知不觉中溜走。

使用 log.Fatal() 记录错误

如果你的应用遇到不可恢复的错误,你可能会很自然地想通过调用 log.Fatal() 来记录这个错误,因为它可以便捷地记录一条消息并立即退出进程。

然而,这里有一个陷阱。log.Fatal() 会调用 os.Exit()。与调用 panic() 不同,os.Exit() 不可恢复,并且会跳过所有延迟(deferred)函数。

一个好的做法是让 func main() 不 defer 任何函数,并且只在 main() 中调用 log.Fatal()os.Exit()

不考虑错误恢复

“尽早崩溃”在许多情况下都是个好建议。让应用崩溃可以使其从干净状态重新启动。然而,崩溃并不总是最佳选择。

  • 如果错误很容易恢复,那么让整个应用崩溃就属于反应过度。
  • 如果一个进程要保证最大可用时间,那么最好尽力从错误中恢复,而不是用一次重启来干扰系统。
  • 如果一个进程会启动多个 goroutine,通常只需要退出观察到错误状态的单个 goroutine 就足够了。http.ListenAndServe() 就是这种策略的一个例子。所有传入请求都在各自的 goroutine 中处理,如果某个 goroutine panic,ListenAndServe() 会从该 panic 中恢复,从而使所有其他并发处理器不受影响地继续运行。

归根结底:精心设计的错误恢复可能对应用大有裨益,尤其是在“尽早崩溃”会带来很高的重新启动应用成本的情况下。

结论

Go 的错误处理涉及的“活动部件”非常少,因此学起来很快。错误处理的真正艺术在于知道如何针对特定错误场景做出最佳响应,以及如何在错误沿调用链向上传递的过程中管理它们。

在本指南中,你学习了有用的错误处理技术、最佳实践、特定错误类型以及需要避免的常见错误。你获得的知识和技能将帮助你编写可维护且易于排查的代码。但是,你知道如何安全地处理 Go 错误吗?请阅读我们的下一篇错误处理指南来一探究竟!