Go 工程化思考 0x2 如何优雅的处理错误

Golang 1369 字

在 Go 中的错误处理与其他语言例如 Java,PHP 的错误处理还是有很大差异的,他没有异常的 try catch 机制,而是在函数的返回值中带上 error,让调用者先判断 error 在处理函数的返回值,调用者通过 error 的级别来判断是否触发 painc,这种高度自主化的错误处理模式原本是 go 语言的一大特色,但是在实际开发中代码会出现一堆的错误判断逻辑。

在发生错误的时候也缺少错误的堆栈信息,从而导致问题排查的效率不高,在这一小节,我们来学习如何优雅的处理 Go 的错误。

Error 的本质

Go Error 就是普通的一个接口,普通的值。

type error interface {
    Error() string    
}

我们经常使用 errors.New() 来返回一个 errorString 的指针。

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

在基础库中大量的自定义 error。

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")
)

我们将它们成为哨兵 error,是一个自己定义好的包级别的对象,外面的人只要加载了这个包,就直接可以用到这个对象,可以进行==等一系列运算。

我们可以看到,在里面有一个小细节,哨兵error 中,会把所在包打在里面,这样可以明确的知道,我是哪个包出现了错误,在我们写业务代码时,最好也加上这样的处理。

Error 为什么要返回指针

我们来看一下下面的代码:

package main

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"){
        fmt.Println("Named Type Error")
    }

    if ErrStructType == errors.New("EOF") {
        fmt.Println("Struct Type Error")
    }
}

执行上面的代码:

Named Type Error

自定义的 error 在进行等值运算的,明明不是一个error,但是却返回了Named Type Error,这是因为在底层使用的是 string 类型,底层两个string ,由于没有去取地址,这样一来,进行的就是等值返回,对于标准库的 error 来说是不返回的,这也是为什么标准库用结构体包一下,并且返回一大个地址。会比较两个指针地址是否是一个。

这样就可以避免一些奇怪的Bug,两个人定义了两个错误,但是文本内容一模一样,结果相等了。

如果一样用结构体包裹一下,但是使用取地址符,那么在进行等值运算的时候,就会首先按照结构体里面的字段是否一样进行判断。

预定义的特定错误

预定义的特定错误,我们叫为 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 Go,我们使用特定的值来表示错误。

if err == ErrSomething { … }

类似的 io.EOF,更底层的 syscall.ENOENT。

使用 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。在 go 源码包中就有对 error 的扩展,与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。

//src : /src/os/error.gop 
type PathError struct {
  Op string
  Path string
  Err error
}

func (e *PathError) Error() string {
  return e.Op + " " + e.Path + " " + e.Err.error()
}

虽然提供了更多的错误信息,但是却造成了新的问题,调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

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

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

maksim
Maksim(一笑,吡罗),PHPer,Goper
OωO
开启隐私评论,您的评论仅作者和评论双方可见