Go中如何优雅的处理错误

前言

在 Error vs Exception vs ErrNo 一文中对比过 Python、C、Go 这三种编程语言错误处理的不同之处。其中 Python 的 Exception 异常处理机制是主流编程语言中最为流行的方式,可是 Go 为什么采用了 Error 机制呢?

Go 官方的 FAQ: Why does Go not have exceptions? 中给出了解释:

我们认为,将异常与控制结构耦合在一起(如 try-catch-finally 语句)会导致代码变得复杂。同时,这也往往会促使程序员将太多普通的错误(比如打开文件失败)标记为异常。

Go 采用了一种不同的处理方式。对于普通的错误处理,Go 函数支持多返回值机制使得在不覆盖返回值的情况下,能够轻松地报告错误。Go 还提供了一个标准的错误类型,再加上其他特性,使得错误处理变得简洁而又与其他语言截然不同。

Go 还提供了一些内置函数,用于标识和恢复真正的异常情况。恢复机制只会在函数状态因错误而被销毁时执行,这足以处理灾难性错误,同时不需要额外的控制结构。使用得当时,可以写出简洁的错误处理代码。

详情请参考 Defer, Panic, and Recover 一文。另外,博客文章 Errors are values 展示了一种整洁的错误处理方式,说明了由于错误只是值,Go 语言的全部能力都可以用于处理错误。

说白了,Go 官方认为 Error 机制更简单有效,且符合 Go 语言大道至简的调性。

构造错误

既然要讲解如何处理错误,那么就先从如何构造一个错误说起吧。

Go 的 error 实际上就是一个普通的接口,普普通通:

type error interface {
	Error() string
}

得益于 Go 函数支持多返回值的能力,我们可以非常方便的返回一个错误:

func foo() (string, error) {
	// do something
	return "", nil
}

NOTE: 当函数返回多个值时,error 作为最后一个返回值是约定俗成的惯用法。如果你不这么做,代码当然能成功编译,但你有更好的选择。

Go 提供了两种构造错误的方式:

// 创建一个错误值
err1 := errors.New("example err1")
// 格式化错误消息
err2 := fmt.Errorf("example err2: %d", userID)

这两种构造错误的方式最终都是返回 errorString 类型的指针:

// errors.New 函数定义
func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

NOTE: 其实 fmt.Errorf 内部也是调用 errors.New 来创建 error。当然,在 Go 1.13 版本以后,fmt.Errorf 可能会在特定条件下返回 wrapError 类型错误。

处理错误

现在我们已经可以构造一个错误,接下来看看如何优雅的处理错误。

错误处理惯用法

如下示例是 Go 中经典的错误处理方式:

data, err := foo()
if err != nil {
	// 处理错误
	return
}
// 正常逻辑
fmt.Println(data)

一切的错误处理都从 if err != nil 开始。

Sentinel error

预定义的错误值:Sentinel error,一般被译为 哨兵错误。这是一种错误处理惯用法,在 Go 内置包中有大量应用。 比如:

var (
    ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
    ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
    ErrBufferFull        = errors.New("bufio: buffer full")
    ErrNegativeCount     = errors.New("bufio: negative count")
)

或者:

var ErrShortWrite = errors.New("short write")
var errInvalidWrite = errors.New("invalid write result")
var ErrShortBuffer = errors.New("short buffer")
var EOF = errors.New("EOF")
var ErrUnexpectedEOF = errors.New("unexpected EOF")
var ErrNoProgress = errors.New("multiple Read calls return no data or error")

这些都叫 Sentinel error,绝大多数 Sentinel error 都会被定义为包级别公开变量,可以看到也有内置的 errInvalidWrite 并没有对外公开。

每个 error 变量都以前缀 Err 开头,这是约定俗成的做法。io.EOF 是个特例,因为 EOF 是另一种约定用法,它的全拼是 end of file,表示文件结束,应用非常广泛,可以算作专有名词了。

我们可以像这样处理 Sentinel error:

if err != nil {
	if err == bufio.ErrBufferFull {
		// handle ErrBufferFull
	}
	// handle err
}

要处理多种错误类型时,可以使用 switch...case... 语句来简化处理:

f, err := os.Open("example.txt")
if err != nil {
	return
}

b := bufio.NewReader(f)

data, err := b.Peek(10)
if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // do something
        return
    case bufio.ErrBufferFull:
        // do something
        return
    default:
        // do something
        return
    }
}
fmt.Println(string(data))

示例中 b.Peek(10) 可能会返回 ErrNegativeCount 或 ErrBufferFull 错误变量,因为它们是依赖包中可导出的公开变量,所以我们可以在自己的代码中使用这些变量来识别返回了哪个特定的错误消息。

也就是说,这些 Sentinel error 变量会成为包 API 的一部分, 用于错误处理。

如果没有 Sentinel error 的存在,我们可能需要通过字符串匹配的方式来识别错误类型:

if err != nil {
	if strings.Contains(err.Error(), "buffer full") {

	}
}

我个人完全不赞成这种写法,不到万不得已,千万不要写成这种代码。

记住:error 接口上的 Error 方法适用于人类,而非代码。只有我们需要查看错误信息,或者记录日志的时候,才应该使用 Error 方法。

此外,你可能在标准库中见到过如下类似代码:

var (
	// ErrInvalid indicates an invalid argument.
	// Methods on File will return this error when the receiver is nil.
	ErrInvalid = fs.ErrInvalid // "invalid argument"

	ErrPermission = fs.ErrPermission // "permission denied"
	ErrExist      = fs.ErrExist      // "file already exists"
	ErrNotExist   = fs.ErrNotExist   // "file does not exist"
	ErrClosed     = fs.ErrClosed     // "file already closed"
)

os.ErrInvalid 实际上等价于 fs.ErrInvalid,这种为 Sentinel error 重新赋值的操作也很常见。为了保持良好的分层架构,我们自己的代码设计也可以这样做。

另外,Sentinel error 还有一种看似“另类”的用法,表示错误没有发生,比如 path/filepath.SkipDir:。

// SkipDir is used as a return value from [WalkDirFunc] to indicate that
// the directory named in the call is to be skipped. It is not returned
// as an error by any function.
var SkipDir = errors.New("skip this directory")

根据注释我们可以了解到,SkipDir 变量用作 WalkDirFunc 的返回值,以指示将跳过调用中指定的目录,它并不表示一个错误。

所以这里 SkipDir 仅作为哨兵,而非错误。其实 io.EOF 也是哨兵,并且它们都没有以 Err 来命名。

这也是我认为 Sentinel error 存在二义性的地方,我个人认为绝大多数情况下不应该这么使用,尽量避免这种用法。

常量错误

因为 Sentinel error 是一个变量,所以我们可以随意改变它的值:

oldEOF := io.EOF
io.EOF = errors.New("MyEOF")
fmt.Println(oldEOF == io.EOF) // false

这是一个很可怕的事情。

所以 Sentinel error 的确不是一个好的设计,起码也应该将其定义成一个常量。

但问题是在 Go 中我们无法直接将 errors.New 的返回值赋值给一个常量。

如下示例:

const ErrMyEOF = errors.New("MyEOF")

这将得到编译报错:

errors.New("MyEOF") (value of type error) is not constant

为了解决这个问题,我们可以自定义 error 类型:

type Error string

func (e Error) Error() string { return string(e) }

Error 类型底层类型为 string,所以可以直接赋值给一个常量:

const ErrMyEOF = Error("MyEOF")

现在常量 ErrMyEOF 不可改变。
但是,这又会引入另外一个新的问题。以下示例代码,执行结果为 true:

const ErrMyEOF = Error("MyEOF")
const ErrNewMyEOF = Error("MyEOF")
fmt.Println(ErrMyEOF == ErrNewMyEOF) // true

这与 Go 内置的 errors.New 表现并不相同。

以下示例代码,执行结果为 false:

myEOF = errors.New("EOF")
fmt.Println(io.EOF == myEOF) // false

造成二者表现不同的原因是:内置的 errors.New 函数返回 errorString 的指针类型 &errorString{text},而我们构造的自定义 Error 实际上是 string 类型。

errors.New 返回指针类型是有意而为之的,目的就是在判断两个错误值是否相等时,会比较两个对象是否为同一个对象,而不是比较 Error 方法所返回的字符串内容是否相等。如果仅比较字符串内容是否相等,则我们随便使用 errors.New 函数创建的错误就可以实现与预置的 Sentinel error 相等。

所以常量错误并不常见,我个人其实也不太推荐一定要追求把错误定义为常量,适当引入的编码规范更加切合实际。

尽管 errorString 类型仅包含一个字段 s string,但它还是被有意设计成 struct 而非简单的 string 类型别名,否则 Sentinel error 实用价值将大大折扣。

定制错误类型

与使用 errors.New 创建出来的 *errorString 错误值相比,定制错误类型往往能提供更多的上下文信息。

Go 内置库中就有这样的例子,比如错误类型 os.PathError:

// PathError records an error and the operation and file path that caused it.
type PathError struct {
	Op   string
	Path string
	Err  error
}

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

NOTE: 错误类型命名通常以 Error 结尾,这是约定俗成的惯用法。

PathError 类型不仅能够记录错误,还会记录导致出现错误的操作和文件路径。在出现错误时,更方便排查问题。

有了新的错误类型后,最大的好处是可以通过类型断言,来判断错误的类型。如果断言成立,则可以根据错误类型对当前错误做更为精细的控制。

示例如下:

// 尝试打开一个不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
	// 使用类型断言检查是否为 *os.PathError 类型
	if pathErr, ok := err.(*os.PathError); ok {
		fmt.Printf("Failed to %s file: %s\n", pathErr.Op, pathErr.Path)
		fmt.Println("Error message:", pathErr.Err)
	} else {
		// 其他类型的错误处理
		fmt.Println("Error:", err)
	}
}

可以发现,为了实现错误类型的断言检查,PathError 类型必须是公开类型。

其实无论是 Sentinel error,还是自定义的错误类型,它们都存在同样的问题,都会成为包 API 的一部分,被公开出去。这很可能导致包 API 的快速膨胀。并且,如果代码分层设计不好,很容易出现循环依赖问题。

Opaque error

Opaque error 是 Go 语言布道师 Dave Cheney 在 Gocon Spring 2016 演讲中提出的一种叫法,姑且把它翻译为 不透明的错误处理。

Opaque error 非常简单,它是最灵活的错误处理策略,因为它需要代码和调用者之间的耦合最少。

示例如下:

import “github.com/quux/bar”

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err
        }
        // use x
}

这就是 Opaque error 的全部内容了:只需返回错误,而不对其内容做出任何假设。

没错,遇到错误后直接 return err 的做法就是 Opaque error。

显然,这种代码看似优雅,却过于理想。现实中我们仍有很多情况下还是需要知道错误内容,然后决定是否对其进行处理。

错误值比较

比较两个错误值是否相等的操作,一般结合 Sentinel error 一同使用:

if err != nil {
	if err == bufio.ErrBufferFull {
		// handle ErrBufferFull
	}
	// handle err
}

先使用 if err != nil 与 nil 比较来判定是否存在错误,如果有错误,更进一步,使用 if err == bufio.ErrBufferFull 来判定错误是否为某个 Sentinel error。

当可能出现多种错误时,还可以使用 switch...case... 来判定错误值:

if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // do something
        return
    case bufio.ErrBufferFull:
        // do something
        return
    default:
        // do something
        return
    }
}

类型断言

Go 支持两种类型断言,Type Assertion 和 Type Switch。

Go 的类型断言语法可以直接应用于错误处理,因为 error 本身就是一个普通的接口。

断言一个错误的类型,其实前文中我们已经见过了:

// 尝试打开一个不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
	// 使用类型断言检查是否为 *os.PathError 类型
	if pathErr, ok := err.(*os.PathError); ok {
		fmt.Printf("Failed to %s file: %s\n", pathErr.Op, pathErr.Path)
		fmt.Println("Error message:", pathErr.Err)
	} else {
		// 其他类型的错误处理
		fmt.Println("Error:", err)
	}
}

如果改用 switch...case... 可以这样写:

// 尝试打开一个不存在的文件
_, err := os.Open("nonexistent.txt")
if err != nil {
	// 使用 switch type 检查错误类型
	switch e := err.(type) {
	case *os.PathError:
		fmt.Printf("Failed to %s file: %s\n", e.Op, e.Path)
		fmt.Println("Error message:", e.Err)
	default:
		// 其他类型的错误处理
		fmt.Println("Error:", err)
	}
}

值得一提的是,在使用 Type Switch 语法时,是禁止使用 fallthrough 关键字的,否则编译报错 cannot fallthrough in type switch。

这种情况 case 语句只能使用逗号并提供多个选项:

if err != nil {
	switch err.(type) {
	case *os.PathError, *os.LinkError:
		// do something
	default:
		// do something
	}
}

这两种方法的最大缺点就是我们需要导入指定的错误类型,如示例中的 os.PathError 或 os.LinkError。这会导致我们的代码与错误所在的包存在较强的依赖关系。

行为断言

随着 Go 语言的演进,大家对 Go 的错误处理又有了新的理解。以前断言错误类型,现在社区中则更推荐断言错误行为。

Go 语言布道师 Dave Cheney 在他的文章 Inspecting errors 中提出了断言错误行为而不是类型。

NOTE: 没错,Dave Cheney 的名字再一次出现,后文还会出现😄。这位大佬对 Go 社区的贡献很大,尤其是错误处理,著名的 pkg/errors 包就是他开发的。

func isTimeout(err error) bool {
	type timeout interface {
		Timeout() bool
	}
	te, ok := err.(timeout)
	return ok && te.Timeout()
}

函数 isTimeout 用来判定一个错误对象是否表示 Timeout,内部通过断言错误对象是否实现了 timeout 接口来实现。

我们不再假设错误的类型,而是假设其实现了某个接口,并且 timeout 接口是一个临时接口,并不是从其他包中导入的接口类型。这样就真正的实现了包之间的解耦,错误类型无需公开,它们不再必须是包 API 的一部分。

net.Error 就是一个比较不错的实践:

// An Error represents a network error.
type Error interface {
	error
	Timeout() bool // Is the error a timeout?

	// Deprecated: Temporary errors are not well-defined.
	// Most "temporary" errors are timeouts, and the few exceptions are surprising.
	// Do not use this method.
	Temporary() bool
}

客户端代码可以断言错误是否为 net.Error 类型,然后再根据行为区分暂时性网络错误和永久性网络错误。

例如,一个爬虫程序在遇到临时错误时可以短暂休眠并重试,否则放弃这个请求,直接处理错误。

示例代码如下:

if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
	time.Sleep(1e9)
	continue
}
if err != nil {
	log.Fatal(err)
}

当然,这段代码还可以写成这样:

if nerr, ok := err.(interface{
	Temporary() bool
}); ok {
	time.Sleep(1e9)
	continue
}
if err != nil {
	log.Fatal(err)
}

这样就实现了我们的代码与错误所在的包之间最大化的解耦。

当然这两种写法其实都可以,看个人喜好。

暂存错误状态

在使用 Builder 模式、链式调用或者 for 循环等场景下,暂存中间过程所出现的错误,有助于简化代码,使编写出的代码逻辑更加连贯。

NOTE: 如果你不了解 Builder 模式,可以查阅我的另一篇文章:《Builder 模式在 Go 语言中的应用》。

以下示例中使用 K8s client-go SDK 提供的 clientset 客户端查询 pod 信息:

pod, err := clientset.CoreV1().Pods("default").Get(ctx, "nginx", metav1.GetOptions{})
if err != nil {
	// do something
}

其中的 Get 方法内部,就使用了链式调用,源码如下:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/kubernetes/typed/core/v1/pod.go#L75

// Get takes name of the pod, and returns the corresponding pod object, and an error if there is any.
func (c *pods) Get(ctx context.Context, name string, options metav1.GetOptions) (result *v1.Pod, err error) {
	result = &v1.Pod{}
	err = c.client.Get().
		Namespace(c.ns).
		Resource("pods").
		Name(name).
		VersionedParams(&options, scheme.ParameterCodec).
		Do(ctx).
		Into(result)
	return
}

c.client.Get() 会返回一个 *Request 对象,接着调用它的 Namespace(c.ns) 方法:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/rest/request.go#L294

// Namespace applies the namespace scope to a request (<resource>/[ns/<namespace>/]<name>)
func (r *Request) Namespace(namespace string) *Request {
	if r.err != nil {
		return r
	}
	if r.namespaceSet {
		r.err = fmt.Errorf("namespace already set to %q, cannot change to %q", r.namespace, namespace)
		return r
	}
	if msgs := IsValidPathSegmentName(namespace); len(msgs) != 0 {
		r.err = fmt.Errorf("invalid namespace %q: %v", namespace, msgs)
		return r
	}
	r.namespaceSet = true
	r.namespace = namespace
	return r
}

*Request.Namespace 方法首先会通过 if r.err != nil 判断是否存在错误,如果存在则直接返回,不再继续执行。如果不存在错误,则接下来每次可能出现错误的调用,都会将错误信息暂存到 r.err 属性中。
接下来是调用 Resource("pods") 方法:

https://github.com/kubernetes/kubernetes/blob/release-1.30/staging/src/k8s.io/client-go/rest/request.go#L210

// Resource sets the resource to access (<resource>/[ns/<namespace>/]<name>)
func (r *Request) Resource(resource string) *Request {
	if r.err != nil {
		return r
	}
	if len(r.resource) != 0 {
		r.err = fmt.Errorf("resource already set to %q, cannot change to %q", r.resource, resource)
		return r
	}
	if msgs := IsValidPathSegmentName(resource); len(msgs) != 0 {
		r.err = fmt.Errorf("invalid resource %q: %v", resource, msgs)
		return r
	}
	r.resource = resource
	return r
}

*Request.Resource 方法内部代码逻辑的套路,与 *Request.Namespace 方法如出一辙。

client-go 就是通过将错误暂存到 *Request.err 属性的方式,简化了使用侧的代码逻辑。我们可以放心编写代码中的链式调用,只在最后处理一次错误即可。如果调用链中间某个方法出现了错误,之后执行的方法都能够自行处理。

返回错误而不是指针

Dave Cheney 在他的文章 Errors and Exceptions, redux 中列举了一个程序示例:

func Positive(n int) (bool, bool) {
        if n == 0 {
                return false, false
        }
        return n > -1, true
}

这是一个判断给定变量 n 的值为正负数的小函数。

0 既不是正数也不是负数,因此为了判断传进来的 n 是否为 0,函数必须返回两个值,第一个 bool 值标识 正/负,第二个 bool 值标识返回的第一个值是否有效,即 n 是否为 0。

还有一种实现方式是下面这样:

func Positive(n int) (bool, error) {
        if n == 0 {
                return false, errors.New("undefined")
        }
        return n > -1, nil
}

我们使用 error 来作为 Sentinel,标识 n 是否为 0。

以上两种方式我个人认为都可以接受,看个人喜好选择即可。

不过,有人可能会有不同的实现:

func Positive(n int) *bool {
        if n == 0 {
                return nil
        }
        r := n > -1
        return &r
}

这次实现的 Positive 函数仅有一个返回值,类型是 *bool。

  • 当 *bool 值为 nil,标识 n 是否为 0。
  • 当 *bool 值不为 nil,其解引用后,值为 true 标识结果为正,值为 false 标识结果为负。

这种做法极其不推荐,不仅使返回值存在二义性,还使调用方代码变得啰嗦。在任何地方使用返回值之前,我们都必须检查它以确保它指向的地址有效。

Errors are values

Errors are values 是 Rob Pike 提出来的,旨在纠正人们对 Go 错误处理的认知。

Errors are values —— 错误就是值! 它没什么特殊的,你在使用 Go 语言编程过程中,可以像对待其他任何普通类型一样对待错误。

所以,我们可以对错误进行等值比较、类型断言、行为断言等操作。遗憾的是,这是非常基本的东西,大多数 Go 程序员却没有注意到。

现在我们有如下示例代码:

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

这里存在非常严重的重复,这也是 if err != nil 容易被吐槽的典型场景。

不过,我们可以编写一个简单的辅助函数,来解决这个问题:

var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
// and so on
if err != nil {
    return err
}

现在,代码看起来是不是好了一些,没有了那么多重复的 if err != nil。

我们还可以对这个示例程序做进一步优化:

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

定义一个 errWriter 结构体,来代理 io.Writer 对象的写操作,并能暂存错误。

可以这样使用 errWriter:

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

这里正是采用了前文中讲解的 暂存错误状态 思想。

之所以列举这个示例,就是为了告诉大家,你可以像对待其他任何普通类型一样对待错误。错误可以作为 errWriter 结构体的一个属性存在,这没什么不妥,像编写其他代码一样正常的处理错误即可。

记住:错误就是值。

不要忽略你的错误

任何时候不要写出这种代码:

data, _ := Foo()

如果你确信 Foo() 的确不会返回错误,可以对其进行包装,在内部处理错误:

func MustFoo() string {
	data, err := Foo()
	if err != nil {
		panic(err)
	}
	return data
}

现在,我们可以不关心错误直接调用 MustFoo():

data := MustFoo()

MustXxx 这种做法也算比较常见,比如 Gin 框架中就有很多这种风格的实现:

https://github.com/gin-gonic/gin/blob/v1.10.0/context.go#L280

// Get returns the value for the given key, ie: (value, true).
// If the value does not exist it returns (nil, false)
func (c *Context) Get(key string) (value any, exists bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	value, exists = c.Keys[key]
	return
}

// MustGet returns the value for the given key if it exists, otherwise it panics.
func (c *Context) MustGet(key string) any {
	if value, exists := c.Get(key); exists {
		return value
	}
	panic("Key \"" + key + "\" does not exist")
}

切记,不要忽略你的错误,除非这你把它当作一个 demo 程序,而非生产项目。

错误只应处理一次

虽然,不应该忽略你的错误,但错误也不应该被重复处理,你应该只处理一次错误。

处理错误意味着检查错误值并做出决定。也就是说,根据能否处理错误,我们实际上只有两种选择:

  1. 不能处理:直接向上传递错误,自身不对错误做任何假设,也就是 Opaque error。
  2. 可以处理:降级处理,并向上返回 nil,因为自己已经处理了错误,表示不再有错误了,上层不应该继续拿到这个错误。
    有如下示例:
func Foo() error {
	return errors.New("foo error")
}

func Bar() error {
	err := Foo()
	if err != nil {
		// do something
		return err
	}
	return err
}

func main() {
	err := Bar()
	if err != nil {
		// do something
	}
}

在函数调用链中,错误被处理了两次。这是非常糟糕的实践,如果两处都记录日志,那么最终看到的日志将会有非常多的重复内容。

正确的做法应该是这样:

func Foo () error {
	return errors.New("foo error")
}

func Bar() error {
	return Foo()
}

func main() {
	err := Bar()
	if err != nil {
		// do something
	}
}

仅在调用链顶端 main 函数中处理一次错误。

有时候,我们也可以这样处理:

func Foo() error {
	return errors.New("foo error")
}

func Bar() error {
	err := Foo()
	if err != nil {
		// do something
		// NOTE: 这里提供服务降级处理,如记录日志
		return nil
	}
    // do something
	return nil
}

func main() {
	err := Bar()
	if err != nil {
		// do something
	}
}

这次,Bar 函数中遇到调用 Foo() 出错的情况,会进行降级,然后返回 nil。

错误同样只会被处理一次,main 函数永远也得不到 Foo() 函数返回的错误。

审查你的错误处理代码

一些代码细节,也能让处理错误的代码更加优雅。

在缩进中处理错误

在缩进中编写你的处理错误逻辑:

f, err := os.Open(filename)
if err != nil {
    // handle error
}
// normal logic

而不是在缩进中处理正常逻辑:

f, err := os.Open(filename)
if err != nil {
    // normal logic
}
// handle error

这样在 IDE 中能够非常方便的折叠 if err != nil 逻辑,使得阅读正常逻辑的代码更加清晰。

不要做冗余的错误检查

不要写出这种代码:

func Foo() error {
	err := Bar()
	if err != nil {
		return err
	}
	return nil
}

这里的错误检查完全没有任何意义,属于冗余代码。除非你的工作以代码行数来做 KPI 考核,否则没有任何理由这样写。

正确写法如下:

func Foo() error {
	return Bar()
}

参考社区中的 Go 代码规范,更多代码细节就等着你自己去发现了。

nil 错误值可能不等于 nil

这个问题来自 Go 的 FAQ: Why is my nil error value not equal to nil ? 是我们开发过程中要注意的一个点。

有如下示例代码:

package main

import "fmt"

type MyError struct {
	msg string
}

func (e *MyError) Error() string {
	return e.msg
}

func returnsError() error {
	var p *MyError = nil
	return p // Will always return a non-nil error.
}

func main() {
	err := returnsError()
	if err != nil {
		fmt.Println("err:", err)
		return
	}
	fmt.Println("success")
}

执行示例代码,得到输出如下:

$ go run main.go
err: <nil>

可以发现,main 函数中的 if err != nil 错误检查结果为 true,但使用 fmt.Println 输出的值却为 nil。

出现这一怪异现象的原因与 Go 的接口实现有关。

在 Go 中一个接口对象实际包含两个属性:类型 T 和具体的值 V。例如,如果我们将值为 3 的 int 类型对象存储在接口中,则生成的接口对象实际上内部保存了:T=int, V=3。

仅当 T 和 V 都未设置时(T=nil, V 未设置),接口的值才为 nil。

如果我们将 *int 类型的 nil 指针存储在接口对象中,则无论指针的值是什么,接口类型都将是 T=*int, V=nil。

同理虽然 p 在初始化时赋值为 nil(var p *MyError = nil),但是它会被赋值给接口类型 error,我们得到的接口类型将是 T=*MyError, V=nil。

所以,我们应该避免写出这种代码。

错误与日志

错误与日志密不可分,在程序出错时记录日志能够方便我们排查问题。

显式胜于隐式

我们知道 fmt.Printf 的 %s 动词能够格式化一个字符串。如果参数是一个 error 对象,则会自动调用其 Error 方法。

示例如下:

fmt.Printf("%s", err)

但更好的方式是我们手动调用 Error 方法:

fmt.Printf("%s", err.Error())

Python 之禅中的「显式胜于隐式」在这里依然适用。

记录日志前请确保错误真的存在

如下示例代码记录了错误日志:

package main

import (
	"fmt"
)

func Foo() error {
	return nil
}

func main() {
	err := Foo()
	// slog.Info(err.Error())
	fmt.Printf("INFO: call foo: %s\n", err)
}

执行示例代码,得到输出如下:

$ go run main.go
call foo: %!s(<nil>)

可以发现这里格式化 err 对象失败了。

在将错误记录到日志前,我们有责任确保错误真的存在。否则调用 err.Error() 程序将发生 panic。

何时记录错误日志

记录日志其实是一个比较大的话题,并且存在一定争议。何时记录、记录什么以及如何更好的记录都是比较复杂的问题。更糟糕的是,对于不同项目,可能会有不同的答案。

所以,这里只讲下我个人对何时记录错误日志的理解。

其实核心还是一句话:错误只应处理一次。

示例如下:

func Foo() error {
	return errors.New("foo error")
}

func Bar() error {
	return Foo()
}

func main() {
	err := Bar()
	if err != nil {
		slog.Error(err.Error())
	}
}

示例代码中只在 main 函数中记录了一次错误日志,调用链中间遇到错误直接返回,不做任何处理。

同样,如果遇到服务降级的情况,我们也可以记录日志,并返回 nil,不再继续向上报告当前错误:

func Foo() error {
	return errors.New("foo error")
}

func Bar() error {
	err := Foo()
	if err != nil {
		// NOTE: 服务降级,记录日志
		slog.Error(err.Error())
		return nil
	}
	// do something
	return nil
}

func main() {
	err := Bar()
	if err != nil {
		slog.Error(err.Error())
	}
}

此外,我们还可以在调用链中间为错误附加信息,并在顶层 main 函数中处理错误:

func Foo() error {
	return errors.New("foo error")
}

func Bar() error {
	err := Foo()
	return errors.WithMessage(err, "Bar")
}

func main() {
	err := Bar()
	if err != nil {
		slog.Error(fmt.Sprintf("%+v", err))
	}
}

并且,这里还使用 %+v 动词记录了错误的详细堆栈信息到日志中。

错误日志应该记录什么

一般来说,记录错误日志是为了方便排查问题,所以信息要尽可能多,那么错误日志中堆栈信息就必不可少。
所以,仍然推荐使用 pkg/errors 来处理错误。

如果项目比较小,调用层数不深,错误日志中只记录 err.Error() 信息也没什么关系。

但是,如果是中大型项目,尤其是微服务开发,错误日志应该记录 err.Format() 信息,即使用 fmt.Sprintf("%+v", err) 结果。

本文示例源码我都放在了 GitHub 中,欢迎点击查看。

关于我
loading