错误处理

You only need to check the error value if you care about the result.

设计意图

Go 的处理异常逻辑是不引入 exception,支持多参数返回,所以很容易的在函数签名中带上实现了 error interface 的对象,交由调用者来判定。

如果一个函数返回了 (value, error),不能对这个 value 做任何假设,必须先判定 error。唯一可以忽略 error 的是连 value 也不关心。

Go 中有 panic 的机制,如果认为和其他语言的 exception 一样,那就错了。当我们抛出异常的时候,相当于把 exception 扔给了调用者来处理。

比如,在 C++ 中,把 string 转为 int,如果转换失败,会抛出异常。或者在 java 中转换 string 为 date 失败时,会抛出异常。 Go panic 意味着 fatal error(就是挂了)。不能假设调用者来解决 panic,意味着代码不能继续运行。

使用多个返回值和一个简单的约定,Go 解决了让程序员知道什么时候出了问题,并为真正的异常情况保留了 panic。

error

错误处理的核心要点是 Warp,将错误包装起来,就像堆栈信息一样,最后在最上层统一打印日志,即错误信息。

error类型是一个接口类型,是Go语言內建类型,在这个接口类型的声明中只包含一个方法Error(),这个方法不接受任何参数,但是会返回一个string类型的结果。

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string    // 返回错误信息的字符串形式
}

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
    return &errorString{text}    
    // 内存中只存在一份相同的字符串
    // 比较结构体时是直接比较结构体中每个字段的值是否相等
}

// errorString is a trivial implementation of error.
type errorString struct {    // 新建错误时是一个新的对象返回新的地址
    s string    
}

func (e *errorString) Error() string {
    return e.s
}

使用error类型的方式,通常是在函数声明的结果列表的最后,声明一个error类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为nil”。

package main

import (
    "errors"
    "fmt"
)

// 如下处理的好处:
// 简单
// 考虑失败,而不是成功(plan for failure,not success)
// 没有隐藏的控制流
// 完全交给业务代码来控制error
// Error are values
func echo(request string) (response string, err error) {
    if request == "" { // 卫述语句
        err = errors.New("empty request")
        return
    }
    response = fmt.Sprintf("echo: %s", request)
    return
}

func main() {
    for _, req := range []string{"", "hello!"} {
        fmt.Printf("request: %s\n", req)
        resp, err := echo(req)
        if err != nil { // 卫述语句
            fmt.Printf("error: %s\n", err)
            continue
        }
        // 无错误的正常流程代码,将成为一条直线,而不是缩进的代码。
        fmt.Printf("response: %s\n", resp)
    }
}

注意点:

  1. 在进行错误处理的时候,经常会用到卫述语句

  2. 在生成error类型值的时候用到errors.New()函数,这是一种最基本的生成错误值的方式。调用它时传入一个字符串代表错误信息,返回一个包含这个错误信息的error类型值。该值的静态类型是error,动态类型是一个errors包中,包级私有的类型*errorString

errorString类型拥有的一个指针方法实现了errors接口中的Error()方法。这个方法被调用后,会原封不动地返回之前传入的错误信息,实际上,error类型值的Error()方法就相当于其他类型值的String方法。

在上述例子中,fmt.Printf()函数发现被打印的是一个error类型,就会调用它的Error()方法。在fmt包中,这类打印函数都是这么做的。

当通过模板化的方式生成错误信息,并得到错误值时,可以使用fmt.Errorf()函数,该函数其实是先调用fmt.Sprintf()函数,得到确切的错误信息,在调用errors.New()函数,得到该错误信息的error类型值,最后返回该值。

Sentinel error

预定义的特定错误,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 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")
)

使用 sentinel 值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查。

甚至是一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。

不应该依赖检测 error.Error 的输出,Error 方法存在于 error 接口主要用于方便程序员使用,但不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout 等。

预定义错误存在的问题:

  • Sentinel errors 成为 API 公共部分。

如果公共函数或方法返回一个特定值的错误,那么该值必须是公共的,当然要有文档记录,这会增加 API 的表面积。

如果 API 定义了一个返回特定错误的 interface,则该接口的所有实现都将被限制为仅返回该错误,即使它们可以提供更具描述性的错误。

比如 io.Reader。像 io.Copy 这类函数需要 reader 的实现者比如返回 io.EOF 来告诉调用者没有更多数据了,但这又不是错误。

  • Sentinel errors 在两个包之间创建了依赖。

sentinel errors 最糟糕的问题是它们在两个包之间创建了源代码依赖关系。例如,检查错误是否等于 io.EOF,您的代码必须导入 io 包。这个特定的例子听起来并不那么糟糕,因为它非常常见,但是想象一下,当项目中的许多包导出错误值时,存在耦合,项目中的其他包必须导入这些错误值才能检查特定的错误条件(in the form of an import loop)。

结论: 尽可能避免在编写的代码中使用 sentinel errors。在标准库中有一些使用它们的情况,但这不是一个应该模仿的模式

Error types

Error type 是实现了 error 接口的自定义类型。例如 MyError 类型记录了文件和行号以展示发生了什么。与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。

type MyError struct {
    Msg string
    File string
    Line int
}

func (e *MyError) Error() string {
    return fmt.Sptintf("%s:%d: %s", e.File, e.Line, e.Message)
}

func main() {
// 使用方式一
// 因为 MyError 是一个 type,调用者可以使用断言转换成这个类型,来获取更多的上下文信息。 
err := &MyError{}
swtich err := err.(type) {
    case nil:
    // call succeded
    case *MyError:
    // TODO
    default:
    // unknow error
}

// 使用方式二
// 直接进行类型断言
err.(MyError)
}

自定义错误类型存在的问题:

  • 调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

  • 结论是尽量避免使用 error types,虽然错误类型比 sentinel errors 更好,因为它们可以捕获关于出错的更多上下文,但是 error types 共享 error values 许多相同的问题。

因此,建议是避免错误类型,或者至少避免将它们作为公共 API 的一部分。

Opaque error

这是最灵活的错误处理策略,因为它要求代码和调用者之间的耦合最少。因为虽然知道发生了错误,但没有能力看到错误的内部。作为调用者,关于操作的结果,所知道的就是它起作用了,或者没有起作用(成功还是失败)。

这就是不透明错误处理的全部功能–只需返回错误而不假设其内容。

import "github.com/quux/bar"

func fn() error {
    x,err := bar.Foo()
    if err != nil {
        return  err
    }

    // TODO: use x
}

不透明错误存在的问题:

  • 无法携带上下文信息。

  • 在少数情况下,这种二分错误处理方法是不够的。例如,与进程外的世界进行交互(如网络活动),需要调用方调查错误的性质,以确定重试该操作是否合理。在这种情况下,可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。如下示例。

type temporary interface {
    Temporary() bool
}

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
    te, ok := err.(temporary)
    return ok && te.Temporary()
}

// 这里的关键是,这个逻辑可以在不导入定义错误的包,
// 或者实际上不了解 err 的底层类型的情况下实现只对它的行为感兴趣。

错误值类型

因为error是一个接口类型,所以即使同为error类型的错误值,他们的实际类型也可能不同。

  1. 对于类型范围已知的错误值:使用类型断言表达式或switch语句来判断

  2. 对于已有相应变量且类型相同的错误值:直接使用判等操作来判断

  3. 对于没有相应变量且类型未知的错误值:只能使用错误信息的字符串表示形式来判断

类型范围已知

类型在已知范围内的错误值是最容易分辨的。如os包中的几个代表错误类型:

  • os.PathError

  • os.LinkError

  • os.SyccallError

  • os/exec.Error

它们的指针类型都是error接口的实现类型,同时它们也都包含了一个名叫Err,类型为error接口类型的代表潜在错误的字段。如果得到一个error类型值,并且知道该值的实际类型肯定是它们中的某一个,那么就用switch语句去判断:

func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}
// 只要类型不同,就可以使用这种方式来判断,但是如果错误值类型相同,那么这种方式就无效了

已有相应变量且类型相同

如os包中不少错误类型都是通过调用errors.New()函数来初始化:

  • os.ErrClosed

  • os.ErrInvalid

  • os.ErrPermission

这几个都是已经定义好的,确切的错误值。os包中的代码有时候会把他们当做潜在的错误值,封装进前面那些错误类型的值中

如果在操作文件系统的时候得到一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那就可以用普通的switch或者if和判等语句去做判断:

// 接受error类型的参数值,该值代表某个文件操作相关的错误
printError := func(i int, err error) {
    if err == nil {
        fmt.Println("nil error")
        return
    }
    err = underlyingError(err)
    switch err {
    case os.ErrClosed:
        fmt.Printf("error(closed)[%d]: %s\n", i, err)
    case os.ErrInvalid:
        fmt.Printf("error(invalid)[%d]: %s\n", i, err)
    case os.ErrPermission:
        fmt.Printf("error(permission)[%d]: %s\n", i, err)
    }
}
// 虽然不知道这些错误值的类型范围,但却知道它们或它们潜在的错误值一定在某个已知的os包中定义的值

没有相应变量且类型未知

如果对于一个错误值可能代表的含义知之甚少,那么只能通过它拥有的错误信息去判断了

通过错误值的Error方法拿到它的错误信息。在os包中有os.IsExitos.IsNotExitos.IsPermission函数来判断。

错误值体系

构建错误值体系的基本方式有两种:

  1. 创建立体的错误类型体系

  2. 创建扁平的错误值列表

在Go语言中实现接口都是非侵入式的,所以可以做的非常灵活。

比如在net包中,有一个名为Error的接口类型,它算是內建接口类型error的一个扩展接口,因为error是net.Error的嵌入接口。

type Error interface {
    error
    Timeout() bool   // Is the error a timeout?
    Temporary() bool // Is the error temporary?
}

net包中有很多错误类型都实现了net.Error接口,如:

  • *net.OpError

  • *net.AddrError

  • net.UnknownNetworkError

把错误类型想象成一棵树,内建接口error就是树根,net.Error接口就是一个在根上延伸的第一级非叶子节点。

用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。

如果不希望包外代码改动返回错误值的话,一定要小写其中字段的名称首字母。通过暴露某些方法让包外代码有进一步获取错误信息的权限,比如编写一个可以返回包级私有的err字段值的公开方法Err。

相对于立体的错误类型体系,扁平的错误列表值简单很多。只是想要先创建一些代表已知错误的错误值的时候,用扁平化的方式很恰当

handle error

  1. 缩进:将err!=nil直接return

  2. 内部函数返回值与外部函数相同时,直接return内部函数即可

  3. 熟悉基础库,使用合适的库函数,枚举遍历的情况,参考bufio库中func (s *Scanner) Scan() bool方法的实现。

  4. 一段逻辑中有多次err判断的情况,定义一个结构体,其中包含一个error字段,每次处理之后将错误保存再结构体中的error字段中,下次处理之前,先判断error字段是否为nil,类似递归的退出条件,再处理逻辑开始前先判断。

warp error

调用某个函数出错,将错误返回给调用方,调用者可能也会继续上抛,依此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件中,打印出来的只是:没有这样的文件或目录。这样的话,没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将被迫进行长时间的代码分割,以发现是哪个代码路径触发了文件未找到错误。

fmt.Errorf增加上下文信息,没有文件名和行号,这种模式与 sentinel errors 或 type assertions 的使用不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后将其转换回 fmt.Errorf 破坏了原始错误,导致等值判定失败。

只应该处理异常一次,打印日志也算处理,包装日志也算处理错误,所以包装之后直接返回错误即可,不用打日志了

Go 中的错误处理契约规定,在出现错误的情况下,不能对其他返回值的内容做出任何假设。由于 JSON 序列化失败,buf 的内容是未知的,可能它不包含任何内容,但更糟糕的是,它可能包含一个半写的 JSON 片段。

在微服务中,调用远程服务失败进行服务降级时,不但要处理error,和error一起返回的内容未知的返回值也要处理,比如设置为默认值,或者设置为降级后的数据等。

日志记录与错误无关且对调试没有帮助的信息应被视为噪音,应予以质疑。记录的原因是因为某些东西失败了,而日志包含了答案

日志记录的理念:

  • 错误要被日志记录。

  • 应用程序处理错误,保证100%完整性,也就是把与error一起返回的值也处理掉。

  • 之后不再报告当前错误。

推荐使用github.com/pkg/errors库来包装错误,向错误值添加上下文,这种方式既可以由人也可以由机器检查。

最佳实践

  1. 在应用代码(不是基础库或者框架)中,使用 errors.New 或者 errros.Errorf 返回错误。

  2. 如果调用项目或者包内的其他函数,通常直接返回,如果warp,堆栈信息就是两倍了。

  3. 如果和其他的库(通过import引入的库,包括标准库、公司基础库、github开源库)进行协作,考虑使用 errors.Wrap 或者 errors.Wrapf 保存堆栈信息。

  4. 直接返回错误,而不是每个错误产生的地方到处打日志。在程序的顶部或者是工作的 goroutine 顶部(请求入口),使用 %+v 把堆栈详情记录。

  5. 使用 errors.Cause 获取 root error,再进行和 sentinel error 判定。

总结:

  • 选择 wrap error 的只能是业务代码。具有最高可重用性的包(基础库或者框架)只能返回根错误值。此机制与 Go 标准库中使用的相同(kit 库的 sql.ErrNoRows)。

  • 如果函数/方法不打算处理错误,那么用足够的上下文 wrap errors 并将其返回到调用堆栈中。例如,额外的上下文可以是使用的输入参数或失败的查询语句。确定您记录的上下文是足够多还是太多的一个好方法是检查日志并验证它们在开发期间是否为您工作。

  • 一旦确定函数/方法将处理错误,错误就不再是错误。如果函数/方法仍然需要发出返回,则它不能返回错误值。它应该只返回零(比如降级处理中,你返回了降级数据,然后需要 return nil)。

go 1.13的改进

没有实现携带堆栈信息,所以和pkg/errors包一起使用,第一次遇到错误时,使用errors的warp包装,后续地方直接返回错误,最顶层对错误进行处理。

go1.13为 errorsfmt 标准库包引入了新特性,以简化处理包含其他错误的错误。其中最重要的是: 包含另一个错误的 error 可以实现返回底层错误的 Unwrap 方法

如果 e1.Unwrap() 返回 e2,那么 e1 包装 e2,可以展开 e1 以获得 e2。

按照此约定,可以为自定义的 QueryError 类型指定一个 Unwrap 方法func(e *QueryError) Unwarp() error { return e.Err },该方法返回其包含的错误。

go1.13 errors 库包含两个用于检查错误的新函数:IsAs

// 注意是%w,将错误格式化,这样Is和As就可以使用根因
errors.Unwrap(fmt.Errorf("... %w ...", ..., err, ...))

---
if errors.Is(err, os.ErrExist)
// is preferable to 
if err == os.ErrExist

---
var perr *os.PathError
if errors.As(err, &perr) {
    fmt.Println(perr.Path)
}
// is preferable to 
if perr, ok := err.(*os.PathError); ok {
    fmt.Println(perr.Path)
}

panic

对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,才使用 panic。对于其他的错误情况,应该是期望使用 error 来进行判定。

通常不能在业务代码中使用panic,panic就意味着程序挂了,因此标准库或者Web中间件中需要使用recover()panic捕获,同时将异常信息打印出来,对于HTTP请求直接返回5xx错误。

go func(){}启动的野生goroutine是无法被main中的recover()捕获住。正确的做法如下所示。

package sync

// 新的goroutine都使用sync.Go(x)来执行
func Go(x func()) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                // panic被捕获
            }
        }() 
        x()
    }()
}

// 接收外部请求
type Message struct {}

// 通常有一个线程池,通道大小可以和线程池大小相等
ch := make(chan struct{},10)

// 将外部请求发送到通道中
ch <- &Message{}


// 从线程池中取一个线程来处理,一个请求创建一个线程不太好
x := func(c chan struct{}){
    req:= <-c
    // 处理请求
}

sync.Go(x(ch))

panic不捕获直接退出的情况:

  1. main启动过程中的初始化等强依赖的情况

  2. 配置文件中的值太过于异常的情况

弱依赖的情况:MySQL不能访问,Redis能访问,服务读多写少,这个时候可以不用panic后退出,具体情况取决于业务逻辑,对于弱依赖增加超时,如10s,超时后依然无法依赖上再panic退出

最后更新于

这有帮助吗?