10. 错误
10.1 接口 error 是什么
C
语言中常常返回整数错误码(errno
)来表示函数处理出错,通常用 -1 表示错误,用 0 表示正确。在 Go
中,使用 error
类型表示错误,不过它不再是一个整数类型,而是一个接口类型。
type error interface {
Error() string
}
它表示哪些能用一个字符串就能说清的错误。最常用的就是 errors.New()
函数,非常简单:
//src/errors/errors.go
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
使用 New
函数创建出来的 error
类型实际上是 errors
包里未导出的 errorString
类型,它包含唯一的一个字段 s ,并且实现了唯一的方法:Error()string
。
通常这就够了,他能反映出当时“出错了”,但是有些时候需要更加具体的信息,例如:
func Sqrt(f float64)(float64, error) {
if f < 0 {
return 0, errors.New("math:square root of negative number")
}
// implementation
}
当调用者发现出现错误的时候,只知道传入了一个负数进来,并不清楚到底传的是什么值。在 Go
里:
It is the error implemetation's responsibility to summarize the context.
即要求返回这个错误的函数要给出具体的“上下文”信息。也就是说,在 Sqrt
函数里,要给出这个负数到底是什么。
所以,如果发现 f<0
,应该这样返回错误:
if f <0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}
这就用到了 fmt.Errorf
函数,它先将字符串格式化,再调用 errors.New
函数来创建错误。当想知道错误类型,并且打印错误信息的时候,直接打印 error
:
fmt.Println(err)
或者:
fmt.Println(err.Error)
fmt
包会自动调用 err.Error()
函数来打印字符串。
通常,将 error
放到函数返回值的最后一个,这是约定俗成的做法; 另外,构造 error
的时候, 要求传入的字符串首字母小写,结尾不带标点符号,这是因为人们经常会这样使用返回的 error
:
... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)
10.2 接口 error 有什么问题
在 Go
中,错误处理是非常重要的。它从语言层面要求人们需要明确地处理遇到的错误。而不是像其他语言,例如 Java
,使用 try-catch-finally
这种“把戏”。
但坏处也是显而易见的,Go
代码里“error”满天飞,显得非常冗长拖沓,并且容易掩盖正常的逻辑。
而为了代码健壮性考虑,对于函数返回的每一个错误,都不能忽略。因为出错的同时,很可能会返回一个 nil
类型的对象。如果不对错误进行判断,那之后对 nil
对象的操作立马会引发 panic
。
于是,Go
语言中诟病最多的就是它的错误处理方式似乎回到了上古 C
语言时代:
err := doStuff1()
if err != nil {
// handle error...
}
err = doStuff2()
if err != nil {
// handle error...
}
err = doStuff3()
if err != nil {
// handle error... }
Go authors
之一的 Russ Cox 对于这种观点进行过驳斥:当初选择返回值这种错误处理机制而不是 try-catch
,主要是考虑前者适用于大型软件,后者更适合小程序。
在 Go
语言官网的 FAQ
里也提到,try-catch
会让代码变得非常混乱,程序员会倾向将一些常见的错误,例如: failing to open a file
,也抛到异常里,这会让错误处理更加冗长烦琐且易出错。
而 Go
语言的多返回值使得返回错误异常简单。一般的错误使用 error
,对于真正的异常,Go
提供 panic-recover
机制,这样的处理比较 Java
那种将错误异常“一锅端”的做法更有优势,也使得代码看起来更简洁。
当然 Russ Cox
也承认 Go
的错误处理机制对于开发人员的确有一定的心智负担。Go
社区曾经出现过用“check & handle
”关键字和“try
内置函数”改进错误处理流程的提案,目前这两种提案已经被官方拒绝,目前还没有更好的后续改进方案。
10.3 如何理解关于 error 的三句谚语
Go
语言有很多“箴言”,说得很顺口,但理解起来并不是太容易,因为它们大部分都是有故事的。例如下面这些,如图所示。
本节讲三条关于 error
的“箴言”,重要的是要理解这些“箴言”背后的道理。
10.3.1 视错误为值
第一句箴言,视错误为值(Errors are just values
),实际意思是只要实现了 Error
接口的类型都可以认为是 Error
。处理 error
的方式分为三种:
- Sentinel errors.
- Error Types.
- Opaque errors.
首先 Sentinel errors
,Sentinel
来自计算机中常用的词汇,中文意思是“哨兵”。Sentinel errors
实际想说的是这里有一个错误,处理流程不能再进行下去了,必须要在这里停下。而这些错误,往往是提前约定好的。
例如,io
包里的 io.EOF
,表示“文件结束”错误:
func main() {
r := bytes.NewReader([]byte("0123456789"))
_, err := r.Read(make([]byte, 10))
if err == io.EOF {
log.Fatal("read failed:", err)
}
}
但是这种方式处理起来,不太灵活;必须要判断 err
是否和约定好的错误 io.EOF
相等。 再来一个例子,如果想返回 err
并且加上一些上下文信息时,就麻烦了:
func main() {
err := readfile(“.bashrc”)
if strings.Contains(error.Error(), "not found") {
// handle error
}
}
func readfile(path string) error {
err := openfile(path)
if err != nil {
return fmt.Errorf("cannot open file %s: %v", path, err)
}
//......
}
如果在 readfile
函数里判断 err
不为空,就用 fmt.Errorf
在 err
前加上具体的 file
信息,返回给调用者,返回的 err
其实还是一个字符串。
造成的后果是,调用者不得不用字符串匹配的方式判断底层函数 readfile
是否出现了某种错误。然而,当必须要这样才能判断某种错误时,代码的“坏味道”就出现了。
其实,err.Error()
方法是给程序员而非代码设计的,也就是说当调用 Error
方法时,结果要写到文件或是打印出来,是给程序员看的。在代码里,不要根据 err.Error()
来做一些判断,就像上面的 main
函数里所做的那样,这是代码的“坏味道”。
Sentinel errors
最大的问题在于它在定义 error
和使用 error
的包之间建立了依赖关系。比如要想判断 err == io.EOF
就得引入 io
包,当然这是标准库的包,还能接受。但如果很多用户自定义的 包都定义了错误,那就要引入很多包,来判断各种错误,容易引起循环引用的问题。
因此,应该尽量避免 Sentinel errors
,尽管标准库中有一些包这样用,但建议还是别模仿。
第二种就是 Error Types
,它指的是实现了 error
接口的那些类型。它的一个重要的好处是,类型中除了 error
外,还可以附带其他字段,从而提供额外的信息,例如出错的行数等。
标准库有一个非常好的例子:
// src/os/error.go
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}
PathError
额外记录了出错时的文件路径和操作类型。通常,使用这样的 error
类型,外层调用者需要使用类型断言来判断错误:
// underlyingError returns the underlying error for known os error types.
func underlyingError(err error) error {
switch err := err.(type) {
case *PathError:
return err.Err
case *LinkError:
return err.Err
case *SyscallError:
return err.Err
}
return err
}
但是这又不可避免地在定义错误和使用错误的包之间形成依赖关系,就又回到了前面的问题。
即使 Error types
比 Sentinel errors
好一些,因为它能承载更多的上下文信息,但它仍然存在引入包依赖的问题。因此,也是不推荐的。至少,不要把 Error types
作为一个导出类型。
最后一种,Opaque errors
,也就是“黑盒 errors”:能知道错误发生了,但是无法看到它内部到底是什么,不知道它的具体类型是什么。
例如下面这段伪代码:
func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
//usex
return nil
}
作为调用者,调用完 Foo
函数后,只用知道 Foo
是正常工作还是出了问题。也就是说只需要判断 err
是否为空,如果不为空,就直接返回错误;否则,继续后面的正常流程,不需要知道 err
到底是什么。
这就是处理 Opaque errors
这种类型错误的策略:一旦出错,直接返回错误;否则,继续后面的流程。
当然,在某些情况下,这样做并不够用。例如,在一个网络请求中,需要调用者判断返回的错误类型,以此来决定是否重试。这种情况下不要去判断错误的类型到底是什么,而是去判断错误是否具有某种行为,或者说实现了某个接口。
来看个例子:
type temporary interface {
Temporary() bool
}
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}
拿到网络请求返回的 error
后,调用 IsTemporary
函数,如果返回 true
,那就重试。
这么做的好处是在进行网络请求的包里,不需要 import
引用定义错误的包,并且不需要知道 error
的具体类型,只需要判断它的行为。这也类似设计模式中的一个原则:面向接口编程,而不是面向对象编程。
10.3.2 检查并优雅地处理错误
第二句箴言:检查并优雅的处理错误(Don’t just check errors, handle them gracefully),即不要仅检查错误,更要优雅地处理它们。
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return err
}
return nil
}
上面这个例子中的代码有很多冗余,不够简洁,直接优化成一行就可以了:
func AuthenticateRequest(r *Request) error {
return authenticate(r.User)
}
还有其他的问题,在函数调用链的最顶层,得到的错误可能是: No such file or directory
。 这个错误反馈的信息太少了,不知道文件名、路径、行号等。尝试改进一下,增加一些上下文:
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}
这种做法实际上是先将错误转换成字符串,再拼接另一个字符串,最后,再通过 fmt.Errorf
转换成错误。这样做破坏了相等性检测,即人们无法判断错误是否是一种预先定义好的错误了。
Go 1.13
之前的应对方案是使用第三方库: github.com/pkg/errors
,Go 1.13
后自带了 error
相关的高级函数,如 Wrap
等。不过要想输出错误堆栈,还是要使用前者。这里先看 pkg/errors
,它提供了友好的接口:
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
// Cause unwraps an annotated error.
func Cause(err error) error
通过 Wrap
可以将一个错误,加上一个字符串,“包装”成一个新的错误; 通过 Cause
则可以进行相反的操作,将里层的错误还原。
有了这两个函数,就方便很多:
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
这是一个读文件的函数,先尝试打开文件,如果出错,则返回一个附加上了“open failed”的错误信息; 之后,尝试读文件,如果出错,则返回一个附加上了“read failed”的错误。
当在外层调用 ReadFile
函数时:
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.Wrap(err, "could not read config")
}
这样在 main
函数里就能输出这样一个错误信息:
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
它是有层次的,非常清晰。而如果用 fmt.Printf
和 %+v
格式来输出:
func main() {
_, err := ReadConfig()
if err !=nil {
fmt.Printf("%+v", err)
os.Exit(1)
}
}
能得到更有层次、更详细的错误堆栈:
open /Users/qcrao/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/qcrao/go/src/hello/main.go:14
main.ReadConfig
/Users/qcrao/go/src/hello/main.go:27
main.main
/Users/qcrao/go/src/hello/main.go:32
runtime.main
/usr/local/go/src/runtime/proc.go:203
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1357
could not read config
main.ReadConfig
/Users/qcrao/go/src/hello/main.go:28
main.main
/Users/qcrao/go/src/hello/main.go:32
runtime.main
/usr/local/go/src/runtime/proc.go:203
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1357
上面讲的是 Wrap
函数,接下来看一下“Cause”函数,以前面提到的 temporary
接口为例:
type temporary interface {
Temporary() bool
}
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := errors.Cause(err).(temporary)
return ok && te.Temporary()
}
判断之前先使用 Cause
取出错误,做断言,最后递归地调用 Temporary
函数。如果错误没实现 temporary
接口,就会断言失败,返回 false
。
10.3.3 只处理错误一次
第三句箴言:只处理错误一次(Only handle error once
)。什么叫“处理”错误:
Handling an error means inspecting the error value, and making a decision.
这句话的意思是检查了一下错误,并且做出一个决定。例如,如果不做任何决定,相当于忽略了错误:
func Write(w io.Writer, buf []byte) {
w.Write(buf)
}
w.Write(buf)
会返回两个结果,一个表示写成功的字节数,一个是 error
,上面的例子中没有对这两个返回值做任何处理。
下面这个例子却又处理了两次错误:
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
i ferr != nil {
// annotated error goes to log file
log.Println("unable to write:", err)
// unannotated error returned to caller
return err
}
return nil
}
第一次处理是将错误写进了日志,第二次处理则是将错误返回给上层调用者。而调用者也可能将错误写进日志或是继续返回给上层。
这样一来,日志文件中会有很多重复的错误描述,并且在最上层调用者(如 main
函数)看来,它拿到的错误却还是最底层函数返回的 error
,没有任何上下文信息。
10.4 错误处理的改进
Go
语言有一些失败的尝试,比如 Go 1.5
引入 vendor
和 internal
来管理包,最后被滥用而引发了很多问题; Go 1.13
直接抛弃了 GOPATH
和 vendor
特性,改用 module
来管理包; Go
语言 之父之一 Robert Griesemer 提交的通过 try
内置函数来简化错误处理也被否决了。
2019 年 9 月 3 号,Go
发布 1.13
版本,除了将 module
特性转正之外,比较重要的还有将 defer
性能提升 30%
,将更多的对象从堆上移动到栈上以提升性能等。
Go 1.13
还支持了 error
包裹(wrapping
):
An error e can wrap another error w by providing an Unwrap method that returns w. Both e and w are available to programs,allowing e to provide additional context to w or to reinterpret it while still allowing programs to make decisions based on w.
为了支持 wrapping
,fmt.Error
f 增加了 %w
的格式,并且在 error
包增加了三个函数: errors.Unwrap
,errors.Is
,errors.As
。
fmt.Errorf
使用 %w
格式符来生成一个嵌套的 error
,它并没有像 pkg/errors
那样使用一个 Wrap
函数来嵌套 error
,非常简洁。
Unwrap
将嵌套的 error
解析出来,多层嵌套需要调用 Unwrap
函数多次,才能获取最里层的 error
:
func Unwrap(err error) error {
// 判断是否实现了 Unwrap 方法
u, ok := err.(interface {
Unwrap() error
})
// 如果不是,返回 nil
if !ok {
return nil
}
// 调用 Unwrap 方法返回被嵌套的 error
return u.Unwrap()
}
对 err
进行断言,看它是否实现了 Unwrap
方法,如果是,调用它的 Unwrap
方法; 否则,返回 nil
。
Is
判断 err
是否和 target
是同一类型,或者 err
嵌套的 error
有没有和 target
是同一类型的,如果是,则返回 true
:
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
// 无限循环,比较 err 以及嵌套的 error
for {
if isComparable && err == target {
return true
}
// 调用 error 的 Is 方法,这里可以自定义实现
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 返回被嵌套的下一层的 error
if err = Unwrap(err); err == nil {
return false
}
}
}
通过一个无限循环,使用 Unwrap
不断地将 err
里层嵌套的 error
解开,再看被解开的 error
是否实现了 Is
方法,并且调用它的 Is
方法,当两者都返回 true
的时候,整个函数返回 true
。
As
从 err
错误链里找到第一个和 target
相等的值并且设置 target
所指向的变量为 err
。
func As(err error, target interface{}) bool {
// target 不能为 nil
if target == nil {
panic("errors: target cannot be nil")
}
val := reflectlite.ValueOf(target)
typ := val.Type()
// target 必须是一个非空指针
if typ.Kind() != reflectlite.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
// 保证 target 是一个接口类型或者实现了 Error 接口
if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
targetType := typ.Elem()
for err != nil{
// 使用反射判断是否可被赋值,如果可以就赋值并且返回 true
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
// 调用 error 自定义的 As 方法,实现自己的类型断言代码
if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
return true
}
// 不断地 Unwrap,一层层的获取嵌套的 error
err = Unwrap(err)
}
return false
}
返回 true
的条件是错误链里的 err
能被赋值到 target
所指向的变量;或者 err
实现的 As(interface{}) bool
方法返回 true
。
前者,会将 err
赋给 target
所指向的变量;后者,由 As
函数来赋值。
如果 target
不是一个指向实现了 error
接口的类型或者其他接口类型的非空的指针的时候,函数会 panic
。