Go 的错误和异常处理 - 3 error

Go 的设计者认为并非所有的异常都是例外,也不是所有的错误都要使程序崩溃。只要你能从错误中正常恢复,你就应该恢复。这样程序才具有鲁棒性。Go需要程序员为了 error,花费费额外的精力,这也会使得程序员更能将软件写的更加健壮、更加稳定。

如何处理错误

在实际项目开发、运维过程中,会经常碰到如下问题:

  • 函数该如何返回错误,是用值,还是用特殊的错误类型
  • 如何检查被调用函数返回的错误,是判断错误值,还是用类型断言
  • 程序中每层代码在碰到错误的时候,是每层都处理,还是只用在最上层处理,如何做到优雅
  • 日志中的异常信息不够完整、缺少 stack strace,不方便定位错误原因

Go语言中三种错误处理策略

官方在 2011 年曾发布过一篇文章教大家如何处理 error。总结起来范式有三种:

  1. 返回和检查错误值:通过特定值表示成功和不同的错误,上层代码检查错误的值,来判断被调用 func 的执行状态。
  2. 自定义错误类型:通过自定义的错误类型来表示特定的错误,上层代码通过类型断言判断错误的类型。
  3. 隐藏内部细节的错误处理:假设上层代码不知道被调用函数返回的错误任何细节,直接再向上返回错误。

1. 返回和检查错误值

errors.New(str string) 定义错误常量, 让调用方去判断返回的 err 是否等于这个常量, 来进行区分处理。这种策略是最不灵活的错误处理策略,上层代码需要判断返回错误值是否等于特定值。如果想修改返回的错误值,则会破坏上层调用代码的逻辑。

高内聚、低耦合 是衡量公共库质量的一个重要方面,而返回特定错误值的方式,增加了公共库和调用代码的耦合性。让模块之间产生了依赖。

2. 自定义错误类型

自定义 struct type 实现 error 接口, 调用方用类型断言转成特定的 struct type, 拿到更结构化的错误信息。这种方式相比于 返回和检查错误值,很大一个优点在于可以将 底层错误 包起来一起返回给上层,这样可以提供更多的上下文信息。然而,这种方式依然会增加模块之间的依赖。

3. 隐藏内部细节的错误处理

这种策略之所以叫 隐藏内部细节的错误处理,是因为当上层代码碰到错误发生的时候,不知道错误的内部细节。作为上层代码,你需要知道的就是被调用函数是否正常工作。如果你接受这个原则,将极大降低模块之间的耦合性。

最合适的错误处理策略

很明显第三种策略耦合性最低。然而,第三种方式也存在一些问题:

  • 没有详细错误信息,比如 stack trace 帮助定位错误原因
  • 如何优雅的处理错误
    • 有些场景需要了解错误细节,比如网络调用,需要知道是否是瞬时的中断
    • 是否每层捕捉到错误的时候都需要处理
func AuthenticateRequest(r *Request) error {
    err := authenticate(r.User)
    if err != nil {
        return fmt.Errorf("authenticate failed: %v", err)    // authenticate failed: No such file or directory
    }
    return nil
}

这里用 fmt.Errorf(fmt string, args... interface{}) 增加一些上下文信息, 用文字的方式告诉调用方哪里出错了, 让调用方打错误日志出来。

处关于 error 的 「箴言」

为了行为断言错误,而非为了类型 Assert errors for behaviour, not type

在有些场景下,仅仅知道是否出错是不够的。比如,和进程外其它服务通信,需要了解错误的属性,以决定是否需要重试操作。这种情况下,不要判断错误值或者错误的类型,我们可以判断错误是否实现某个行为。

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()
}

这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的三方 package。如果你是底层代码的开发者,哪天你想更换一个实现更好的 error,也不用担心影响上层代码逻辑。如果你是上层代码的开发者,你只需要关注 error 是否实现了特定行为,不用担心引用的三方 package 升级后,程序逻辑失败。

不要忽略错误,也不要重复处理错误 Don’t just check errors, handle them gracefully

遇到错误,而不去处理,导致信息缺失,会增加后期的运维成本。而重复处理,添加了不必要的处理逻辑,导致信息冗余,也会增加后期的运维成本。

func Write(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        log.Println("unable to write:", err)    // 第1次错误处理

        return err
    }
    return nil
}

func main() {
    // create writer and read data into buf

    err := Write(w, buf)
    if err != nil {
        log.Println("Write error:", err)        // 第2次错误处理
        os.Exit(1)
    }

    os.Exit(0)
}

重构代码,减少因为 error 判断而带来的冗余 Only handle errors once

func myHandler(w http.Response, r *http.Request) {

    err := validateRequest(r)
    if err != nil {
        log.Printf("error validating request to myHandler - err: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    user, err := getUserFromRequest(r)
    if err != nil {
        log.Printf("error getting user from request in myHandler - err: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    dataset, err := db.GetUserData(user)
    if err != nil {
        log.Printf("error retrieving user data in myHandler - err: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    buffer := newBuffer()
    err := serialize.UserData(dataset, &buffer)
    if err != nil {
        log.Printf("error serializing user data in myHandler - err %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    err := buffer.WriteTo(w);
    if err != nil {
        log.Printf("error writing buffer to response in myHandler - err %v", err)
        return
    }
}
func myHandler(w http.Response, r *http.Request) {

    var err error
    defer func() {
        if err != nil {
            log.Printf("error in myHandler - error: %v", err)
            w.WriteHeader(http.StatusInternalServerErrror)
        }
    }()

    err = validateRequest(r)
    if err != nil { return }

    user, err := getUserFromRequest(r)
    if err != nil { return  }

    dataset, err := db.GetUserData(user)
    if err != nil { return }

    buffer := newBuffer()
    err = serialize.UserData(dataset, &buffer)
    if err != nil { return }

    err2 := buffer.WriteTo(w)
    if err2 != nil {
        log.Printf("error writing buffer to response in myHandler - error %v", err2)
        return
    }
}

结论

读一下Can new Go errors wrapper replace pkg/errors?Golang Error Handling — Best Practice in 2020,还是使用 pkg/errors,因为:

  • go 1.13 的 error 并不会携带调用栈,只有出错信息。
  • pkg/errors 包的 New, Errorf, Wrap, Wrapf 方法会将调用栈封装上,然后在 %+v 的时候打印出来。
import (
    "fmt"
    "os"

    "github.com/pkg/errors"
)

func main() {
    f, err := os.Open("notes.txt")
    if err != nil {
        err = errors.Wrap(err, "Error opening file")
        fmt.Printf(" %+v", err)
    }
    defer f.Close()
}

错误判定的例子

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

type fileError struct{}

// error 类型是一个接口类型,只要实现了 Error() 方法,就是实现了 error
func (fe *fileError) Error() string {
    return "文件错误"
}

type temporaryError interface {
    Temporary() bool
}

func (fe *fileError) Temporary() bool {
    return true
}

// IsTemporary returns true if err is temporaryError.
func IsTemporary(err error) bool {
    // 在无论何时需要检查错误与特定值或类型相匹配时,都应先用 Cause 功能恢复原始错误
    te, ok := errors.Cause(err).(temporaryError)
    return ok && te.Temporary()
}

func main() {
    err := &fileError{}
    fmt.Println(IsTemporary(err))
}

判断之前先使用 Cause 取出错误,做断言,最后,递归地调用 Temporary 函数。如果错误没实现 temporaryError 接口,就会断言失败,返回 false。

比如:拿到网络请求返回的 error 后,调用 IsTemporary 函数,如果返回 true,那就重试。这么做的好处是在进行网络请求的包里,不需要 import 引用定义错误的包。

pkg/errors

它的使用非常简单,如果我们要新生成一个错误,可以使用 New 函数,其生成的错误自带调用堆栈信息。

func New(message string) error

如果有一个现成的 error,我们需要对他进行再次包装处理,这时候有三个函数可以选择。

// 只附加新的信息
func WithMessage(err error, message string) error

// 只附加调用堆栈信息
func WithStack(err error) error

// 同时附加堆栈和信息 Wrap annotates cause with a message.
func Wrap(err error, message string) error

// 获得最根本的错误原因 Cause unwraps an annotated error.
func Cause(err error) error

goErrorHandlingSample 这个 repo 中的例子演示了,不同错误处理方式,输出的错误信息的区别。

以上的错误我们都包装好了,也收集好了,那么怎么把他们里面存储的堆栈、错误原因等这些信息打印出来呢?其实,这个错误处理库的错误类型,都实现了 Formatter 接口,我们可以通过 fmt.Printf 函数输出对应的错误信息。

%s,%v //功能一样,输出错误信息,不包含堆栈
%q //输出的错误信息带引号,不包含堆栈
%+v //输出错误信息和堆栈

以上如果有循环包装错误类型的话,会递归的把这些错误都会输出。

通过使用这个错误库,我们可以收集更多的信息,可以让我们更容易的定位问题。收集的这些信息不止可以输出到控制台,也可以当做日志,使用输出到相应的 Log 日志里,便于分析问题。

func main() {
    err := a.A()
    if err != nil {
        fmt.Println(PrintMessage(err)) // 打印普通信息
        fmt.Println(PrintStack(err)) // 打印信息附带堆栈信息
        return
    }
    fmt.Println("ok")
}
// 打印普通信息,没有堆栈信息
func PrintMessage(err error) string {
    return err.Error()
}

// 打印详细信息,附带堆栈信息。
func PrintStack(err error) string {
    errMsg := fmt.Sprintf("%+v", err)
    return CleanPath(errMsg)
}

// 脱敏
func CleanPath(s string) string {
    return strings.ReplaceAll(s, GetCurrentPath() + "/", "")
}

// 获取当前项目目录
func GetCurrentPath() string {
    getwd, err := os.Getwd()
    if err != nil {
        return ""
    }
    return strings.Replace(getwd, "\\", "/", -1)
}

扩展

它也可以兼容新的 errors,详见 https://github.com/pkg/errors/pull/206/files。

只要实现了 Unwrap 方法即可。

相关链接

如果觉得我的文章对您有用,请在支付宝公益平台找个项目捐点钱。 @Victor Sep 6, 2020

奉献爱心