Go异常处理

一、Error vs Exception

(一)、errors.go文件中的源码分析

在这部分开头先讲一下,在Go语言中没有Exception的概念,只有Error,这一点区别于Python、Java等这些语言。至于为什么没有Exception后边来说,现在我们先看一下Go中的error.go的源码

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

源码具体分析:
• 在errors.go的源码包中定义很简单,一个结构体,两个方法。结构体中一个字段,string类型。
• Error方法又一个指针类型的方法接收器,返回的值是结构体中的字段
• New方法没有方法接收器,参数是一个string类型,返回的是结构体的内存地址,这里为什么会返回呢?因为如果参数不同的程序传入来相同内容的text的参数,如果返回的不是内存地址,那么两个error是完全相等的,但两个程序又是不同的程序,所以会出现问题。在计算机中会为每一个变量专门开辟出一块独立的内存空间用于存储,所以每一个变量的内存地址是不一样的,通过返回内存地址就可以区分两个不同程序的error。

(二)、示例

通过上边的源码分析,我们现在自己编写一个程序,对比一下:
• ErrNamedType调用的自定义的New()返回的是字符串,所以ErrNamedType == New(“EOF”)是true
• ErrStructType()调用的是errors.New()返回的是内存地址,所以ErrStructType == errors.New(“EOF”)收false

import (
    "errors"
    "fmt"
)
type errorString string
func (e errorString) Error() string {
    return string(e)   // 类型的强制转换
}
func New(text string) error {
    return errorString(text)  // 返回的是字符串
}
var ErrNamedType = New("EOF")
var ErrStructType = errors.New("EOF")
func main() {
    if ErrNamedType == New("EOF") { // 这里两个都是自定义的New("EOF")方法的返回值,所以是相等的
        fmt.Println("Named Type Error")
    }
    if ErrStructType == errors.New("EOF") {
        // 这里不相等,是因为标准库中的errors.New()方法返回的是指针,return &errorString{text}即使text完全相等
        // 返回的结果也不想等
        fmt.Println("Struct Type Error")
    }
}

(三)、为什么不使用Exception?

Go语言中为什么不使用Exception?回答这个问题前我们先看一下几个主流语言的演进:
• C:单返回值,一般通过传递指针作为入参,返回值为int表示成功还是失败,这么做的好处是int可以表示更多的错误结果
• C++:引入了exception,但是无法知道被调用方会抛出什么异常
• Java:引入了checked exception,方法的所有者必须声明,调用者必须处理。在启动时抛出大量的异常是司空见惯的事情,并在他们呢的调用堆栈中尽职的记录下来。Java异常不再是异常,而是变得司空见惯了。它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。
在Go语言中,处理逻辑是不引入exception,支持多参数返回,所以很容易的在函数签名中带上实现来err interface的对象,交由调用者来判定。如果一个函数返回了value和error,那么必须先判定error是否为nil,不能直接对value的值做任何的假设。唯一可以忽略error的情况是,调用者对value都不关心。
例如:

package main
import "fmt"
func handle() (int, error) {
    return 1, nil   // 多参数返回
}
func main() {
    i, err := handle()
    if err != nil {  // 首先处理err
        return
    }
    fmt.Println(i)
}

Go中有panic机制,panic机制和其他语言的exception不同,其他语言的exception是把异常向上抛,让调用者来处理。而panic则意味着fatal error(程序直接挂掉了),调用的是底层的os.Exit。不能假设调用者来解决panic,意味着代码不能继续进行。使用多个返回值和一个简单的约定,Go让程序员知道什么时候出了问题,并为真正的异常情况保留了panic。
总结:对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,才能使用panic。对于其他的情况,应该使用error来进行判定。

you need to check the error value if you care about the result. --Dave

二、错误类型

(一)、Sentinel Error

预定义的特定错误,被成为sentinel error,这个名字来源于计算机编程中使用一个特定的值来表示不可能进一步处理的做法。所以,对于Go来说使用特定的值来表示错误。"if err == ErrSomething{…}类似的io.EOF,更底层的syscall.ENOENT。
使用sentinel值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当想要提供更多的上下文时,就出现了一个问题,因为返回一个错误就会破坏相等性检查。甚至是一些有意义的fmt.Errorf携带一些上下文,也会破坏调用者的 ==,调用者将被迫查看error.Error()方法的输出,以查看它是否与特定的字符串相匹配。
注意事项:
• 不依赖检查error.Error的输出
不应该依赖检测 error.Error 的输出,Error 方法存在于 error 接口主要用于方便程序员使用,但不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout 等。
• Sentinel errors 成为你 API 公共部分。
如果您的公共函数或方法返回一个特定值的错误,那么该值必须是公共的,当然要有文档记录,这会增加 API 的表面积。
如果 API 定义了一个返回特定错误的 interface,则该接口的所有实现都将被限制为仅返回该错误,即使它们可以提供更具描述性的错误。
比如 io.Reader。像 io.Copy 这类函数需要 reader 的实现者比如返回 io.EOF 来告诉调用者没有更多数据了,但这又不是错误
在做基础库开发的时候,一定是尽可能少的暴露公共API,越多的暴露,则整个基础库/系统越脆弱。
• Sentinel errors在两个包之间创建了依赖关系
Sentinel errors最糟糕的问题是在源代码包和引入Sentinel errors的包之间创建了依赖的关系。例如:检查错误是否等于io.EOF,那么代码之中必须引入io。虽然这种引用很常见,但是这么操作无疑是增加了项目中的耦合度。
• 结论是:尽可能避免sentinel errors的使用。

(二)、Error types

Error types这种类型错误是通过自定义一个error结构体,在结构体内有不同的字段信息,这些信息描述了错误的上下文,当错误发生时,使用switch+类型断言判断是否调用,以及返回什么错误信息。与Sentinel error相比,错误类型的一大改进是他们能够包装底层的错误以提供更多的上下文。一个不错的例子就是os.PathError它提供了底层执行什么操作、哪个路径出现了问题。
这种类型错误,调用者需要使用类型断言和类型switch,就要让自定义的error变成了public。这种模型会导致和调用者之间产生强耦合,从而导致API变弱。

package day11
import (
    "fmt"
    "testing"
)
// 自定义Error Type
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 test() error {
    return &MyError{"Something happened", "error_type_test.go", 44}
}
func TestErrorType(t *testing.T) {
    err := test()
    switch err := err.(type) {  // switch+断言判断错误
    case nil:
    case *MyError:
        fmt.Println("error occurred on line: ", err.Line)  // 类型断言成功之后就可以调用自定义Error结构体内的任意字段
    default:
    }
}

结论:尽量避免使用error types,虽然错误类型比sentinel errors更好,因为它们可以捕获关于错误的更多上下文(字段信息),但是error types共享error values许多相同的问题。因此,应尽量避免错误类型,或者至少避免将它们作为公共API的一部分。

(三)、Opaque errors

Opaque errors(不透明的错误)相比较而言是最透明的、最佳的错误处理策略,它要求代码和调用者之间的耦合最少。因为虽然调用者知道发生了错误,但是却没有能力获知错误的内部信息。作为调用者关于操作结果所知道的也仅仅是它是否起作用了(成功还是失败)。
Assert errors for behaviour, not type. 这句话的意思是断言错误的行为,而不是类型。在少数的情况下,这种二分的错误处理方法是不够的。例如:与进程外的世界交互(入网络活动),需要调用方调查错误的性质,以确定重试该操作是否合理。在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。
代码示例:

package main
import "fmt"
type MyError struct {
    Msg string
    File string
    Line int
}
// 自定义一个私有的接口
type temporary interface {
    Temporary() bool
}
func IsTemporary(err error) bool {
    te, ok := err.(temporary)  // 断言
    return ok && te.Temporary()
}
func test() error {
    return &MyError{"Something happened", "error_type_test.go", 44}
}
func main() {
    err := test()
    fmt.Println(IsTemporary(err))
}
// 运行结果:false

三、错误的处理

(一)、错误处理的原则

• 尽量使用if err != nil,而不是if err == nil,如果使用后者,大量的正常代码都要在{}内,看着很不舒服。
• 日志记录与错误无关且对调试没有帮助的信息应该被视为噪音,应该予以质疑。记录的原因是某些东西失败了,而日志包含了答案。
• 错误要被日志记录。应用程序处理错误,保证100%的完整性,并且处理之后不再报当前错误。
• 当出现错误的时候,如果当前程序可以处理应立即处理,并且一个错误只能处理一次,处理完之后错误应该立即降级;如果不能处理,则应该把错误的详细信息向上层抛,让上层去解决,如果上层也不能解决,则继续向上抛。

(二)、第三方pkg/errors包

• 为什么要使用第三方的errors包呢?因为Go语言自带的errors包,包括fmt打印日志不能提供原始错误的堆栈信息(上下文信息),也没有完整的错误链路。如果使用fmt打印错误日志,一个error在经过不同的方法之后,每一个方法都要打印一次,直到最顶层时去查看错误会发现两问题:1. 没有错误的详细信息;2. 错误的日志分散在各处,不好排查错误。而第三方包pkg/errors提供了包装原始错误的方法,可以直接把包含堆栈信息的原始错误包装之后返回去,等到最上层的时候直接可以打印出错误的详细信息,利于排查问题。
• 基本用法:
• 导入:go get github.com/pkg/errors
• errors.Wrap(错误,添加的信息)
• errors.WithMessage(错误,添加的信息)
• errors.Cause(err)用于获取错误原始错误,拿到原始错误(根因)后就可以使用Sentinel或者error types了。 %T获取类型,%v获取值,%+v获取错误原始的堆栈信息。
errors.Wrap()和errors.WithMessage()这两个方法的作用都是添加信息,包装错误的原始错误,包装后的信息更加的清晰、易读。不同点在于:errors.Warp()会携带错误完整的堆栈信息,而errors.WithMessage()却没有。还有一点需要注意,在我们日常开发时,应用的代码(业务代码)中,可以直接使用errors.New()或者errors.Errorf()方法返回错误信息,这两个方法包含有堆栈信息,这么做也符合我们一个错误处理一次的原则。

package day11
import (
    "bufio"
    "fmt"
    "github.com/pkg/errors"
    "io"
    "os"
    "testing"
)
func ReadFile02(f io.Reader) ([]string, error) {
    var (
        se      = bufio.NewScanner(f)
        lines   int
        content []string
    )
    for se.Scan(){
        content = append(content, se.Text())
        lines++
    }
    return content, se.Err()
}
func ReadFile(path string) ([]string, error) {
    f, err := os.Open(path)  // 类似于Python中的获取文件句柄
    if err != nil {
        return nil, errors.Wrap(err, "open failed")  // 包装原始错误,包含堆栈信息
    }
    defer f.Close()
    content, err := ReadFile02(f)
    if err != nil {
        return nil, errors.Wrap(err, "get content failed")
    }
    return content, err
}
func TestReadFile(t *testing.T) {
    con, err := ReadFile("/Users/apple/workplace/go_test/src/day11/test.txt")
    if err != nil {
        fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))  // 获取原始错误的类型和值
        fmt.Printf("stack trace: \n%+v\n", err)  // 获取错误原始的堆栈信息
    }
    for i := 0; i < len(con); i++ {
        fmt.Println(con[i])
    }
    os.Exit(1)
}
(完)