本文是对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 中打开代码:
- 启动 GoLand。
- 如果这是一次全新安装,会看到欢迎界面。点击 Open 按钮。
- 在随后弹出的文件选择对话框中,进入之前 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 也有一种功能类似的机制:
panicrecover
但是请注意:
与 try...catch 不同,panic 和 recover 不是、也不应该成为 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.go 中 func 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() 直接通过泛型类型参数指定:
你正在寻找哪一种错误类型。
它返回两个值:
- 匹配到的 Error,其类型为
E; - 一个 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 中发生错误:
循环并不会因此停止。
相反,它会:
- 把新的 Error Join 到变量
errs中; - 继续执行循环;
- 如果又出现新的 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()
失败,那么它会返回:
- 一个
nilFile; - 一个非
nilError。
此时根本没有东西需要关闭。
如果在 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()
并且提供了这些字段:
PathOpErr
用于保存详细错误信息。
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。
让它携带额外信息。
可以参考:
的设计。
使用错误的 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()
因为它可以非常方便地:
- 记录一条消息;
- 立刻退出进程。
但是这里存在一个陷阱。
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 吗?
可以继续阅读下一篇错误处理指南: