Golang error 的突围

写过 C 的同窗知道,C 语言中经常返回整数错误码(errno)来表示函数处理出错,一般用 -1 来表示错误,用 0 表示正确。html

而在 Go 中,咱们使用 error 类型来表示错误,不过它再也不是一个整数类型,是一个接口类型:git

type error interface {
    Error() string
}
复制代码

它表示那些能用一个字符串就能说清的错误。程序员

咱们最经常使用的就是 errors.New() 函数,很是简单:github

// 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() stringgolang

一般这就够了,它能反映当时“出错了”,可是有些时候咱们须要更加具体的信息,例如:spring

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 implementation's responsibility to summarize the context.bash

它要求返回这个错误的函数要给出具体的“上下文”信息,也就是说,在 Sqrt 函数里,要给出这个负数究竟是什么。网络

因此,若是发现 f 小于 0,应该这样返回错误:app

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 放到函数返回值的最后一个,没什么好说的,你们都这样作,约定俗成。

参考资料【Tony Bai】这篇文章提到,构造 error 的时候,要求传入的字符串首字母小写,结尾不带标点符号,这是由于咱们常常会这样使用返回的 error:

... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)
复制代码

error 的困局

In Go, error handling is important. The language's design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).

在 Go 语言中,错误处理是很是重要的。它从语言层面要求咱们须要明确地处理遇到的错误。而不是像其余语言,类如 Java,使用 try-catch- finally 这种“把戏”。

这就形成代码里 “error” 满天飞,显得很是冗长拖沓。

而为了代码健壮性考虑,对于函数返回的每个错误,咱们都不能忽略它。由于出错的同时,极可能会返回一个 nil 类型的对象。若是不对错误进行判断,那下一行对 nil 对象的操做百分之百会引起一个 panic

这样,Go 语言中诟病最多的就是它的错误处理方式彷佛回到了上古 C 语言时代。

rr := 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 语言的多返回值使得返回错误异常简单。对于真正的异常,Go 提供 panic-recover 机制,也使得代码看起来很是简洁。

固然 Russ Cox 也认可 Go 的错误处理机制对于开发人员的确有必定的心智负担。

参考资料【Go 语言的错误处理机制是一个优秀的设计吗?】是知乎上的一个回答,阐述了 Go 对待错误和异常的不一样处理方式,前者使用 error,后者使用 panic,这样的处理比较 Java 那种错误异常一锅端的作法更有优点。

【如何优雅的在Golang中进行错误处理】对于在业务上如何处理 error,给出了一些很好的示例。

尝试破局

这部分的内容主要来自 Dave cheney GoCon 2016 的演讲,参考资料能够直达原文。

常常听到 Go 有不少“箴言”,说得很顺口,但理解起来并非太容易,由于它们大部分都是有故事的。例如,咱们常说:

Don't communicating by sharing memory, share memory by communicating.

文中还列举了不少,都颇有意思:

go proverbs

下面咱们讲三条关于 error 的“箴言”。

Errors are just values

Errors are just values 的实际意思是只要实现了 Error 接口的类型均可以认为是 Error,重要的是要理解这些“箴言”背后的道理。

做者把处理 error 的方式分为三种:

  1. Sentinel errors
  2. Error Types
  3. 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: %v", err)
	}
	// ……
}
复制代码

readfile 函数里判断 err 不为空,则用 fmt.Errorf 在 err 前加上具体的 file 信息,返回给调用者。返回的 err 其实仍是一个字符串。

形成的后果时,调用者不得不用字符串匹配的方式判断底层函数 readfile 是否是出现了某种错误。当你必需要这样才能判断某种错误时,代码的“坏味道”就出现了。

顺带说一句,err.Error() 方法是给程序员而非代码设计的,也就是说,当咱们调用 Error 方法时,结果要写到文件或是打印出来,是给程序员看的。在代码里,咱们不能根据 err.Error() 来作一些判断,就像上面的 main 函数里作的那样,很差。

Sentinel errors 最大的问题在于它在定义 error 和使用 error 的包之间创建了依赖关系。好比要想判断 err == io.EOF 就得引入 io 包,固然这是标准库的包,还 Ok。若是不少用户自定义的包都定义了错误,那我就要引入不少包,来判断各类错误。麻烦来了,这容易引发循环引用的问题。

所以,咱们应该尽可能避免 Sentinel errors,仅管标准库中有一些包这样用,但建议仍是别模仿。

第二种就是 Error Types,它指的是实现了 error 接口的那些类型。它的一个重要的好处是,类型中除了 error 外,还能够附带其余字段,从而提供额外的信息,例如出错的行数等。

标准库有一个很是好的例子:

// 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 typesSentinel errors 好一些,由于它能承载更多的上下文信息,可是它仍然存在引入包依赖的问题。所以,也是不推荐的。至少,不要把 Error types 做为一个导出类型。

最后一种,Opaque errors。翻译一下,就是“黑盒 errors”,由于你能知道错误发生了,可是不能看到它内部究竟是什么。

譬以下面这段伪代码:

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

做为调用者,调用完 Foo 函数后,只用知道 Foo 是正常工做仍是出了问题。也就是说你只须要判断 err 是否为空,若是不为空,就直接返回错误。不然,继续后面的正常流程,不须要知道 err 究竟是什么。

这就是处理 Opaque errors 这种类型错误的策略。

固然,在某些状况下,这样作并不够用。例如,在一个网络请求中,须要调用者判断返回的错误类型,以此来决定是否重试。这种状况下,做者给出了一种方法:

In this case rather than asserting the error is a specific type or value, we can assert that the error implements a particular behaviour.

就是说,不去判断错误的类型究竟是什么,而是去判断错误是否具备某种行为,或者说实现了某个接口。

来个例子:

type temporary interface {
	Temporary() bool
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}
复制代码

拿到网络请求返回的 error 后,调用 IsTemporary 函数,若是返回 true,那就重试。

这么作的好处是在进行网络请求的包里,不须要 import 引用定义错误的包。

handle not just check errors

这一节要说第二句箴言:“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 转换成错误。这样作破坏了相等性检测,即咱们没法判断错误是不是一种预先定义好的错误了。

应对方案是使用第三方库:github.com/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
复制代码

它是有层次的,很是清晰。而若是咱们用 pkg/errors 库提供的打印函数:

func main() {
	_, err := ReadConfig()
	if err != nil {
		errors.Print(err)
		os.Exit(1)
	}
}
复制代码

能获得更有层次、更详细的错误:

readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory
复制代码

上面讲的是 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

Only handle errors 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)

}
复制代码

w.Write(buf) 会返回两个结果,一个表示写成功的字节数,一个是 error,上面的例子中没有对这两个返回值作任何处理。

下面这个例子却又处理了两次错误:

func Write(w io.Writer, buf []byte) error {
 
	
_, err := w.Write(buf)

	if err != nil {

		// annotated error goes to log file

		log.Println("unable to write:", err)
	

		// unannotated error returned to caller
 return err

		return err
	}

	return nil
}
复制代码

第一次处理是将错误写进了日志,第二次处理则是将错误返回给上层调用者。而调用者也可能将错误写进日志或是继续返回给上层。

这样一来,日志文件中会有不少重复的错误描述,而且在最上层调用者(如 main 函数)看来,它拿到的错误却仍是最底层函数返回的 error,没有任何上下文信息。

使用第三方的 error 包就能够比较完美的解决问题:

func Write(w io.Write, buf []byte) error {

	_, err := w.Write(buf)

	return errors.Wrap(err, "write failed")

}
复制代码

返回的错误,对于人和机器而言,都是友好的。

小结

这一部分主要讲了处理 error 的一些原则,引入了第三方的 errors 包,使得错误处理变得更加优雅。

做者最后给出了一些结论:

  1. errors 就像对外提供的 API 同样,须要认真对待。
  2. 将 errors 当作黑盒,判断它的行为,而不是类型。
  3. 尽可能不要使用 sentinel errors。
  4. 使用第三方的错误包来包裹 error(errors.Wrap),使得它更好用。
  5. 使用 errors.Cause 来获取底层的错误。

胎死腹中的 try 提案

以前已经出现用 “check & handle” 关键字和 “try 内置函数”改进错误处理流程的提案,目前 try 内置函数的提案已经被官方提早拒绝,缘由是社区里一边倒地反对声音。

关于这两个提案的具体内容见参考资料【check & handle】和【try 提案】。

go 1.13 的改进

有一些 Go 语言失败的尝试,好比 Go 1.5 引入的 vendor 和 internal 来管理包,最后被滥用而引起了不少问题。所以 Go 1.13 直接抛弃了 GOPATHvendor 特性,改用 module 来管理包。

柴大在《Go 语言十年而立,Go2 蓄势待发》一文中表示:

好比最近 Go 语言之父之一 Robert Griesemer 提交的经过 try 内置函数来简化错误处理就被否决了。失败的尝试是一个好的现象,它表示 Go 语言依然在一些新兴领域的尝试 —— Go 语言依然处于活跃期。

今年 9 月 3 号,Go 发布 1.13 版本,除了 module 特性转正以外,还改进了数字字面量。比较重要的还有 defer 性能提高 30%,将更多的对象从堆上移动到栈上以提高性能,等等。

还有一个重大的改进发生在 errors 标准库中。errors 库增长了 Is/As/Unwrap三个函数,这将用于支持错误的再次包装和识别处理,为 Go 2 中新的错误处理改进提早作准备。

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.Errorf 增长了 %w 的格式,而且在 error 包增长了三个函数:errors.Unwraperrors.Iserrors.As

fmt.Errorf

使用 fmt.Errorf 加上 %w 格式符来生成一个嵌套的 error,它并无像 pkg/errors 那样使用一个 Wrap 函数来嵌套 error,很是简洁。

Unwrap

func Unwrap(err error) error 复制代码

将嵌套的 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

func Is(err, target error) bool 复制代码

判断 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

func As(err error, target interface{}) bool 复制代码

从 err 错误链里找到和 target 相等的而且设置 target 所指向的变量。

源码以下:

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。

这一部分的内容,飞雪无情大佬的文章【飞雪无情 分析 1.13 错误】写得比较好,推荐阅读。

总结

Go 语言使用 error 和 panic 处理错误和异常是一个很是好的作法,比较清晰。至因而使用 error 仍是 panic,看具体的业务场景。

固然,Go 中的 error 过于简单,以致于没法记录太多的上下文信息,对于错误包裹也没有比较好的办法。固然,这些能够经过第三方库来解决。官方也在新发布的 go 1.13 中对这一块做出了改进,相信在 Go 2 里会有更进一步的优化。

本文还列举了一些处理 error 的示例,例如不要两次处理一个错误,判断错误的行为而不是类型等等。

参考资料里列举了不少错误处理相关的示例,这篇文章做为一个引子。

参考资料

【Go 2 错误提案】go.googlesource.com/proposal/+/…

【check & handle】go.googlesource.com/proposal/+/…

【错误讨论的 issue】github.com/golang/go/i…

【error value 的 FAQ】github.com/golang/go/w…

【error 包】golang.org/pkg/errors/

【飞雪无情的博客 错误处理】www.flysnow.org/2019/01/01/…

【飞雪无情 分析 1.13 错误】www.flysnow.org/2019/09/06/…

【Tony Bai Go语言错误处理】tonybai.com/2015/10/30/…

【Go 官方 error 使用教程】blog.golang.org/error-handl…

【Go FAQ】golang.org/doc/faq#exc…

【ethancai 错误处理】ethancai.github.io/2017/12/29/…

【Dave cheney GoCon 2016 演讲】dave.cheney.net/paste/gocon…

【Morsing's Blog Effective error handling in Go】morsmachine.dk/error-handl…

【如何优雅的在Golang中进行错误处理】www.ituring.com.cn/article/508…

【Go 2 错误处理提案:try 仍是 check?】toutiao.io/posts/uh9qo…

【try 提案】github.com/golang/go/i…

【否决 try 提案】github.com/golang/go/i…

【Go 语言的错误处理机制是一个优秀的设计吗?】www.zhihu.com/question/27…

相关文章
相关标签/搜索