前言

错误处理是一门语言的重要特性。通过此机制,我们可以捕获、处理和记录错误,确保程序的稳定性和可靠性。

Go 错误处理

处理机制

Go 错误处理利用了函数的多返回值特性,通常会将 err 作为最后一个返回值,用于表示此处函数调用是否成功。

  • errnil 时,表示函数调用成功,否则表示失败。
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 结构体中包含了 FieldReason 两个字段,分别表示出错的字段和出错的原因。

 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.Errorferrors.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, &notFound) {
		// 处理该错误
	} 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.Iserrors.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

错误命名

对于存放在全局变量中的错误值,按是否导出分别使用 Errerr 前缀。

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.Iserrors.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

不推荐:

1
t := i.(string)

更稳妥的写法是使用 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.Fatalt.FailNow,不要直接 panic

总结

本文介绍了 Go 错误处理机制,包括错误类型的定义、错误的包装和解包、错误的上下文信息等。

希望本文能帮助大家理解 Go 错误处理机制,写出更健壮的代码。

参考