25 别笑!这就是 Go 的错误处理哲学
C++之父 Bjarne Stroustrup 曾说过:“世界上有两类编程语言,一类是总被人抱怨诟病的,而另外一类是无人使用的”。Go 语言自其出生那天起,就因其简单且看起来有些过时错误处理机制(error handling) 被大家所诟病,直至今天这种声音依旧存在。
Go 语言没有像 C++、Java、Python 等主流编程语言那样提供基于异常(exception)的结构化try-catch-finally
错误处理机制,Go 的设计者们认为将异常耦合到程序控制结构中会导致代码混乱,并且在那样的机制下,程序员会将大多常见错误(例如:无法打开文件等)标记为异常,这与 Go 追求简单的价值观背道而驰。
Go 语言设计者选择了C 语言家族的经典错误机制:错误就是值,而错误处理就是基于值比较后的决策。同时,Go 结合函数/方法的多返回值机制避免了像 C 语言等那样在单一的函数返回值中承载多重信息的问题。比如:C 标准库中的fprintf
函数的返回值就承载了两种含义:在正常情况下,其返回值表示输出到 FILE 流中的字符数量;如果出现错误,则返回值为一个负数,代表错误值。
// stdio.h
int fprintf(FILE * restrict stream, const char * restrict format, ...);
而 Go 标准库中等同功能的fmt.Fprintf
的函数则通过一个独立的表示错误值的返回值变量(如下面代码返回值列表中的 err)避免了上述问题:
// fmt包
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error)
Go 这种简单的基于错误值比较的错误处理机制让每个 Go 开发人员必须显式地去关注和处理每个错误,经过显式错误处理的代码会更为健壮,也会让 Go 开发人员对这些代码更有信心;Go 中的错误不是异常,它就是普通的值,我们不需要额外的语言机制去处理它们,我们只需利用已有的语言机制,像处理其他普通类型值一样的去处理错误。这也决定了这也的错误处理机制让代码更容易调试(就像对待普通变量值那样),也更容易针对每个错误处理的决策分支进行测试覆盖;同时,缺少了try-catch-finally
的异常处理机制也让 Go 代码的可读性更佳。
要写出高质量的 Go 代码,我们需要始终想着错误处理。这些年来,Go 核心开发团队与 Go 社区已经形成了几种惯用的Go 错误处理策略,在本小节中,我就和大家一起来了解和学习一下。
错误处理的策略与构造错误值的方法是有着密切关联的。
错误是值,只是以error
接口变量的形式统一呈现(函数或方法按惯例通常将 error 类型返回值放在返回值列表的末尾):
var err error
err = errors.New("this is a demo error")
// $GOROOT/src/encoding/json
func Marshal(v interface{}) ([]byte, error)
func Unmarshal(data []byte, v interface{}) error
error 接口是 Go 原生内置的类型,它的定义如下:
// $GOROOT/src/builtin/builtin.go
type interface error {
Error() string
}
任何实现了Error() string
方法的类型的实例均可以作为错误赋值给 error 接口变量。
在标准库中,Go 提供了构造错误值的两种基本方法:errors.New
和fmt.Errorf
:
err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
wrapErr = fmt.Errorf("wrap error: %w", err) // 仅Go 1.13及后续版本可用
Go 1.13 版本之前,这两种方法实际上返回的是同一个实现了 error 接口的类型的实例,这个未导出的类型就是errors.errorString
:
// $GOROOT/src/errors/errors.go
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
Go 1.13 及后续版本中,当我们在格式化字符串中使用%w
时,fmt.Errorf
返回的错误值的底层类型为fmt.wrapError
:
// $GOROOT/src/fmt/errors.go (go 1.13及后续版本)
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
和errorString
相比,wrapError
还实现了Unwrap
方法,这使得被wrapError
类型包装的错误值在包装错误链中被检视(inspect)到:
var ErrFoo = errors.New("the underlying error")
err := fmt.Errorf("wrap err: %w", ErrFoo)
errors.Is(err, ErrFoo) // true (仅适用于Go 1.13及后续版本)
我们看到标准库中提供的构建错误值的方法方便有余,但给错误处理者提供的错误上下文(error context)则仅限于以字符串形式呈现的信息(Error 方法返回的信息)。在一些场景下,错误处理者需要从错误值中提取出更多信息以帮助其选择错误处理路径,我们可以自定义错误类型来满足这一需求。比如:标准库中的 net 包就定义了一种携带额外错误上下文的错误类型:
// $GOROOT/src/net/net.go
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
这样错误处理者便可以根据这个类型的错误值提供的额外上下文信息做出错误处理路径的选择,比如下面的代码:
// $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 接口是错误值的提供者与错误值的检视者之间的契约。error 接口的实现者负责提供错误上下文供负责错误处理的代码使用。这种错误上下文与 error 接口类型的分离也体现了 Go 设计哲学中“正交”的理念。
在上述错误值构造的基础上,我们正式来看一下 Go 语言错误处理的几种惯用策略。
Go 语言中的错误处理就是根据函数/方法返回的 error 类型变量中携带的错误值信息做决策并选择后续代码执行路径的过程。这样最简单的错误策略莫过于完全不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径,这也是 Go 语言中最常见的错误处理策略,80%以上的 Go 错误处理情形都可以归类到这种策略下。
err := doSomething()
if err != nil {
// 不关心err变量底层错误值所携带的具体上下文信息
// 执行简单错误处理逻辑并返回
... ...
return err
}
这种策略下由于错误处理方并不关心错误值的上下文,因此,错误值的构造方(如上面的函数doSomething
)可以直接使用 Go 标准库提供的两个基本错误值构造方法errors.New
和fmt.Errorf
构造错误值。这样构造出的错误值对错误处理方是不透明的,因此这种策略被称为 “不透明错误处理策略”。
func doSomething(...) error {
... ...
return errors.New("some error occurred")
}
不透明错误处理策略最大程度地减少了错误处理方与错误值构造方之间的耦合关系,它们之间唯一的耦合就是 error 接口变量所规定的“契约”。
当错误处理方不能仅根据“不透明的错误值”就做出错误处理路径的选取的情况下,错误处理方会尝试对返回的错误值进行检视,于是就有可能出现下面的反模式:
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)错误值并做出错误处理分支的决策:
// $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")
)
// 我们的错误处理代码
data, err := b.Peek(1)
if err != nil {
switch err {
case bufio.ErrNegativeCount:
// ... ...
return
case bufio.ErrBufferFull:
// ... ...
return
case bufio.ErrInvalidUnreadByte:
// ... ...
return
default:
// ... ...
return
}
}
或者:
if err := doSomething(); err == bufio.ErrBufferFull {
// 处理缓冲区满的错误情况
... ...
}
一般“哨兵”错误值变量以 ErrXXX 格式命名。和不透明错误策略相比,“哨兵”策略让错误处理方在有检视错误值的需求时候可以“有的放矢”了。不过对于 API 的开发者而言,暴露“哨兵”错误值也意味着这些错误值和包的公共函数/方法一起成为了 API 的一部分。一旦发布出去,开发者就要对其进行很好的维护。而“哨兵”错误值也让使用这些值的错误处理方对其产生了依赖。
从 Go 1.13 版本开始,标准库 errors 包提供了Is
方法用于错误处理方对错误值进行检视。Is
方法类似于将一个 error 类型变量与“哨兵”错误值的比较:
// 类似 if err == ErrOutOfBounds{ … }
if errors.Is(err, ErrOutOfBounds) {
// 越界的错误处理
}
不同的是如果 error 类型变量的底层错误值是一个包装错误(wrap error),errors.Is
方法会沿着该包装错误所在错误链(error chain),与链上所有被包装的错误(wrapped error)进行比较,直至找到一个匹配的错误。下面是Is
函数应用的一个例子:
// go-error-handling-strategy-1.go
package main
import (
"errors"
"fmt"
)
var ErrSentinel = errors.New("the underlying sentinel error")
func main() {
err1 := fmt.Errorf("wrap err1: %w", ErrSentinel)
err2 := fmt.Errorf("wrap err2: %w", err1)
if errors.Is(err2, ErrSentinel) {
println("err is ErrSentinel")
return
}
println("err is not ErrSentinel")
}
运行上述代码:
$go run go-error-handling-strategy-1.go
err is ErrSentinel
我们看到errors.Is
函数沿着 err2 所在错误链向上找到了被包装到最深处的“哨兵”错误值ErrSentinel
。
因此,如果您使用的是 Go 1.13 及后续版本,请尽量使用errors.Is
方法去检视某个错误值是否是某特定的“哨兵”错误值。
上面我们看到基于 Go 标准库提供的错误值构造方法构造的“哨兵”错误值除了让错误处理方可以“有的放矢”的进行值比较之外,并未提供其他有效的错误上下文信息。如果错误处理方需要错误值提供更多的“错误上下文”,上面的错误处理策略和错误值构造方式将无法满足。
我们需要通过自定义错误类型的构造错误值的方式来提供更多的“错误上下文”信息,并且由于错误值均通过 error 接口变量统一呈现,要得到底层错误类型携带的错误上下文信息,错误处理方需要使用 Go 提供的类型断言机制(type assertion)**或**类型选择机制(type switch),这种错误处理我称之为错误值类型检视策略。我们来看一个标准库中的例子:
json 包中自定义了一个UnmarshalTypeError
的错误类型:
// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
Value string // description of JSON value - "bool", "array", "number -5"
Type reflect.Type // type of Go value it could not be assigned to
Offset int64 // error occurred after reading Offset bytes
Struct string // name of the struct type containing the field
Field string // the full path from root node to the field
}
错误处理方可以通过错误类型检视策略获得更多错误值的错误上下文信息:
// $GOROOT/src/encoding/json/decode_test.go
// 通过类型断言机制获取
func TestUnmarshalTypeError(t *testing.T) {
for _, item := range decodeTypeErrorTests {
err := Unmarshal([]byte(item.src), item.dest)
if _, ok := err.(*UnmarshalTypeError); !ok {
t.Errorf("expected type error for Unmarshal(%q, type %T): got %T",
item.src, item.dest, err)
}
}
}
// $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
}
一般自定义导出的错误类型以XXXError
的形式命名。和“哨兵”错误处理策略一样,错误值类型检视策略由于暴露了自定义的错误类型给错误处理方,因此这些错误类型也和包的公共函数/方法一起成为了 API 的一部分。一旦发布出去,开发者就要对其进行很好的维护。而它们也让借由这些类型进行检视的错误处理方对其产生了依赖。
从 Go 1.13 版本开始,标准库 errors 包提供了As
方法用于错误处理方对错误值进行检视。As
方法类似于通过类型断言判断一个 error 类型变量是否为特定的自定义错误类型:
// 类似 if e, ok := err.(*MyError); ok { … }
var e *MyError
if errors.As(err, &e) {
// 如果err类型为*MyError,变量e将被设置为对应的错误值
}
不同的是如果 error 类型变量的底层错误值是一个包装错误(wrap error),errors.As
方法会沿着该包装错误所在错误链(error chain),与链上所有被包装的错误(wrapped error)的类型进行比较,直至找到一个匹配的错误类型。下面是As
函数应用的一个例子:
// go-error-handling-strategy-2.go
package main
import (
"errors"
"fmt"
)
type MyError struct {
e string
}
func (e *MyError) Error() string {
return e.e
}
func main() {
var err = &MyError{"my error type"}
err1 := fmt.Errorf("wrap err1: %w", err)
err2 := fmt.Errorf("wrap err2: %w", err1)
var e *MyError
if errors.As(err2, &e) {
println("err is a variable of MyError type ")
println(e == err)
return
}
println("err is not a variable of the MyError type ")
}
运行上述代码:
$go run go-error-handling-strategy-2.go
err is the MyError type
true
我们看到errors.As
函数沿着 err2 所在错误链向上找到了被包装到最深处的错误值,并将 err2 与其类型*MyError
成功匹配。
因此,如果您使用的是 Go 1.13 及后续版本,请尽量使用errors.As
方法去检视某个错误值是否是某自定义错误类型的实例。
到这里,我们需要思考一个问题:除了“不透明错误处理策略”,我们是否还有手段可以降低错误处理方与错误值构造方的耦合?在 Go 标准库中,我们发现了这样一种错误处理方式:将某个包中的错误类型归类,统一提取出一些公共的错误行为特征(behaviour),并将这些错误行为特征放入一个公开的接口类型中。以标准库中的net包
为例,它将包内的所有错误类型的公共行为特征抽象并放入net.Error
这个接口中。而错误处理方仅需依赖这个公共接口即可检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策:
// $GOROOT/src/net/net.go
type Error interface {
error
Timeout() bool // 是超时类(timeout)错误吗?
Temporary() bool // 是临时性(temporary)错误吗?
}
下面是 http 包使用错误行为特征检视策略进行错误处理的代码:
// $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
,因此可以被错误处理方通过net.Error
接口的方法判断其行为是否满足 Temporary 或 Timeout 特征:
// $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()
}
Go 社区关于如何进行错误处理的讨论有很多,但唯一正确的结论就是没有某种单一的错误处理策略可以适合所有项目或所有场合。综合上述的构造错误值方法以及错误处理策略,请记住如下几点:
- 请尽量使用“不透明错误”处理策略降低错误处理方与错误值构造方之间的耦合;
- 如果可以通过错误值类型的特征进行错误检视,那么请尽量使用“错误行为特征检视策略”;
- 在上述两种策略无法实施的情况下,再“哨兵”策略和“错误值类型检视”策略;
- Go 1.13 及后续版本中,尽量用
errors.Is
和errors.As
方法替换原先的错误检视比较语句。