You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

481 lines
25 KiB
Markdown

2 years ago
# 22函数怎么结合多返回值进行错误处理
你好我是Tony Bai。
上一节课我们开始了Go函数的学习对Go语言中的函数已经有了基础的了解。那么今天这节课我们要再进一步学习怎么做好函数设计。
在上节课的函数声明部分我们提到多返回值是Go语言函数区别于其他主流静态编程语言中函数的一个重要特点。同时它也是Go语言设计者建构Go语言错误处理机制的基础而错误处理设计也是做函数设计的一个重要环节。
所以今天这节课我们将会从Go语言的错误处理机制入手围绕Go语言错误处理机制的原理、Go错误处理的常见策略来学习一下如何结合函数的多返回值机制进行错误处理的设计。
这会让你建立起Go编码的统一错误处理思维写出更健壮的、让你自己更有信心的Go代码。
要想做好错误处理设计我们首先要先来了解Go语言错误设计的基本思路与原理。
## Go语言是如何进行错误处理的
采用什么错误处理方式,其实是一门编程语言在设计早期就要确定下来的基本机制,它在很大程度上影响着编程语言的语法形式、语言实现的难易程度,以及语言后续的演进方向。
我们前面已经多次提到Go语言继承了“先祖”C语言的很多语法特性在错误处理机制上也不例外Go语言错误处理机制也是在C语言错误处理机制基础上的再创新。
那么这里我们依然从源头讲起先看看前辈C语言的错误处理机制。在C语言中我们通常用一个类型为整型的函数返回值作为错误状态标识函数调用者会基于值比较的方式对这一代表错误状态的返回值进行检视。通常这个返回值为0就代表函数调用成功如果这个返回值是其它值那就代表函数调用出现错误。也就是说函数调用者需要根据这个返回值代表的错误状态来决定后续执行哪条错误处理路径上的代码。
C语言的这种简单的、**基于错误值比较**的错误处理机制有什么优点呢?
首先,它让每个开发人员必须显式地去关注和处理每个错误,经过显式错误处理的代码会更健壮,也会让开发人员对这些代码更有信心。
另外你也可以发现这些错误就是普通的值所以我们不需要用额外的语言机制去处理它们我们只需利用已有的语言机制像处理其他普通类型值一样的去处理错误就可以了这也让代码更容易调试更容易针对每个错误处理的决策分支进行测试覆盖。C语言错误处理机制的这种简单与显式结合的特征和Go语言设计哲学十分契合于是Go语言设计者决定继承C语言这种错误处理机制。
不过C语言这种错误处理机制也有一些弊端。比如由于C语言中的函数最多仅支持一个返回值很多开发者会把这单一的返回值“一值多用”。什么意思呢就是说一个返回值不仅要承载函数要返回给调用者的信息又要承载函数调用的最终错误状态。比如C标准库中的`fprintf`函数的返回值就承载了两种含义。在正常情况下它的返回值表示输出到FILE流中的字符数量但如果出现错误这个返回值就变成了一个负数代表具体的错误值
```plain
// stdio.h
int fprintf(FILE * restrict stream, const char * restrict format, ...);
```
特别是当返回值为其他类型比如字符串的时候我们还很难将它与错误状态融合到一起。这个时候很多C开发人员要么使用输出参数承载要返回给调用者的信息要么自定义一个包含返回信息与错误状态的结构体作为返回值类型。大家做法不一就很难形成统一的错误处理策略。
为了避免这种情况Go函数增加了**多返回值机制**,来支持错误状态与返回信息的分离,并建议开发者把要返回给调用者的信息和错误状态标识,分别放在不同的返回值中。
我们继续以上面C语言中的fprintf函数为例Go标准库中有一个和功能等同的`fmt.Fprintf`的函数这个函数就是使用一个独立的表示错误状态的返回值如下面代码中的err解决了fprintf函数中错误状态值与返回信息耦合在一起的问题
```go
// fmt包
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
```
我们看到在fmt.Fprintf中返回值n用来表示写入io.Writer中的字节个数返回值err表示这个函数调用的最终状态如果成功err值就为nil不成功就为特定的错误值。
另外我们还可以看到fmt.Fprintf函数声明中代表错误状态的变量err的类型并不是一个传统使用的整数类型而是用了一个名为error的类型。
虽然在Go语言中我们依然可以像传统的C语言那样用一个整型值来表示错误状态但**Go语言惯用法**是使用error这个接口类型表示错误并且按惯例我们通常将error类型返回值放在返回值列表的末尾就像fmt.Fprintf函数声明中那样。
那么error接口类型究竟如何表示错误我们又该如何构造一个满足error接口类型的错误值呢我们继续向下看。
## error类型与错误值构造
error接口是Go原生内置的类型它的定义如下
```plain
// $GOROOT/src/builtin/builtin.go
type error interface {
Error() string
}
```
任何实现了error的Error方法的类型的实例都可以作为错误值赋值给error接口变量。那这里问题就来了**难道为了构造一个错误值我们还需要自定义一个新类型来实现error接口吗**
Go语言的设计者显然也想到了这一点他们在标准库中提供了两种方便Go开发者构造错误值的方法 `errors.New`和`fmt.Errorf`。使用这两种方法我们可以轻松构造出一个满足error接口的错误值就像下面代码这样
```plain
err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
```
这两种方法实际上返回的是同一个实现了error接口的类型的实例这个未导出的类型就是`errors.errorString`,它的定义是这样的:
```plain
// $GOROOT/src/errors/errors.go
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
```
大多数情况下使用这两种方法构建的错误值就可以满足我们的需求了。但我们也要看到虽然这两种构建错误值的方法很方便但它们给错误处理者提供的错误上下文Error Context只限于以字符串形式呈现的信息也就是Error方法返回的信息。
但在一些场景下错误处理者需要从错误值中提取出更多信息帮助他选择错误处理路径显然这两种方法就不能满足了。这个时候我们可以自定义错误类型来满足这一需求。比如标准库中的net包就定义了一种携带额外错误上下文的错误类型
```plain
// $GOROOT/src/net/net.go
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
```
这样错误处理者就可以根据这个类型的错误值提供的额外上下文信息比如Op、Net、Source等做出错误处理路径的选择比如下面标准库中的代码
```plain
// $GOROOT/src/net/http/server.go
func isCommonNetReadError(err error) bool {
if err == io.EOF {
return true
}
if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
return true
}
if oe, ok := err.(*net.OpError); ok && oe.Op == "read" {
return true
}
return false
}
```
我们看到上面这段代码利用类型断言判断error类型变量err的动态类型是否为 \*net.OpError或 net.Error。如果err的动态类型是 \*net.OpError那么类型断言就会返回这个动态类型的值存储在oe中代码就可以通过判断它的Op字段是否为"read"来判断它是否为CommonNetRead类型的错误。
不过这里你不用过多了解类型断言Type Assertion到底是什么你只需要知道通过类型断言我们可以判断接口类型的动态类型以及获取它动态类型的值接可以了。后面我们在讲解接口类型的时候还会再细讲。
那么使用error类型而不是传统意义上的整型或其他类型作为错误类型有什么好处呢至少有这三点好处
**第一点:统一了错误类型。**
如果不同开发者的代码、不同项目中的代码甚至标准库中的代码都统一以error接口变量的形式呈现错误类型就能在提升代码可读性的同时还更容易形成统一的错误处理策略。这个我们下面会细讲。
**第二点:错误是值。**
我们构造的错误都是值也就是说即便赋值给error这个接口类型变量我们也可以像整型值那样对错误做“==”和“!=”的逻辑比较,函数调用者检视错误时的体验保持不变。
**第三点:易扩展,支持自定义错误上下文。**
虽然错误以error接口变量的形式统一呈现但我们很容易通过自定义错误类型来扩展我们的错误上下文就像前面的Go标准库的OpError类型那样。
error接口是错误值的提供者与错误值的检视者之间的契约。error接口的实现者负责提供错误上下文供负责错误处理的代码使用。这种错误具体上下文与作为错误值类型的error接口类型的解耦也体现了Go组合设计哲学中“正交”的理念。
到这里我们已经基本了解了Go错误处理机制、统一的错误值类型以及错误值构造方法。在这些基础上我们可以再进一步学习Go语言的几种错误处理的惯用策略学习这些策略将有助于我们提升函数错误处理设计的能力。
## 策略一:透明错误处理策略
简单来说Go语言中的错误处理就是根据函数/方法返回的error类型变量中携带的错误值信息做决策并选择后续代码执行路径的过程。
这样,最简单的错误策略莫过于完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径,比如下面这段代码:
```plain
err := doSomething()
if err != nil {
// 不关心err变量底层错误值所携带的具体上下文信息
// 执行简单错误处理逻辑并返回
... ...
return err
}
```
这也是Go语言中**最常见的错误处理策略**80%以上的Go错误处理情形都可以归类到这种策略下。在这种策略下由于错误处理方并不关心错误值的上下文所以错误值的构造方如上面的函数`doSomething`可以直接使用Go标准库提供的两个基本错误值构造方法`errors.New`和`fmt.Errorf`来构造错误值,就像下面这样:
```plain
func doSomething(...) error {
... ...
return errors.New("some error occurred")
}
```
这样构造出的错误值代表的上下文信息,对错误处理方是透明的,因此这种策略称为**“透明错误处理策略”**。在错误处理方不关心错误值上下文的前提下,透明错误处理策略能最大程度地减少错误处理方与错误值构造方之间的耦合关系。
## 策略二:“哨兵”错误处理策略
当错误处理方不能只根据“透明的错误值”就做出错误处理路径选取的情况下,错误处理方会尝试对返回的错误值进行检视,于是就有可能出现下面代码中的**反模式**
```plain
data, err := b.Peek(1)
if err != nil {
switch err.Error() {
case "bufio: negative count":
// ... ...
return
case "bufio: buffer full":
// ... ...
return
case "bufio: invalid use of UnreadByte":
// ... ...
return
default:
// ... ...
return
}
}
```
简单来说,反模式就是,错误处理方以透明错误值所能提供的唯一上下文信息(描述错误的字符串),作为错误处理路径选择的依据。但这种“反模式”会造成严重的**隐式耦合**。这也就意味着,错误值构造方不经意间的一次错误描述字符串的改动,都会造成错误处理方处理行为的变化,并且这种通过字符串比较的方式,对错误值进行检视的性能也很差。
那这有什么办法吗Go标准库采用了定义导出的Exported“哨兵”错误值的方式来辅助错误处理方检视inspect错误值并做出错误处理分支的决策比如下面的bufio包中定义的“哨兵错误”
```plain
// $GOROOT/src/bufio/bufio.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")
)
```
下面的代码片段利用了上面的哨兵错误,进行错误处理分支的决策:
```plain
data, err := b.Peek(1)
if err != nil {
switch err {
case bufio.ErrNegativeCount:
// ... ...
return
case bufio.ErrBufferFull:
// ... ...
return
case bufio.ErrInvalidUnreadByte:
// ... ...
return
default:
// ... ...
return
}
}
```
你可以看到一般“哨兵”错误值变量以ErrXXX格式命名。和透明错误策略相比“哨兵”策略让错误处理方在有检视错误值的需求时候可以“有的放矢”。
不过对于API的开发者而言暴露“哨兵”错误值也意味着这些错误值和包的公共函数/方法一起成为了API的一部分。一旦发布出去开发者就要对它进行很好的维护。而“哨兵”错误值也让使用这些值的错误处理方对它产生了依赖。
从Go 1.13版本开始标准库errors包提供了Is函数用于错误处理方对错误值的检视。Is函数类似于把一个error类型变量与“哨兵”错误值进行比较比如下面代码
```plain
// 类似 if err == ErrOutOfBounds{ … }
if errors.Is(err, ErrOutOfBounds) {
// 越界的错误处理
}
```
不同的是如果error类型变量的底层错误值是一个包装错误Wrapped Errorerrors.Is方法会沿着该包装错误所在错误链Error Chain)与链上所有被包装的错误Wrapped Error进行比较直至找到一个匹配的错误为止。下面是Is函数应用的一个例子
```plain
var ErrSentinel = errors.New("the underlying sentinel error")
func main() {
err1 := fmt.Errorf("wrap sentinel: %w", ErrSentinel)
err2 := fmt.Errorf("wrap err1: %w", err1)
println(err2 == ErrSentinel) //false
if errors.Is(err2, ErrSentinel) {
println("err2 is ErrSentinel")
return
}
println("err2 is not ErrSentinel")
}
```
在这个例子中我们通过fmt.Errorf函数并且使用%w创建包装错误变量err1和err2其中err1实现了对ErrSentinel这个“哨兵错误值”的包装而err2又对err1进行了包装这样就形成了一条错误链。位于错误链最上层的是err2位于最底层的是ErrSentinel。之后我们再分别通过值比较和errors.Is这两种方法判断err2与ErrSentinel的关系。运行上述代码我们会看到如下结果
```plain
false
err2 is ErrSentinel
```
我们看到通过比较操作符对err2与ErrSentinel进行比较后我们发现这二者并不相同。而errors.Is函数则会沿着err2所在错误链向下找到被包装到最底层的“哨兵”错误值`ErrSentinel`。
所以如果你使用的是Go 1.13及后续版本,我建议你尽量使用`errors.Is`方法去检视某个错误值是否就是某个预期错误值,或者包装了某个特定的“哨兵”错误值。
## 策略三:错误值类型检视策略
上面我们看到基于Go标准库提供的错误值构造方法构造的“哨兵”错误值除了让错误处理方可以“有的放矢”的进行值比较之外并没有提供其他有效的错误上下文信息。那如果遇到错误处理方需要错误值提供更多的“错误上下文”的情况上面这些错误处理策略和错误值构造方式都无法满足。
这种情况下我们需要通过自定义错误类型的构造错误值的方式来提供更多的“错误上下文”信息。并且由于错误值都通过error接口变量统一呈现要得到底层错误类型携带的错误上下文信息错误处理方需要使用Go提供的**类型断言机制**Type Assertion或**类型选择机制**Type Switch这种错误处理方式我称之为**错误值类型检视策略**。
我们来看一个标准库中的例子加深下理解这个json包中自定义了一个`UnmarshalTypeError`的错误类型:
```plain
// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
Value string
Type reflect.Type
Offset int64
Struct string
Field string
}
```
错误处理方可以通过错误类型检视策略获得更多错误值的错误上下文信息下面就是利用这一策略的json包的一个方法的实现
```plain
// $GOROOT/src/encoding/json/decode.go
func (d *decodeState) addErrorContext(err error) error {
if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
switch err := err.(type) {
case *UnmarshalTypeError:
err.Struct = d.errorContext.Struct.Name()
err.Field = strings.Join(d.errorContext.FieldStack, ".")
return err
}
}
return err
}
```
我们看到这段代码通过类型switch语句得到了err变量代表的动态类型和值然后在匹配的case分支中利用错误上下文信息进行处理。
这里,一般自定义导出的错误类型以`XXXError`的形式命名。和“哨兵”错误处理策略一样,错误值类型检视策略,由于暴露了自定义的错误类型给错误处理方,因此这些错误类型也和包的公共函数/方法一起成为了API的一部分。一旦发布出去开发者就要对它们进行很好的维护。而它们也让使用这些类型进行检视的错误处理方对其产生了依赖。
从Go 1.13版本开始标准库errors包提供了`As`函数给错误处理方检视错误值。`As`函数类似于通过类型断言判断一个error类型变量是否为特定的自定义错误类型如下面代码所示
```plain
// 类似 if e, ok := err.(*MyError); ok { … }
var e *MyError
if errors.As(err, &e) {
// 如果err类型为*MyError变量e将被设置为对应的错误值
}
```
不同的是如果error类型变量的动态错误值是一个包装错误`errors.As`函数会沿着该包装错误所在错误链与链上所有被包装的错误的类型进行比较直至找到一个匹配的错误类型就像errors.Is函数那样。下面是`As`函数应用的一个例子:
```plain
type MyError struct {
e string
}
func (e *MyError) Error() string {
return e.e
}
func main() {
var err = &MyError{"MyError error demo"}
err1 := fmt.Errorf("wrap err: %w", err)
err2 := fmt.Errorf("wrap err1: %w", err1)
var e *MyError
if errors.As(err2, &e) {
println("MyError is on the chain of err2")
println(e == err)
return
}
println("MyError is not on the chain of err2")
}
```
运行上述代码会得到:
```plain
MyError is on the chain of err2
true
```
我们看到,`errors.As`函数沿着err2所在错误链向下找到了被包装到最深处的错误值并将err2与其类型 `* MyError`成功匹配。匹配成功后errors.As会将匹配到的错误值存储到As函数的第二个参数中这也是为什么`println(e == err)`输出true的原因。
所以如果你使用的是Go 1.13及后续版本,请尽量使用`errors.As`方法去检视某个错误值是否是某自定义错误类型的实例。
## 策略四:错误行为特征检视策略
不知道你注意到没有,在前面我们已经讲过的三种策略中,其实只有第一种策略,也就是“透明错误处理策略”,有效降低了错误的构造方与错误处理方两者之间的耦合。虽然前面的策略二和策略三,都是我们实际编码中有效的错误处理策略,但其实使用这两种策略的代码,依然在错误的构造方与错误处理方两者之间建立了耦合。
那么除了“透明错误处理策略”外,我们是否还有手段可以降低错误处理方与错误值构造方的耦合呢?
在Go标准库中我们发现了这样一种错误处理方式**将某个包中的错误类型归类,统一提取出一些公共的错误行为特征,并将这些错误行为特征放入一个公开的接口类型中**。这种方式也被叫做错误行为特征检视策略。
以标准库中的`net包`为例,它将包内的所有错误类型的公共行为特征抽象并放入`net.Error`这个接口中,如下面代码:
```plain
// $GOROOT/src/net/net.go
type Error interface {
error
Timeout() bool
Temporary() bool
}
```
我们看到net.Error接口包含两个用于判断错误行为特征的方法Timeout用来判断是否是超时Timeout错误Temporary用于判断是否是临时Temporary错误。
而错误处理方只需要依赖这个公共接口,就可以检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策。
这里我们再看一个http包使用错误行为特征检视策略进行错误处理的例子加深下理解
```plain
// $GOROOT/src/net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
... ...
for {
rw, e := l.Accept()
if e != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
if ne, ok := e.(net.Error); ok && ne.Temporary() {
// 注:这里对临时性(temporary)错误进行处理
... ...
time.Sleep(tempDelay)
continue
}
return e
}
...
}
... ...
}
```
在上面代码中Accept方法实际上返回的错误类型为`*OpError`它是net包中的一个自定义错误类型它实现了错误公共特征接口`net.Error`,如下代码所示:
```plain
// $GOROOT/src/net/net.go
type OpError struct {
... ...
// Err is the error that occurred during the operation.
Err error
}
type temporary interface {
Temporary() bool
}
func (e *OpError) Temporary() bool {
if ne, ok := e.Err.(*os.SyscallError); ok {
t, ok := ne.Err.(temporary)
return ok && t.Temporary()
}
t, ok := e.Err.(temporary)
return ok && t.Temporary()
}
```
因此OpError实例可以被错误处理方通过`net.Error`接口的方法判断它的行为是否满足Temporary或Timeout特征。
## 小结
好了今天的课讲到这里就结束了。在这一讲中我们重点讲解了Go函数设计中的一个重要环节**错误处理设计**,希望通过这节课的内容,能帮助你建立起代码设计的意识,提高函数设计的水平。
Go语言继承了C语言的基于值比较的错误处理机制但又在C语言的基础上做出了优化也就是说Go函数通过支持多返回值消除了C语言中将错误状态值与返回给函数调用者的信息耦合在一起的弊端。
Go语言还统一错误类型为error接口类型并提供了多种快速构建可赋值给error类型的错误值的函数包括errors.New、fmt.Errorf等我们还讲解了使用统一error作为错误类型的优点你要深刻理解这一点。
基于Go错误处理机制、统一的错误值类型以及错误值构造方法的基础上Go语言形成了多种错误处理的惯用策略包括透明错误处理策略、“哨兵”错误处理策略、错误值类型检视策略以及错误行为特征检视策略等。这些策略都有适用的场合但**没有某种单一的错误处理策略可以适合所有项目或所有场合**。
在错误处理策略选择上,我有一些个人的建议,你可以参考一下:
* 请尽量使用“透明错误”处理策略,降低错误处理方与错误值构造方之间的耦合;
* 如果可以通过错误值类型的特征进行错误检视,那么请尽量使用“错误行为特征检视策略”;
* 在上述两种策略无法实施的情况下,再使用“哨兵”策略和“错误值类型检视”策略;
* Go 1.13及后续版本中,尽量用`errors.Is`和`errors.As`函数替换原先的错误检视比较语句。
## 思考题
这节课我们列出了一些惯用的错误处理策略当然Go社区关于错误处理策略的讨论可能不止这些你还见过哪些比较实用的错误处理策略吗不妨在留言区和我们探讨一下吧。
欢迎你把这节课分享给更多对Go语言的错误处理机制感兴趣的朋友。我是Tony Bai我们下节课见。