Go 中如何处理错误

本文是对Dominika Stankiewicz的整理与翻译

发布日期:2026 年 9 月 2 日

本文最初由社区贡献者 Christoph Berger 撰写并发布在 JetBrains 的 Go Guide 中,之后迁移到了 JetBrains Go Blog。我们还在 2026 年 8 月对文章进行了更新,以反映 Go 语言最近的变化。

错误处理是 Go 与 Java、C++、JavaScript 和 Python 等其他流行语言存在明显区别的一个方面。

在 Go 中:

错误也是值。

其他语言通常会把错误处理从正常的代码执行流程中抽离出来,而 Go 则把错误视为程序正常执行流程的一部分。

如果一个函数遇到错误,它会把这个错误与其他返回值一起返回。

调用者有责任检查这个错误,并进行相应处理。

一个典型的 Go Package 或应用程序在运行过程中可能遇到各种类型的错误,包括:

  • 逻辑错误;
  • I/O 错误;
  • 网络错误;
  • 数据验证错误;
  • 以及其他各种错误。

不同类型的错误可能需要使用不同的错误处理方式。

Go 提供了一系列工具和技术,用于处理不同类型的错误。

本文将探讨 Go 错误处理中的多个方面。

你将学习:

  • 错误处理技术以及最佳实践;
  • 如何处理某些特定类型的错误;
  • 如何避免错误处理过程中常见的问题。

开始之前

本文使用的所有示例都直接嵌入在文章中,所以仅仅阅读这些代码片段,就应该足以理解文章介绍的内容。

不过,如果你希望一边阅读一边亲自运行、修改这些代码,我们提供了一个包含 GoLand Blog 不同文章代码示例的代码仓库

本文对应的代码位于:

text 复制代码
error-handling

目录中。

你可以使用自己喜欢的 IDE,也可以安装 GoLand IDE

GoLand 提供免费试用。

如果你之前没有使用过 GoLand,这也是一个体验它的好机会。

然后,Fork 或 Clone 包含本文代码的仓库。

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

  1. 启动 GoLand。
  2. 如果这是一次全新安装,会看到欢迎界面。点击 Open 按钮。
  3. 在随后弹出的文件选择对话框中,进入之前 Clone 的仓库,选择 error-handling 文件夹,然后点击 Open

这样就准备好了。

在阅读本文时,可以把 IDE 放在旁边,随时运行和修改示例代码。

Go 中常见的错误处理技术

正如前面提到的,Go 中所有错误处理都建立在:

错误也是值

这一理念之上。

Go 中的错误与其他任何值一样,本身就是一个值。

错误值的类型是:

go 复制代码
error

这是一个 Go 内置类型。

但是,这个类型到底是什么?

幸运的是,GoLand 让我们能够非常方便地查看 Go 自身的源代码。

Project 面板中,向下滚动到 External Libraries

展开:

text 复制代码
Go SDK <installed version>

然后展开:

text 复制代码
builtin.go

因为 error 是一个内置类型。

如果无法展开 builtin.go,点击 Project 面板中的三点菜单,然后依次选择:

Tree Appearance → Show Members

确保 Show Members 已经勾选。

向下滚动,直到在 builtin.go 中看到 error 类型,然后点击它。

编辑器中会打开 builtin.go 文件,并显示 error 类型:

go 复制代码
type error interface {
    Error() string
}

error 是一个只包含单个函数的 Interface:

go 复制代码
Error() string

这里采用 Interface 类型,使我们能够非常方便地创建自定义错误类型。

只需要让自定义类型实现:

go 复制代码
error

Interface 即可。

接下来看看错误到底应该如何处理。

返回错误

大多数情况下,如果某个函数遇到错误,它本身并没有足够的上下文信息来正确处理这个错误。

因此,它必须把错误返回给调用者。

例如,看看示例代码 readfile.go 中的:

go 复制代码
func ReadFile()

go 复制代码
func ReadFile(path string) ([]byte, error) {
    if path == "" {
        // 使用 errors.New() 创建一个错误
        return nil, errors.New("path is empty")
    }

    f, err := os.Open(path)

    if err != nil {
        // 包装错误。
        //
        // 如果格式化字符串使用 %w 来格式化错误,
        // fmt.Errorf() 会返回一个实现了
        // "func Unwrap() error" 方法的错误。
        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 中的惯例,如果一个函数需要返回错误值:

错误通常总是返回值列表中的最后一个,也就是最右边那个值。

例如:

go 复制代码
func ReadFile(path string) ([]byte, error) {

调用 ReadFile() 时:

  • 成功时,会返回文件内容,同时 Error 值为 nil
  • 失败时,会返回一个非 nil 的 Error。

按照惯例,返回的错误通常会被赋值给名为:

go 复制代码
err

的变量。

例如配套仓库中的 main.go

go 复制代码
_, err := ReadFile("no/file")

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

这里不需要使用 ReadFile() 返回的数据,因为本文专门讨论错误处理。

所以,第一个返回值被赋给了空标识符:

go 复制代码
_

此时,调用者就可以检查 Error 是否为非 nil,然后进行相应处理。

Panic 与 recover

刚接触 Go 的开发者可能会怀念其他语言提供的:

text 复制代码
try...catch

机制。

不过,Go 也有一种功能类似的机制:

  • panic
  • recover

但是请注意:

try...catch 不同,panicrecover 不是、也不应该成为 Go 中标准的错误处理方式。

只有当某个错误确实是完全意外的,并且根本没有办法处理时,使用 Panic 才是合理的。

在这种情况下,更好的策略通常是:

让应用尽早崩溃,然后重新启动。

后面的最佳实践部分还会继续讨论这一点。

一种理论上"不应该发生"的错误,就是:

使用一个硬编码的正则表达式字符串,却在运行时编译失败。

因为这个正则表达式在编译代码时就是已知的,开发者应该确保它本身是一个合法的表达式,从而保证它在运行时不可能编译失败。

为此,regexp Package 提供了:

go 复制代码
MustCompile()

函数。

Must 前缀意味着:

如果函数无法编译传入的正则表达式,它会直接 Panic。

为了演示这个情况,verifypath.go 中包含一个用于检查路径是否合法的函数。

但是开发者把正则表达式写错了:

少写了一个右括号。

go 复制代码
func isValidPath(p string) bool {
    pathRe := regexp.MustCompile(`(invalid regular expression`)
    return pathRe.MatchString(p)
}

如果在没有任何保护措施的情况下调用这个函数,应用会立刻崩溃:

text 复制代码
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

Stack Trace 表明:

verifypath.go 的第 6 行是此次 Panic 的来源。

不过,在一些场景中,让整个应用崩溃并不是一个可接受的选择。

例如:

考虑一个必须持续运行、不能中断的 HTTP Server。

如果处理某个请求时出现 Panic,只要可能,其他请求仍然应该继续被正常处理。

为此,net/http Package 使用了 Go 的 Recovery 技术。

对于前面会触发 Panic 的 isValidPath() 函数,可以通过下面两个步骤来实现。

在调用者中添加 Deferred 函数调用

isValidPath() 的调用者可以在函数体开头附近设置一个 Deferred Function:

go 复制代码
defer func() {
    // 延迟执行的代码......
}() // <- 不要忘记括号,这里是真正调用了函数!

只要包含这个 defer 的函数退出,Deferred Function 就会自动执行。

无论退出方式是:

  • 正常执行 return
  • 还是因为 Panic 而退出;

都会执行 Deferred Function。

在 Deferred Function 中调用 recover()

Deferred Function 可以判断:

这次函数退出究竟来自正常 Return,还是来自 Panic。

只需要调用:

go 复制代码
recover()

并检查返回结果即可。

例如,看看 main.gofunc main() 结尾处的代码:

go 复制代码
defer func() {
    // 这个函数是不是由 Panic 触发的?
    if r := recover(); r != nil {
        // 是:从 Panic 中恢复
        fmt.Println("Recovering")
        // ...
    }
}()

如果返回值是 nil,说明 Deferred Function 是因为正常 Return 而执行的,因此不需要进行 Recovery。

如果 Deferred Function 是由 Panic 触发的,那么:

go 复制代码
recover()

会返回导致 Panic 的值。

此时,Deferred Function 就可以根据需要执行相应的恢复操作。

记录错误日志

如果一个函数能够处理它从其他函数中收到的错误,它可能还希望把错误信息写入日志。

Go 中记录 Error 非常简单。

标准库提供了:

go 复制代码
log

Package。

从 Go 1.21 开始,还提供了:

go 复制代码
slog

Package。

下面使用上一节的 Deferred Function,通过 log Package 记录错误:

go 复制代码
if r := recover(); r != nil {
    log.Printf("Recovering from error `%v`\n", r)
}

log.Printf() 基本可以直接作为 fmt.Printf() 的替代方案。

区别是:

它会把内容写入标准 Logger 的输出。

如果想格式化一个 Error 类型,可以使用:

text 复制代码
%v

它会按照默认格式打印这个值。

顺便提醒:

如果你开发的是一个 Library,最好考虑:

不要在 Library 中主动记录任何日志。

使用这个 Library 的不同客户端,对于:

  • 使用什么 Logger;
  • 什么内容应该打印到 stdout
  • 什么内容应该打印到 stderr

往往都有不同看法。

因此,绝大多数情况下更好的方式是:

Library 只返回 Error,让 Library 的使用者自己按照需要记录日志。

使用错误包装

一个错误经常会沿着多层函数调用链不断"向上冒泡"。

换句话说:

某个函数收到一个 Error,然后通过返回值把它交给自己的调用者。

调用者可能继续做同样的事情。

如此反复,直到调用链上某个函数:

  • 真正处理这个 Error;
  • 或者把它记录到日志。

在 Error 向上传递的过程中,每一个参与其中的函数,都有机会在把 Error 继续交给自己的调用者之前,为它添加有价值的上下文信息。

在保留这条原始错误链的前提下传递 Error,就叫作:

Error Wrapping,也就是错误包装。

你可以添加新的上下文,同时把原来的错误保存在新 Error 的内部

之后仍然可以把它 Unwrap 出来,检查或者匹配底层 Error。

只有当一个函数确实无法添加任何有价值的信息时,才应该原样返回 Error:

go 复制代码
if err != nil {
    // 只有在完全无法添加额外上下文时才这么做!
    return err
}

其他情况下,都应该添加合适的上下文信息。

但是,单纯把新的错误消息和原始错误字符串拼起来是不对的:

go 复制代码
// 错误做法!
if err != nil {
    return errors.New("open failed:" + err.Error())
}

这种方式只保留了原始错误的文本消息。

但 Error 本身已经被展平成一个普通字符串。

Error 类型和结构化信息全部丢失。

这样一来,调用者以后就无法再:

  • Unwrap;
  • 检查;
  • 匹配;

原始 Error。

正确方式应该使用 Error Wrapping。

可以通过:

go 复制代码
fmt.Errorf()

以及特殊格式化动词:

text 复制代码
%w

把一个 Error 包装到另一个 Error 中。

看看 readfile.go 中的 ReadFile()

go 复制代码
f, err := os.Open(path)

if err != nil {
    return nil, fmt.Errorf("open failed: %w", err)
}

os.Open() 返回的错误类型中包含一些额外信息。

后面还会介绍这些信息。

使用 Wrapping 可以完整保留这些额外数据。

解开被包装的错误

一个函数返回的 Error 可能包含一个或者多个被包装的 Error。

直接打印或者记录最外层的 Error 时,输出也会包含其中所有被包装 Error 的消息。

不过,有时你需要知道:

在整条 Error Chain 中,是否包含某一种特定的 Error。

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

go 复制代码
_, err := ReadFile("no/file")

log.Println("err = ", err)

// 解开 os.Open() 返回的错误
log.Println("errors.Unwrap(err) = ", errors.Unwrap(err))

这段代码会打印:

text 复制代码
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

被包装后的 Error 消息是:

text 复制代码
open failed: open no/file: no such file or directory

而解开之后得到:

text 复制代码
open no/file: no such file or directory

也就是说,去掉了外层 Error 添加的:

text 复制代码
open failed:

这部分上下文。

通过这种方式,可以一层一层执行 Unwrap,直到到达 Error Chain 的末尾。

检查特定的错误类型

有时,需要判断一条被层层包装的 Error Chain 中,是否存在某一种特定类型的 Error。

例如:

os.Open 会返回一个:

go 复制代码
fs.PathError

类型的错误。

它不仅记录了实际错误,还会保存:

  • 导致错误的操作;
  • 出错的文件路径。

如果能够判断 Error Chain 中包含 fs.PathError,就可以利用这些额外信息进行故障排查。

为此,errors Package 提供了三个函数:

  • Is()
  • As()
  • Go 1.26 引入的 AsType()
errors.Is()

函数:

go 复制代码
func Is(err, target error) bool

会在 Error Chain 中判断 err 是否匹配 target

例如,对于 ReadFile()

可以判断它返回的 Error 本身是不是、或者是否包装了:

go 复制代码
fs.ErrNotExist

go 复制代码
_, err := ReadFile("no/file")

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

输出:

text 复制代码
err is fs.ErrNotExist: true
errors.As()

我们可能还希望获得出错路径的信息。

为此,不仅需要确认 Error Chain 中存在:

go 复制代码
fs.PathError

还希望直接获得这个 PathError,进而访问它的所有字段和方法。

可以使用:

go 复制代码
func As(err error, target any) bool

Is() 类似,如果 err 本身或者其包装链中存在与 target 类型相匹配的 Error,As() 会返回:

go 复制代码
true

同时,它还会解开相应 Error,并把它赋值给:

go 复制代码
target

需要先定义一个:

go 复制代码
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)
}

这会把发生错误的路径和操作写入日志:

text 复制代码
err as PathError: path is 'no/file'
err as PathError: op is 'open'
errors.AsType()

Go 1.26 新增了:

go 复制代码
AsType()

它是一个 Generic、类型安全的 As() 替代方案。

其函数签名为:

go 复制代码
func AsType[E error](err error "E error") (E, bool)

与提前声明 Target 变量、再把它的指针传给 As() 不同:

AsType() 直接通过泛型类型参数指定:

你正在寻找哪一种错误类型。

它返回两个值:

  1. 匹配到的 Error,其类型为 E
  2. 一个 Boolean,表示是否找到了匹配项。

这样,匹配出来的 Error 可以非常自然地限制在 if Block 的 Scope 中:

go 复制代码
if target, ok := errors.AsType[*fs.PathError](err "*fs.PathError"); ok {
    log.Printf("err as PathError: path is '%s'\n", target.Path)
    log.Printf("err as PathError: op is '%s'\n", target.Op)
}

和前面的 As() 示例一样,这会输出:

text 复制代码
err as PathError: path is 'no/file'
err as PathError: op is 'open'

AsType() 相比 As() 有几个优势。

首先,因为 Error 类型直接写在函数调用的泛型参数中,编译器可以帮你检查类型。

例如:

如果某处必须传指针,但开发者错误地使用了 Value,这种问题可以在编译期被发现。

相比之下,如果给 As() 传入不合适的 Target,这类问题可能会导致运行时 Panic。

另外:

AsType() 还避免了 As() 内部依赖的 Reflection。

因此,它会稍微快一些。

As() 并没有被废弃,因此现有代码仍然可以正常工作。

但是,对于新代码:

更推荐使用 AsType()

尤其是在需要依次测试多个不同 Error Type 时,它非常方便。

每次匹配出来的错误都可以自然地限定在自己的分支中:

go 复制代码
if pathErr, ok := errors.AsType[*fs.PathError](err "*fs.PathError"); ok {
    log.Println("path error at:", pathErr.Path)
} else if linkErr, ok := errors.AsType[*os.LinkError](err "*os.LinkError"); ok {
    log.Println("link error during:", linkErr.Op)
}

合并多个错误

通常,Error 会沿着不同函数一层一层被包装,然后返回给各自的调用者。

不过,有时候一个函数需要收集多个 Error,然后把它们合并成一个 Error。

例如:

看看 readfiles.go 中的:

go 复制代码
ReadFiles()

注意这里是复数的 Files

这个函数会读取多个文件,并返回所有成功读取到的文件内容。

如果其中一个或多个文件读取失败,ReadFiles() 会收集这些 Error,然后把它们 Join 成一个。

为此,errors Package 提供了:

go 复制代码
Join()

函数。

它是在 Go 1.20 中引入的。

看看 ReadFiles() 如何使用 Join()

go 复制代码
func ReadFiles(paths []string) ([][]byte, error) {
    var errs error
    var contents [][]byte

    if len(paths) == 0 {
        // 使用 fmt.Errorf() 创建一个新的错误
        // (这里不使用 %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 Loop 中发生错误:

循环并不会因此停止。

相反,它会:

  1. 把新的 Error Join 到变量 errs 中;
  2. 继续执行循环;
  3. 如果又出现新的 Error,就继续 Join。

最终:

ReadFiles() 会同时返回:

  • 成功读取到的文件内容;
  • 合并后的错误。
处理 Join 后的错误

你可能会认为:

Join 后的错误应该像普通单个 Error 一样,通过 errors.Unwrap() 解开。

遗憾的是:

并不是这样。

Join 后的 Error 实际上包含的是:

go 复制代码
[]error

也就是一个 Error Slice。

但是:

go 复制代码
errors.Unwrap()

只能返回一个单独的:

go 复制代码
error

因此,如果对 Join Error 调用 errors.Unwrap()

它会返回:

go 复制代码
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))

第二行日志会打印:

text 复制代码
errors.Unwrap(err) =  <nil>

幸运的是,仍然存在办法解开 Join 后的 Error Slice。

Join Error 类型本身提供了:

go 复制代码
Unwrap() []error

方法。

这个方法会返回整个 Error Slice。

要访问这个 Unwrap() 方法,只需要使用 Type Assertion,判断 Error 是否实现了这个方法:

go 复制代码
e, ok := err.(interface{ Unwrap() []error })

if ok {
    log.Println("e.Unwrap() = ", e.Unwrap())
}

这样就能安全调用它。

输出会包含所有 Join 起来的 Error:

text 复制代码
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 Package 经常用于:

  • 控制请求 Timeout;
  • 按需取消多个 Goroutine。

如果使用的是可取消 Context,还可以检查并处理:

导致 Context 被取消的具体 Error。

从 Go 1.20 开始,使用:

go 复制代码
WithCancelCause

还可以在取消 Context 时提供一个自定义 Error。

下面是一个简单例子:

go 复制代码
parent := context.Background()

ctx, cancel := context.WithCancelCause(parent)

defer cancel(nil)             // 把 Cause 设置为 Canceled
cancel(fmt.Errorf("myError")) // 把 Cause 设置为 myError

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

构造 Goroutine 和各种 Cancel 场景很容易迅速变得复杂。

完整示例可以查看:

text 复制代码
readfiles_concurrent.go

context.WithCancelCause() 会返回:

  • 一个 Context;
  • 一个接收 error 参数的 Cancel Function。

调用 cancel 时,可以传入一个自定义 Error。

所有能够访问这个 Context 的相关方,都可以通过:

go 复制代码
context.Cause(ctx)

获取这个自定义错误。

Go 错误处理最佳实践

了解了上面的错误处理技术之后,接下来看看使用 Go Error 时的一些最佳实践。

使用 defer

一个函数可能通过多个位置退出:

  • return
  • Panic

因此,只要一个函数分配了某些需要释放的资源,例如:

  • 文件;
  • 网络连接;
  • Goroutine;

就应该使用:

go 复制代码
defer

在函数退出时清理尚未释放的资源。

ReadFile() 中就包含一个 Deferred Call,用于关闭已经打开的文件:

go 复制代码
f, err := os.Open(path)

if err != nil {
    return nil, fmt.Errorf("open failed: %w", err)
}

defer f.Close()

注意:

go 复制代码
defer f.Close()

位于 Error 检查之后

如果:

go 复制代码
os.Open()

失败,那么它会返回:

  • 一个 nil File;
  • 一个非 nil Error。

此时根本没有东西需要关闭。

如果在 Error Check 之前就注册 defer f.Close(),就可能尝试对一个 nil File 调用 Close。

提供明确的错误信息

没有什么比在日志中看到下面这样模糊的错误消息更让人沮丧:

text 复制代码
ERROR: EPIC FAIL

而且完全不知道错误究竟是在什么上下文中发生的。

如果你觉得这种 Error Message 不会出现在现实世界里:

会的。

这种消息真正的问题是:

即使是最熟悉代码的开发人员,也可能完全不知道某一次具体出现这个错误到底是由什么造成的。

例如:

"这个代码被很多不同地方调用,我们现在确实无法判断这一次具体的错误究竟是怎么触发的。日志里没有足够的上下文信息。"

因此:

如果一个函数遇到错误,不应该单纯原封不动地把这个 Error 沿调用链返回。

相反:

如果当前函数掌握了任何有助于排查问题的上下文信息,就应该通过 Error Wrapping,把这些信息添加到新的 Error 中。

前面"使用错误包装"一节已经介绍过这种做法。

只在必要时使用 Panic 和 recover

刚接触 Go 的开发者常常会对 Go 略显啰嗦的错误处理方式感到不满。

为了少写一点代码,他们可能会希望:

函数遇到 Error 时直接 Panic,而不是正常返回 Error。

然后,在最顶层统一 Recover 并进行处理。

但是:

这不是符合 Go 惯例的写法,而且存在很多缺点。

其中最重要的一点是:

这种方式无法方便地逐层添加前面提到的、有价值的上下文信息。

另外,因为 Panic 会跳出常规的函数 Call/Return 流程,并开始 Unwind Call Stack:

位于顶层函数和发生 Panic 的函数之间,整个调用链上的函数都不会包含显式的错误处理代码。

那么,阅读代码的人应该如何判断:

这些函数中的某一个调用是否有可能发生错误?

作为对比:

Java 有:

text 复制代码
throws

关键字,可以列出一个函数可能抛出的 Exception。

Go 没有这样的功能。

单纯查看一个函数,并不能知道它调用的下层函数中是否会 Panic。

而标准的 Go Error Handling 会让 Error Flow 清晰可见。

Go 把错误视作程序正常执行流程的一部分。

因为错误本来就是如此。

发生错误时:

  • 应该处理它;
  • 或者把它交给调用者;
  • 一直沿 Call Chain 返回;
  • 直到某一层函数真正处理它;
  • 或者把它写入日志,以便排查。

当你检查一个函数时,应该能够立刻看到:

  • 它可能遇到哪些 Error;
  • 它如何把 Error 继续沿调用链向上传递。

调用:

go 复制代码
panic

应该仅仅保留给:

理论上绝不应该发生的意外错误。

前面"Panic 与 recover"一节中的硬编码 Regex 就是一个例子。

硬编码的正则表达式应该在开发阶段被精心编写和验证。

它绝对不应该在运行时编译失败。

另外,还有一些类别的错误根本无法处理。

例如:

内存不足。

如果无法分配程序所需要的内存,应用已经没有任何有意义的方式继续运行,此时就应该 Panic。

另一方面:

运行时用户输入不可靠,本来就是预期之中的事情。

下面这些场景产生的 Error 都是可以预期的:

  • 用户输入错误;
  • 文件无效;
  • 文件缺失;
  • 网络超时;
  • 其他可以预见的失败来源。

这些情况都应该:

作为普通 Error 处理。

使用遵循错误处理最佳实践的 Library 和 Package

如果你需要在多个提供相同或者类似功能的第三方 Package 中做选择:

应该优先选择:

遵循错误处理最佳实践的那个。

如果某个 Package:

  • API 看起来非常漂亮;
  • 但错误处理非常脆弱;

那么选择它最终不会给自己带来什么好处。

任何:

  • 把 Error 静默吞掉,而不是正确返回;
  • 或者返回 Error 时完全没有上下文;

的 Package,最终都会让故障排查变成一场靠运气的 Debug 噩梦。

所以:

可以花一点时间查看 Package 内部代码,判断它是否拥有:

  • 健壮的实现;
  • 正确的错误处理。

这种预防措施从长期来看一定是值得的。

在合适的时候创建自定义错误类型

由于:

go 复制代码
error

是一个 Interface,因此只要实现:

go 复制代码
Error() string

就可以创建带有额外功能的自定义 Error Type。

前面"检查特定错误类型"中已经看到一个例子。

os.Open 返回:

go 复制代码
fs.PathError

这个 Error 是一个 Struct。

它实现了:

  • Error()
  • Unwrap()
  • Timeout()

并且提供了这些字段:

  • Path
  • Op
  • Err

用于保存详细错误信息。

go 复制代码
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 报告这个错误是否表示一次超时。
func (e *PathError) Timeout() bool {
    t, ok := e.Err.(interface{ Timeout() bool })
    return ok && t.Timeout()
}

你也可以按照类似方式创建自己的 Error Type。

唯一必须实现的方法是:

go 复制代码
Error()

但是,如果同时实现:

go 复制代码
Unwrap()

那么:

go 复制代码
errors.Unwrap()

就能够解开你定义的 Error。

处理特定类型的错误

一些类型的 Error 由于自身特点,需要特殊处理。

其中包括:

  • 网络错误;
  • I/O 错误;
  • 系统错误。

网络错误

网络连接失败需要特殊处理。

网络错误可能来自:

  • 永久性故障;
  • 临时问题。

处理网络错误的代码必须能够区分这两种情况。

例如:

考虑建立一个新的 TCP Connection。

这个过程可能因为下面这些原因失败:

  • 网络暂时不可用;
  • 连接另一端的系统正在重启;
  • 对端负载过高,暂时无法接受新的连接。

在这些情况下:

我们通常希望过一段时间之后重新尝试连接。

例如:

go 复制代码
net.Dial()

会返回一个特定 Error Type:

go 复制代码
net.OpError

它提供了:

go 复制代码
Temporary()

方法,用于判断这个错误是否预计会在之后自行消失。

使用 Temporary(),可以实现一个简单的 Retry 算法。

也可以进一步使用更复杂的策略,例如指数退避(Exponential Backoff)

go 复制代码
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 {
            // 判断 err 是否属于 net.OpError
            opErr := &net.OpError{}

            if errors.As(err, &opErr) {
                log.Println("err is net.OpError:", opErr.Error())

                // 检查这个错误是否是临时性的
                if opErr.Temporary() {
                    log.Printf("Retrying...\n")
                    continue
                }

                retry = 0
            }
        }
    }

    if err != nil {
        return fmt.Errorf("connect failed: %w", err)
    }

    defer conn.Close()

    // 发送或者接收数据

    return nil
}

I/O 错误

如果已经读取或者写入了大量数据,然后才发生 I/O Error:

恢复的代价可能会非常高。

因为在错误发生之前已经处理过的大量数据,可能都需要重新:

  • 读取;
  • 或写入。

为了更加高效地进行错误恢复:

标准库中大多数 I/O 相关函数和方法除了返回 Error,还会同时返回:

成功处理的字节数。

典型例子就是 io.Reader 的:

go 复制代码
Read()

函数:

go 复制代码
type Reader interface {
    Read(p []byte) (n int, err error)
}

错误恢复逻辑可以利用返回的字节数,在 I/O 操作被中断的位置继续执行。

重要说明:

io Package 提供了一个 Sentinel Error:

go 复制代码
io.EOF

它定义为:

go 复制代码
errors.New("EOF")

用于表示:

成功地读取到了输入流末尾。

注意,这实际上是一个成功状态。

所有实现:

go 复制代码
io.Reader

Interface 的类型,都应该遵循文档规定的 Error 返回语义:

如果 Reader 在输入流末尾返回了非零字节数,那么它可以返回 err == EOF,也可以返回 err == nil。下一次 Read 应该返回 0, EOF

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

Go 的错误处理方式第一眼看上去可能有些不同寻常。

但是它的逻辑其实非常直接。

不过,这并不意味着:

在处理 Error 时不会犯错。

下面看看一些应该避免的常见问题。

忽略错误

无论使用哪一种编程语言:

开发者在错误处理方面能够犯的最大错误,就是:

直接忽略 Error。

如果没有尽早发现错误,很容易在后续过程中产生新的错误。

相比最初的 Error,这些后续问题可能更加难以排查。

因此,避免错误处理问题的第一条原则是:

不要把一个函数返回的 Error 赋值给空标识符。

另外,还需要特别留意:

唯一返回值就是 Error 的函数。

Go 不会阻止你完全忽略一个单独的返回值。

但是,可以使用 Linter 检测:

某个函数返回的 Error 是否被忽略。

GoLand 甚至会直接在编辑器中高亮没有处理的 Error,让你更容易避免这种问题。

一个有趣的小知识:

你知道吗?

go 复制代码
fmt.Println()

本身也会返回 Error。

总之:

不要这样写:

go 复制代码
WriteString(w, s)

应该这样写:

go 复制代码
n, err := WriteString(w, s)

// 在这里进行错误处理,见下文

向上传播错误时没有附加额外上下文

很多时候,甚至可以说绝大多数时候:

一个函数从下层函数接收到 Error 之后,都能够为它添加一些有价值的上下文信息。

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

go 复制代码
n, err := WriteString(w, s)

if err != nil {
    return err
}

最好重新考虑一下:

能不能加入一些上下文?

绝大多数情况下,都可以。

哪怕只是函数当前已经处理了多少数据,也可能非常有价值。

因为这些信息可以帮助追踪最终导致 Error 的函数调用链。

例如:

go 复制代码
n, err := WriteString(w, s)

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

你现在只是多敲了几个键。

但以后排查问题时:

它可能会为你节省大量时间。

对错误进行过度泛化

编写 Error Message 时:

尽可能具体。

把所有当前已经掌握的上下文信息都包含进去。

例如:

text 复制代码
database error

这样的错误消息可能对应无数种不同原因。

所以:

"database error"

实际上完全没有意义,也没有任何帮助。

应该尽可能向 Error Message 中添加更多信息。

同时,可以考虑创建:

自定义 Error Type。

让它携带额外信息。

可以参考:

os.PathError

的设计。

使用错误的 Error Type

一个 Error Value 具体使用什么类型,看上去也许只是无关紧要的小细节。

毕竟:

所有 Error 都实现:

go 复制代码
type error interface {
    Error() string
}

所以到头来:

Error 不就是一个豪华版字符串吗?

错。

自定义 Error Type 可以携带额外信息。

同时还可以配合:

  • errors.Is()
  • errors.As()
  • errors.AsType()

进行更加高级的 Error 检查。

因此:

每当向调用者返回 Error 时,都应该确保使用:

与当前错误上下文相匹配的 Error Type。

不记录错误日志

错误消息对于排查故障不可或缺。

无论:

  • 应用最终能够处理这个 Error;
  • 还是这个 Error 最终导致应用必须退出;

应用都应该记录这个错误,以便事后分析。

通常来说:

如果一个函数发现 Error,它应该:

  • 处理这个 Error;
  • 或者把它返回给调用者。

如果它自己处理了 Error,或者因为某种原因根本无法继续向上返回 Error------例如当前函数就是:

go 复制代码
main()

那么应该:

把这个 Error 和所有上下文信息写入日志。

每一个实际发生的 Error 都意味着:

  • 可能存在 Bug;
  • 或者代码还有改进空间。

不要让这种机会悄无声息地消失。

使用 log.Fatal() 记录错误

如果应用遇到一个无法恢复的错误:

很自然地可能会想到:

go 复制代码
log.Fatal()

因为它可以非常方便地:

  1. 记录一条消息;
  2. 立刻退出进程。

但是这里存在一个陷阱。

log.Fatal() 内部会调用:

go 复制代码
os.Exit()

与:

go 复制代码
panic()

不同:

os.Exit() 无法 Recover。

而且:

它会跳过所有 Deferred Function。

一个比较好的实践是:

把:

go 复制代码
func main()

写成不包含任何 defer 的形式。

然后只在:

go 复制代码
main()

中使用:

  • log.Fatal()
  • os.Exit()

没有考虑错误恢复

"尽早崩溃"在很多场景下都是一个不错的建议。

让应用 Crash,可以让它从一个干净状态重新启动。

但是:

Crash 并不总是最好的选择。

  • 如果某个 Error 很容易恢复,那么直接让整个应用崩溃明显反应过度。
  • 如果一个进程需要保证最高可用性,那么最好尽最大努力从 Error 中恢复,而不是通过重启中断整个系统。
  • 如果一个进程启动了多个 Goroutine,那么很多时候,只退出发现 Error 的那一个 Goroutine 就已经足够。

http.ListenAndServe() 就是一个使用这种策略的例子。

所有进来的 Request 都会在独立 Goroutine 中处理。

如果其中某一个 Goroutine Panic:

ListenAndServe() 会从这个 Panic 中 Recover。

这样:

其他并发 Handler 完全不会受到影响,可以继续工作。

总结来说:

如果"尽早崩溃"意味着必须承担非常高的应用重新拉起成本,那么:

设计良好的 Error Recovery 可以给应用带来很大收益。

结语

Go 中的错误处理所涉及的基本机制非常少,因此学起来很快。

真正的错误处理艺术在于:

  • 知道面对某种具体 Error 时,应该采用什么最合适的处理方式;
  • 知道如何管理 Error 沿着 Call Chain 不断向上传递的整个过程。

通过本文,你已经学习了:

  • 实用的错误处理技术;
  • 错误处理最佳实践;
  • 某些特定 Error Type 的处理方式;
  • 常见错误处理问题以及如何避免它们。

掌握这些知识和技能,将帮助你写出:

  • 更容易维护;
  • 更容易排查问题;

的 Go 代码。

不过,你知道应该如何安全地处理 Go 中的 Error 吗?

可以继续阅读下一篇错误处理指南:

如何以安全的方式处理 Go 错误

相关推荐
孙克旭_1 小时前
JVM 入门详解:JVM、JRE、JDK 区别,JVM 基础结构梳理
java·开发语言·jvm
随遇而安zx1 小时前
【地基篇】---JVM 内存结构知识点(Java 8 运行时数据区深度解析)
java·开发语言·jvm
GoGeekBaird1 小时前
Agent 时代,你的生产环境,真的敢让它裸奔吗
后端·agent
FfHUCisI1 小时前
Golang 切片扩容策略
开发语言·数据库·golang
IT_陈寒1 小时前
Redis Pipeline用错竟比不用还慢,这个坑我帮你踩过了
前端·人工智能·后端
en.en..1 小时前
Ubuntu嵌入式开发 export环境变量
java·开发语言·数据库
ynchyong1 小时前
JavaScript Promise 实战:.then() 与 .catch() 的区别及回调函数封装指南
开发语言·javascript·ecmascript·promise·then
名字还没想好☜1 小时前
Go 1.21 context.WithoutCancel 实战:父 context 取消了,收尾任务还要继续跑
开发语言·后端·golang·go
源代码•宸2 小时前
前置准备:定时微服务有什么价值
经验分享·后端·微服务·云原生·架构