错误处理的四种策略
C++ 之父 Bjarne Stroustrup 说过:”世界上有两类编程语言,一类是总被人抱怨和诟病的,而另一类是无人使用的。“ Go 语言自出生起,就因起简单且看起来有些过时的错误处理机制而被大家所诟病,直至今日这种声音依旧存在。
Go 语言设计者们选择了 C 语言家族的经典错误机制:错误就是值,而错误处理就是基于值比较后的决策。 同时,Go 结合函数/方法的多返回值机制避免了像 C 语言那样在单一函数返回值中承载多重信息的问题。
Go 这种简单的基于错误值比较的错误处理机制使得每个 Go 开发者必须显式地关注和处理每个错误,经过显式处理的代码更为健壮,让更有信心。
Go 中的错误不是异常,它就是普通值,我们不需要额外的语言机制去处理它们,而只需利用已有的语言机制,像处理其他普通类型值一样处理错误。
Go 核心开发团队与 Go 社区已经形成了四种惯用的 Go 错误处理策略。
构造错误值
错误处理的策略与构建错误值的方法是紧密关联的。
错误是值,只是以 error 的接口变量的形式统一呈现。
var err error
err = errors.New("emit macho dwarf: elf header corrupted")
// src/encoding/json
func Marshal(v interface{}) ([]byte, error) {}
func Unmarshal(data []byte, v interface{}) error {}
error 接口是 Go 原生内置的类型,它的定义如下:
type error interface {
Error() string
}
在标准库中,Go 提供了构造错误值的两种基本方法:errors.New
和 fmt.Errorf
。
err := errors.New("emit macho dwarf: elf header corrupted")
errWithCtx = fmt.Errorf("emit macho dwarf: elf header corrupted: %d", i)
wrapErr = fmt.Errorf("emit macho dwarf: elf header corrupted: %w", err)
errors.Is(err, wrapErr) // true
自定义错误类型,我们来看一个 net 包定义了一种携带额外错误上下文的错误类型:
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
透明错误处理策略
Go 语言中的错误处理就是根据函数/方法返回的 error 类型变量中携带的错误值信息做决策并选择后续代码执行路径的过程。
最简单的错误处理策略就是透明错误处理策略,即直接将错误值传递给调用者,由调用者决定如何处理。这也是 Go 语言中最常见的错误处理策略。 80% 以上的错误处理情形可以归类到这种策略下。
err := doSomething()
if err != nil {
return err
}
在这种策略下,由于错误处理并不关心错误值的上下文,因此错误值的构筑方可以直接使用 Go 标准库提供的两个基本错误值构造方法 errors.New
和 fmt.Errorf
。
这个构造出的错误值对错误处理方是透明的。
func doSomething(...) error {
err := doSomethingElse()
if err != nil {
return errors.New("doSomethingElse failed")
}
return nil
}
透明错误处理策略最大限制地减少了错误处理方与错误值构造方之间的耦合关系,它们之间唯一的耦合就是 error 接口变量所规定的契约。
哨兵错误处理策略
如果不能仅根据透明错误值就做出错误处理路径的选取策略,错误处理方会尝试对返回的错误值进行检视,于是就有了下面的模式:
err := doSomething()
if err != nil {
switch err.Error() {
case "file not found":
// do something
return
case "permission denied":
// do something
return
default:
// do something
return
}
}
错误处理方以透明错误值所能提供的唯一上下文信息作为选择错误处理路径的依据。
但是这种模式会造成严重的隐式耦合:错误值构造方不经意间的一次错误描述字符串的改动,都会造成错误处理方的处理行为的变化,并且字符串比较的方式性能也很差。
我们可以采取导出的”哨兵“错误值的方式来辅助错误处理方检视错误值并做出错误处理分支的决策。
var ErrFileNotFound = errors.New("file not found")
var ErrPermissionDenied = errors.New("permission denied")
err := doSomething()
if err != nil {
switch err {
case ErrFileNotFound:
// do something
return
case ErrPermissionDenied:
// do something
return
default:
// do something
return
}
}
// 或者
err := doSomething()
if errors.Is(err, ErrFileNotFound) {
// do something
return
}
if errors.Is(err, ErrPermissionDenied) {
// do something
return
}
推荐使用 errors.Is
。
错误值类型检视策略
我们需要通过自定义错误类型的构造错误值的方式来提供更多的错误上下文信息,并且由于错误值均通过 error 接口变量统一呈现, 要得到底层错误类型携带的错误上下文信息,错误处理方需要使用 Go 提供的类型断言机制(type assertion)或类型选择机制(type switch)。
type MyError struct {
Msg string
File string
Line int
}
func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Msg)
}
func doSomething() error {
return &MyError{"Something happened", "server.go", 42}
}
err := doSomething()
// 类型断言
if err != nil {
if e, ok := err.(*MyError); ok {
fmt.Println(e.File, e.Line, e.Msg)
}
}
// 类型选择
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *MyError:
fmt.Println("error occurred on line:", err.Line)
default:
fmt.Println(err)
}
// errors.As
var e *MyError
if errors.As(err, &e) {
fmt.Println(e.File, e.Line, e.Msg)
}
推荐使用 errors.As
。
错误行为特征检视策略
// src/net/net.go
type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}
下面是 http 包使用错误行为特征检视策略的例子:
// src/net/http/server.go
func (srv *Server) Serve(l net.Listener) error {
...
for {
rw, e := l.Accept()
if e != nil {
if ne, ok := e.(net.Error); ok && ne.Temporary() {
// 这里对临时性错误进行处理
...
time.Sleep(tempDelay)
continue
}
return e
}
...
}
...
}