写在前面:如果你还没在 error 上栽跟头,那么当你栽了跟头时才会哭着想起来,当年为什么没好好思考和反省错误处理这么一个宏大的话题

关于 Golang 错误处理的实践

Golang 有很多优点,这也是它如此流行的主要原因。但是 Go 1 对错误处理的支持过于简单了,以至于日常开发中会有诸多不便利,遭到很多开发者的吐槽。这些不足催生了一些开源解决方案。与此同时, Go 官方也在从语言和标准库层面作出改进。这篇文章将给出几种常见创建错误的方式并分析一些常见问题,对比各种解决方案,并展示了迄今为止(go 1.13)的最佳实践。

几种创建错误的方式

首先介绍几种常见的创建错误的方法

  1. 基于字符串的错误


err1 := errors.New("math: square root of negative number")
err2 := fmt.Errorf("math: square root of negative number %g", x)
  1. 带有数据的自定义错误

package serr


import (
"fmt"
"github.com/satori/go.uuid"
"log"
"runtime/debug"
"time"
)
// 自定义基础错误类型
type BaseError struct {
InnerError error
Message string
StackTrace string
Misc map[string]interface{}
}


func WrapError(err error, message string, messageArgs ...interface{}) BaseError {
return BaseError{
InnerError: err,
Message: fmt.Sprintf(message, messageArgs),
StackTrace: string(debug.Stack()),
Misc: make(map[string]interface{}),
}
}


func (err *BaseError) Error() string {
// 实现 Error 接口
return err.Message
}


// 具体使用
// "intermediate" module
type IntermediateErr struct {
error
}


func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)
iferr != nil{
return IntermediateErr{wrapError( err,
"cannot run job %q: requisite binaries not available",
id, )}
} else if isExecutable == false {
return wrapError(
nil,
"cannot run job %q: requisite binaries are not executable", id,
)
}
return exec.Command(jobBinPath, "--id="+id).Run()
}

抛出问题

开发中经常需要检查返回的错误值并作相应处理。下面给出一个最简单的示例。

import (
"database/sql"
"fmt"
)


func GetSql() error {
return sql.ErrNoRows
}


func Call() error {
return GetSql()
}


func main() {
err := Call()
if err != nil {
fmt.Printf("got err, %+v\n", err)
}
}
//Outputs:
// got err, sql: no rows in result set

有时需要根据返回的 error 类型作不同处理,例如:

import (
"database/sql"
"fmt"
)


func GetSql() error {
return sql.ErrNoRows
}


func Call() error {
return GetSql()
}


func main() {
err := Call()
if err == sql.ErrNoRows {
fmt.Printf("data not found, %+v\n", err)
return
}
if err != nil {
// Unknown error
}
}
//Outputs:
// data not found, sql: no rows in result set

实践中经常需要为错误增加上下文信息后再返回,以方便调用者了解错误场景。例如 Getcall 方法时常写成:

func Getcall() error {
return fmt.Errorf("GetSql err, %v", sql.ErrNoRows)
}
err==sql.ErrNoRows

解决方案

针对存在的不足,目前有几种解决方案。这些方式可以对错误进行上下文包装,并携带原始错误信息, 还能尽量保留完整的调用栈

方案 1:github.com/pkg/errors

errors.WithStack(err)errors.Wrap(err,"custom message")
  1. Wrap 方法用来包装底层错误,增加上下文文本信息并附加调用栈。一般用于包装对第三方代码(标准库或第三方库)的调用。

  2. WithMessage 方法仅增加上下文文本信息,不附加调用栈。如果确定错误已被 Wrap 过或不关心调用栈,可以使用此方法。注意:不要反复 Wrap ,会导致调用栈重复

  3. Cause 方法用来判断底层错误 。

现在我们用这三个方法来重写上面的代码:

import (
"database/sql"
"fmt"


"github.com/pkg/errors"
)


func GetSql() error {
return errors.Wrap(sql.ErrNoRows, "GetSql failed")
}


func Call() error {
return errors.WithMessage(GetSql(), "bar failed")
}


func main() {
err := Call()
if errors.Cause(err) == sql.ErrNoRows {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
return
}
if err != nil {
// unknown error
}
}
/*Output:
data not found, Call failed: GetSql failed: sql: no rows in result set
sql: no rows in result set
main.GetSql
usr/three/main.go:11
main.Call
usr/three/main.go:15
main.main
usr/three/main.go:19
runtime.main
...
*/

从输出内容可以看到, 使用 %v 作为格式化参数,那么错误信息会保持一行, 其中依次包含调用栈的上下文文本。使用 %+v ,则会输出完整的调用栈详情。如果不需要增加额外上下文信息,仅附加调用栈后返回,可以使用 WithStack 方法:

func GetSql() error {
return errors.WithStack(sql.ErrNoRows)
}

注意:无论是 Wrap , WithMessage 还是 WithStack ,当传入的 err 参数为 nil 时, 都会返回nil, 这意味着我们在调用此方法之前无需作 nil 判断,保持了代码简洁

方案 2:golang.org/x/xerrors

github.com/pkg/errors
import (
"database/sql"
"fmt"


"golang.org/x/xerrors"
)


func Call() error {
if err := GetSql(); err != nil {
return xerrors.Errorf("bar failed: %w", GetSql())
}
return nil
}


func GetSql() error {
return xerrors.Errorf("GetSql failed: %w", sql.ErrNoRows)
}


func main() {
err := Call()
if xerrors.Is(err, sql.ErrNoRows) {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
return
}
if err != nil {
// unknown error
}
}
/* Outputs:
data not found, Call failed: GetSql failed: sql: no rows in result set
bar failed:
main.Call
usr/four/main.go:12
- GetSql failed:
main.GetSql
usr/four/main.go:18
- sql: no rows in result set
*/

与 github.com/pkg/errors 相比,它有几点不足:

  1. 使用 : %w 代替了 Wrap , 看似简化, 但失去了编译期检查。如果没有冒号,或 : %w 不位于于格式化字符串的结尾,或冒号与百分号之间没有空格,包装将失效且不报错;

  2. 而且,调用 xerrors.Errorf 之前需要对参数进行nil判断。这完全没有简化开发者的工作

方案 3:Go 1.13 内置支持

Go 1.13 将 xerrors 的部分功能(不是全部)整合进了标准库。它继承了上面提到的 xerrors 的全部缺点, 并额外贡献了一项。因此目前没有使用它的必要。

import (
"database/sql"
"errors"
"fmt"
)


func Call() error {
if err := GetSql(); err != nil {
return fmt.Errorf("Call failed: %w", GetSql())
}
return nil
}


func GetSql() error {
return fmt.Errorf("GetSql failed: %w", sql.ErrNoRows)
}


func main() {
err := Call()
if errors.Is(err, sql.ErrNoRows) {
fmt.Printf("data not found, %+v\n", err)
return
}
if err != nil {
// unknown error
}
}
/* Outputs:
data not found, Call failed: GetSql failed: sql: no rows in result set
*/
github.com/pkg/errors

Golang 中将来可能的错误处理方式

在 Go2 的草案中,我们看到了有关于 error 相关的一些提案,那就是 check/handle 函数。

我们也许在下一个大版本的 Golang 可以像下面这样处理错误:

import "fmt"
func game() error {
handle err {
return fmt.Errorf("dependencies error: %v", err)
}


resource := check findResource() // return resource, error
defer func() {
resource.Release()
}()


profile := check loadProfile() // return profile, error
defer func() {
profile.Close()
}


// ...
}

感兴趣的同学可以关注下这个提案:https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview.md

得出结论

重要的是要记住,包装错误会使该错误成为 API 的一部分。如果您不想将来将错误作为 API 的一部分来支持,则不应包装该错误。无论是否包装错误,错误文本都将相同。那些试图理解错误的人将得到相同的信息,无论采用哪种方式; 是否要包装错误的选择取决于是否要给程序提供更多信息,以便他们可以做出更明智的决策,还是保留该信息以保留抽象层。

github.com/pkg/errorsgolang.org/x/xerrors

总的来说,Go 在诞生之初就在各个方面表现得相当成熟、稳健。在演进路线上很少出现犹疑和摇摆, 而在错误处理方面却是个例外。除了被广泛吐槽的 if err != nil 之外, 就连其改进路线也备受争议、分歧明显,以致于一个改进提案都会因为压倒性的反对意见而不得不作出调整。好在 Go 团队比以前更加乐于倾听社区意见,团队甚至专门就此问题建了个反馈收集页面。相信最终大家会找到更好的解决方案。



官方资讯*最新技术*独家解读