错误处理是一门语言的重要特性。通过此机制,我们可以捕获、处理和记录错误,确保程序的稳定性和可靠性。
Go 错误处理#
处理机制#
Go 错误处理利用了函数的多返回值特性,通常会将 err 作为最后一个返回值,用于表示此处函数调用是否成功。
err 为 nil 时,表示函数调用成功,否则表示失败。
1
2
3
4
5
6
| ret, err := Open("filename")
if err != nil {
// 处理错误
logs.Error(ctx, err)
return nil, err
}
|
error 接口#
error 接口是对 Go 中错误信息的抽象,定义了 Error 方法,用于返回错误的字符串表示。
1
2
3
| type error interface {
Error() string
}
|
自定义错误类型#
有些错误只用一段字符串还不够。日常开发中,上游往往不只想知道出错了,还想知道是哪个字段、哪个资源、哪一步出了问题。
这时候就可以通过自定义错误类型,把这些上下文信息放到结构体字段里,既方便打印日志,也方便调用方按类型做进一步处理。
如下边的示例,ValidationError 结构体中包含了 Field 和 Reason 两个字段,分别表示出错的字段和出错的原因。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
func CreateUser(name string, age int) error {
if name == "" {
return &ValidationError{
Field: "name",
Reason: "cannot be empty",
}
}
if age < 0 {
return &ValidationError{
Field: "age",
Reason: "must be greater than or equal to 0",
}
}
return nil
}
|
调用方在发现错误后,可以将错误转换为 ValidationError 类型,从而获取到出错的字段和原因。
1
2
3
4
5
6
7
8
9
10
| err := CreateUser("", 18)
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("参数错误,field=%s, reason=%s\n", ve.Field, ve.Reason)
return
}
fmt.Println("系统错误:", err)
}
|
常用函数#
为方便开发者处理错误,Go 标准库中提供了一些常用函数:
1
2
3
4
5
6
7
| fmt.Errorf(format string, a ...any) error
errors.New(text string) error
errors.Join(errs ...error) error
errors.Is(err, target error) bool
errors.As(err error, target any) bool
|
fmt.Errorf#
fmt.Errorf 函数用于格式化错误信息。它接受一个格式化字符串和可选的参数,返回一个 error 类型的值。
1
2
3
4
5
6
| reader, err := Open("filename")
if err != nil {
// 格式化错误信息
err = fmt.Errorf("open path `%s`: %w", "filename", err)
return nil, err
}
|
errors.New#
errors.New 函数用于创建一个新的错误。它接受一个字符串作为参数,返回一个 error 类型的值。
1
2
3
4
5
6
| func Open(path string) (io.Reader, error) {
if os.Exists(path) {
return nil, errors.New("path not exists")
}
// ...
}
|
相比于 fmt.Errorf,errors.New 函数更简单,更符合 Go 语言的风格。
创建全局错误常量是 errors.New 函数更常见的用法:
1
2
3
4
5
6
7
8
9
10
| const (
ErrEOF = errors.New("EOF")
)
func Open(path string) (io.Reader, error) {
if os.Exists(path) {
return nil, ErrEOF
}
// ...
}
|
全局错误常量可在多个函数中共享,避免重复创建。其次,全局错误常量可以方便地进行错误判断:
1
2
3
4
5
6
7
8
| reader, err := Open("filename")
if err != nil {
if errors.Is(err, ErrEOF) {
// 处理 EOF 错误
} else {
// 处理其他错误
}
}
|
errors.Is#
errors.Is 函数用于判断错误是否为某个特定错误。它接受两个 error 类型的参数。如果第一个参数是第二个参数的子错误,则返回 true。
1
2
3
| if errors.Is(err, io.EOF) {
// 处理 EOF 错误
}
|
在实际开发过程中,一个 error 可能不是一个单独错误,而会经过多次包装,形成一棵错误树。
比如下边的代码,openTemp 函数内对 error 进行了包装,形成一棵错误树。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| func openTemp() (io.Reader, error) {
reader, err := Open("/tmp/file")
if err != nil {
// 在 ErrEOF 上包装了一层错误
return nil, fmt.Errorf("open path `%s`: %w", "/tmp/file", err)
}
return reader, nil
}
reader, err := openTemp()
if err != nil {
if errors.Is(err, io.EOF) {
// 如果 Open("/tmp/file") 抛出 io.EOF 错误,则会走到此分支
// 处理 EOF 错误
} else {
// 处理其他错误
}
}
|
上边代码块中第 12 行的 errors.Is 仍然会成立,因为该函数会检查这棵树里任意一个错误是否匹配 target。
errors.Is 函数内部是如何实现子错误检查的呢?
当使用 fmt.Errorf 包装错误的时候,会在原错误的基础上添加一个 wrapError 结构体,用于存储包装的错误信息。
同时,该接口体实现了 Unwrap 方法,用于返回包装的错误信息。具体实现可查看 wrapError 结构体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // 包装单个错误
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
// 包装多个错误
type wrapErrors struct {
msg string
errs []error
}
func (e *wrapErrors) Error() string {
return e.msg
}
func (e *wrapErrors) Unwrap() []error {
return e.errs
}
|
当使用 errors.Is 函数判断错误是否为某个特定错误的时候,会递归调用 Unwrap 方法,直到找到包装的错误信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| func Is(err, target error) bool {
if err == nil || target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
return is(err, target, isComparable)
}
func is(err, target error, targetComparable bool) bool {
for {
if targetComparable && err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 递归检查包装错误
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap() // 处理单个错误包装
if err == nil {
return false
}
case interface{ Unwrap() []error }:
for _, err := range x.Unwrap() { // 处理多个错误包装
if is(err, target, targetComparable) {
return true
}
}
return false
default:
return false
}
}
}
|
errors.As#
errors.As 函数用于将错误转换为指定类型。它接受两个参数,一个是 error 类型的值,一个是 any 类型的值。如果第一个参数是第二个参数的子错误,返回 true。
该函数主要用于将包装的错误转换为指定类型,从而获取到结构体中包装的信息。
如下边的代码,errors.As 函数用于将错误转换为 ValidationError 结构体,从而获取到出错的字段和原因。
1
2
3
4
5
6
7
8
9
10
| err := CreateUser("", 18)
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("参数错误,field=%s, reason=%s\n", ve.Field, ve.Reason)
return
}
fmt.Println("系统错误:", err)
}
|
该函数和 errors.Is 一样会递归检查包装的错误,直到找到包装的错误信息。
errors.Join#
errors.Join 函数用于将多个错误合并为一个错误。它接受一个可变参数,返回一个 error 类型的值。
1
| err := errors.Join(err1, err2, err3)
|
err.Error() 返回的字符串就是 err1.Error() + "\n" + err2.Error() + "\n" + err3.Error()。
该函数内部实现也较为简洁,直接将参数中的多个错误合并为一个 joinError 结构体。
1
2
3
| type joinError struct {
errs []error
}
|
Uber 错误处理规范#
本章节翻译自 Uber Go Style 中的错误处理规范。
https://github.com/uber-go/guide/blob/master/style.md#errors
错误类型#
声明错误有几种常见方式。选择具体方案前,可以先考虑下面两个问题:
- 调用方是否需要匹配这个错误,并据此做不同处理。
- 错误消息是固定文本,还是需要带上上下文信息的动态文本。
可以直接按下表判断:
| 是否需要匹配 | 错误消息类型 | 建议做法 |
|---|
| 否 | 静态 | errors.New |
| 否 | 动态 | fmt.Errorf |
| 是 | 静态 | 顶层 var 搭配 errors.New |
| 是 | 动态 | 自定义 error 结构体类型 |
对于固定字符串的错误,可以直接使用 errors.New。
1
2
3
4
5
6
7
8
9
10
11
12
| // package foo
func Open() error {
return errors.New("could not open")
}
// package bar
if err := foo.Open(); err != nil {
// 这里没法针对这个错误做特定处理
panic("unknown error")
}
|
这种写法简单直接,但调用方无法稳定匹配这个错误,也就没法对它做专门处理。
如果调用方需要匹配这个固定错误,就应该把它定义成顶层错误变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // package foo
var ErrCouldNotOpen = errors.New("could not open")
func Open() error {
return ErrCouldNotOpen
}
// package bar
if err := foo.Open(); err != nil {
if errors.Is(err, foo.ErrCouldNotOpen) {
// 处理该错误
} else {
panic("unknown error")
}
}
|
如果错误消息需要动态拼接上下文,但调用方并不需要匹配这个错误,可以使用 fmt.Errorf。
1
2
3
4
5
6
7
8
9
10
11
12
| // package foo
func Open(file string) error {
return fmt.Errorf("file %q not found", file)
}
// package bar
if err := foo.Open("testfile.txt"); err != nil {
// 这里也没法针对这个错误做特定处理
panic("unknown error")
}
|
如果错误消息是动态的,同时调用方还需要进一步判断具体错误,就更适合定义自定义错误类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // package foo
type NotFoundError struct {
File string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("file %q not found", e.File)
}
func Open(file string) error {
return &NotFoundError{File: file}
}
// package bar
if err := foo.Open("testfile.txt"); err != nil {
var notFound *foo.NotFoundError
if errors.As(err, ¬Found) {
// 处理该错误
} else {
panic("unknown error")
}
}
|
需要注意的是,如果你把错误变量或错误类型导出给外部使用,它们就会成为包公共 API 的一部分,后续调整时要谨慎一些。
错误包装#
当某个调用失败,需要继续向上返回错误时,主要有三种做法:
- 原样返回底层错误。
- 使用
fmt.Errorf 配合 %w 补充上下文。 - 使用
fmt.Errorf 配合 %v 补充上下文。
如果当前层没有额外信息可以补充,直接返回原始错误即可。这样会保留原始错误类型和错误消息。
1
2
3
| func load() error {
return store.New()
}
|
如果当前层知道这次调用是在做什么,最好补上简洁的上下文,让错误链更容易读懂。
1
2
3
4
5
6
7
8
| func load() error {
s, err := store.New()
if err != nil {
return fmt.Errorf("new store: %w", err)
}
_ = s
return nil
}
|
%w 表示保留底层错误,调用方可使用 errors.Is 或 errors.As 匹配和提取内部错误。
如果你不希望调用方依赖底层错误细节,可以使用 %v。
1
2
3
4
5
6
7
8
| func load() error {
s, err := store.New()
if err != nil {
return fmt.Errorf("new store: %v", err)
}
_ = s
return nil
}
|
这种写法只保留错误文本,不暴露底层错误本身,因此调用方无法再继续匹配内部错误。
写错误上下文信息时,文案要尽量短,只说明当前这一步做了什么,不要反复堆叠 failed to 这类空洞前缀。
不推荐:
1
2
3
4
| s, err := store.New()
if err != nil {
return fmt.Errorf("failed to create new store: %w", err)
}
|
推荐:
1
2
3
4
| s, err := store.New()
if err != nil {
return fmt.Errorf("new store: %w", err)
}
|
错误不断向上包裹时,简洁文案的可读性会明显更好。
1
2
3
| failed to x: failed to y: failed to create new store: the error
x: y: new store: the error
|
错误命名#
对于存放在全局变量中的错误值,按是否导出分别使用 Err 或 err 前缀。
1
2
3
4
5
6
7
8
9
| var (
// 下面两个错误是导出的,
// 这样包使用方就可以通过 errors.Is 匹配它们。
ErrBrokenLink = errors.New("link is broken")
ErrCouldNotOpen = errors.New("could not open")
// 这个错误不导出,只在包内部使用。
errNotFound = errors.New("not found")
)
|
自定义错误类型则通常使用 Error 作为后缀。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| type NotFoundError struct {
File string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("file %q not found", e.File)
}
type resolveError struct {
Path string
}
func (e *resolveError) Error() string {
return fmt.Sprintf("resolve %q", e.Path)
}
|
这样命名的好处是比较直观,看到名字就能判断它是一个错误值,还是一个可供 errors.As 提取的错误类型。
一个错误只处理一次#
调用方从被调用方拿到错误后,通常有几种处理方式:
- 如果契约里定义了明确错误,就用
errors.Is 或 errors.As 做分支处理。 - 如果错误可恢复,就记录日志并优雅降级。
- 如果当前层没法处理,就包装后继续返回。
这里最重要的一条经验是:同一个错误通常只处理一次。
不要一边打印日志,一边把同一个错误继续往上返回,否则上层很可能还会再记一遍日志,最后形成大量重复噪音。
不推荐:
1
2
3
4
5
| u, err := getUser(id)
if err != nil {
log.Printf("could not get user %q: %v", id, err)
return err
}
|
推荐做法是补充上下文后直接返回,让上层决定是否记录日志。
1
2
3
4
| u, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
|
如果这个错误是可恢复的,也可以在当前层就地处理并降级,而不是继续中断主流程。
1
2
3
| if err := emitMetrics(); err != nil {
log.Printf("could not emit metrics: %v", err)
}
|
如果被调用方已经约定了明确的错误语义,就可以针对这个错误做恢复处理,其余错误继续返回。
1
2
3
4
5
6
7
8
| tz, err := getUserTimeZone(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
tz = time.UTC
} else {
return fmt.Errorf("get user %q: %w", id, err)
}
}
|
处理类型断言失败#
类型断言如果使用单返回值写法,一旦类型不匹配就会直接触发 panic。
不推荐:
更稳妥的写法是使用 comma ok 模式。
1
2
3
4
| t, ok := i.(string)
if !ok {
// 优雅处理错误
}
|
不要把 panic 当成常规错误处理#
生产代码里,普通错误应该尽量通过返回 error 来处理,而不是直接 panic。因为 panic 很容易引发级联故障,让本来只影响一个请求的问题扩大成整个进程异常。
不推荐:
1
2
3
4
5
| func run(args []string) {
if len(args) == 0 {
panic("an argument is required")
}
}
|
推荐:
1
2
3
4
5
6
7
8
9
10
11
12
13
| func run(args []string) error {
if len(args) == 0 {
return errors.New("an argument is required")
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
|
panic/recover 不是一种常规错误处理策略。通常只有在真正不可恢复的场景下,panic 才是合理选择,比如程序初始化阶段就已经发现关键依赖无法正常工作。
1
| var statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))
|
测试代码里也一样,优先使用 t.Fatal 或 t.FailNow,不要直接 panic。
本文介绍了 Go 错误处理机制,包括错误类型的定义、错误的包装和解包、错误的上下文信息等。
希望本文能帮助大家理解 Go 错误处理机制,写出更健壮的代码。