基础知识

Go 语言的错误处理思想及设计包含以下特征:

  • 一个可能造成错误的函数,需要返回值中返回一个错误接口 error,如果调用是成功的,错误接口将返回 nil,否则返回错误。
  • 在函数调用后需要检查错误,如果发生错误
    • error 立马被处理或者被忽略
    • 返回给调用者处,从而你可以跟踪 error 的路径信息

Go 语言通过内置的错误接口提供了非常简单的错误处理机制。

error 类型是一个接口类型,这是它的定义:

// 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
}

它只有一个方法 Error,只要实现了这个方法,就是实现了error。我们可以在编码中通过实现 error 接口类型来生成错误信息。

  • Go 语言希望开发者将错误处理视为正常开发必须实现的环节。
  • 函数通常在最后的返回值中返回错误信息。
  • 在 Go 语言中使用 errors 包进行错误的定义,errors.New 可返回一个错误信息:
func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // 实现
}

错误处理

在下面的例子中,我们在调用 Sqrt 的时候传递的一个负数,然后就得到了 non-nil 的 error 对象,将此对象与 nil 比较,结果为 true,所以 fmt.Println fmt包在处理 error 时会调用 Error 方法 被调用,以输出错误,请看下面调用的示例代码:

result, err:= Sqrt(-1)

if err != nil {
    fmt.Println(err)
}

自定义 error

首先我们定义 fileError 类型,然后实现 error 接口。

type fileError struct {
}

func (fe *fileError) Error() string {
   return "文件错误"
}

//只是模拟一个错误
func openFile() ([]byte, error) {
    return nil, &fileError{}
}

在实际项目中,每个错误的信息提示都不一样,再修改一下,让错误文字可以设置。

type fileError struct {
	s string
}

func (fe *fileError) Error() string {
	return fe.s
}

//只是模拟一个错误
func openFile() ([]byte, error) {
	return nil, &fileError{"文件错误,自定义"}
}

接下来继续修改,把错误类型的名字改一下,再创建一个辅助函数,以便我们创建不同的错误类型。

type errorString struct {
	s string
}

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

func New(text string) error {
	return &errorString{text}
}

//只是模拟一个错误
func openFile() ([]byte, error) {
	return nil, New("文件错误,自定义")
}

实例

使用 errors.New 定义的错误字符串的错误类型无法提供丰富的错误信息,需要借助自定义结构体实现错误接口。所有符合 Error() string 格式的方法,都能实现错误接口。

package main

import (
    "fmt"
)

// 定义一个 DivideError 结构
type DivideError struct {
    dividee int
    divider int
}

// 实现 `error` 接口
func (de *DivideError) Error() string {
    strFormat := `
    Cannot proceed, the divider is zero.
    dividee: %d
    divider: 0
`
    return fmt.Sprintf(strFormat, de.dividee)
}

// 定义 `int` 类型除法运算的函数
func Divide(varDividee int, varDivider int) (result int, errorMsg string) {
    if varDivider == 0 {
            dData := DivideError{
                    dividee: varDividee,
                    divider: varDivider,
            }
            errorMsg = dData.Error()
            return
    } else {
            return varDividee / varDivider, ""
    }

}

func main() {

    // 正常情况
    if result, errorMsg := Divide(100, 10); errorMsg == "" {
            fmt.Println("100/10 = ", result)
    }
    // 当被除数为零的时候会返回错误信息
    if _, errorMsg := Divide(100, 0); errorMsg != "" {
            fmt.Println("errorMsg is: ", errorMsg)
    }

}

执以上程序,输出结果为:

100/10 =  10
errorMsg is:
    Cannot proceed, the divider is zero.
    dividee: 100
    divider: 0

如何处理错误

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

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

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

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

1. 2. 3.

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

返回和检查错误值

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

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

自定义错误类型

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

隐藏内部细节的错误处理

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

最合适的错误处理策略

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

  • 没有详细错误信息,比如 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{}) 增加一些上下文信息, 用文字的方式告诉调用方哪里出错了, 让调用方打错误日志出来。

优雅的处理错误

Rails 中 model.save! 这样的方法可能抛出异常,而实际上什么时候会发生异常,发生在什么具体的位置,也就只能听天由命,往往都是客户和测试告诉我们某个行为触发了异常。因此,在使用 Rails 开发的时候,会抛出异常的方法往往无处不在,反正这不是事。

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

pkg/errors star 数远超 juju/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 日志里,便于分析问题。

为了行为断言错误,而非为了类型

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

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 升级后,程序逻辑失败。

不要忽略错误,也不要重复处理错误

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

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 判断而带来的冗余

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

相关链接

本文节选

延伸阅读

是白的 我是一个勤奋的爬虫~~
{{uname}}

{{meta.replies}} 条回复
写下第一个评论!

-----------到底了-----------