golang error 实践

1. Error vs Exception

1.1 Error本质

Error本质上是一个接口

1
2
3
type error interface{
Error() string
}

经常使用errors.New()来返回一个error对象

例如标准库中的error定义, 通过bufio 前缀带上上下文信息

1
2
3
4
5
6
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")
)

errors.New()是返回的error对象的指针

为了防止在error比较时,因为error内部内容定义相同导致两个不同类型的error误判相等

1.2 Error和Exception的区别

各语言演进:

  • C: 一般传入指针,通过返回的int值判断成功还是失败
  • C++: 无法知道抛出的什么异常,是否抛出异常(只能通过文档)
  • JAVA: 需要抛出异常则方法的所有者必须声明,调用者也必须处理。处理方式、轻重程度都由调用者区分。
  • GO: 不引入exception,采用多参数返回,一般最后一个返回值都是error
  • error or panic: 如果你写过java你会觉得go中的panic处理很像java中的抛exception, 那我们到底是使用error还是panic?

在 Go 中 panic 会导致程序直接退出,是一个致命的错误,如果使用 panic recover 进行处理的话,会存在很多问题

  1. 性能问题,频繁 panic recover 性能不好
  2. 容易导致程序异常退出,只要有一个地方没有处理到就会导致程序进程整个退出
  3. 不可控,一旦 panic 就将处理逻辑移交给了外部,我们并不能预设外部包一定会进行处理 什么时候使用 panic 呢? 对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才使用 panic

1.3 使用error代替exception的好处:

  • 简单
  • 考虑失败不是成功
  • 没有隐藏的控制流
  • error are value

2. Error Type 🌟

2.1 Sentinel Error(预定义错误)

1
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")

预定义的错误,缺点:

  • 不灵活,调用方使用==去比较错误值是否相等;一旦出现fmt.Errorf这种携带上下文信息的error,会破坏相等性检查
  • 成为你的公共api;比如io.reader,io.copy这类函数都需要去判断错误类型是否是io.eof,但这并不是一个错误。
  • 创建了两个包之间的依赖

2.2 Error Types

Error types 是实现了 error 接口的自定义类型。例如 MyError 类型记录了文件和行号以展示发生了什么:
os包下的error类型:https://golang.org/src/os/error.go

1
2
3
4
5
6
7
8
9
10
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}

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

func (e *PathError) Unwrap() error { return e.Err }

Error types 优点:

  • 携带更多的上下文

Error types 缺点:

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

    2.3 Opaque Error

    当开始使用 errors.Cause(err, sql.ErrNoRows) 或 xerrors.Is(err, sql.ErrNoRows) 时, 就意味着 sql.ErrNoRows 作为实现细节被暴露给外界了, 它成了API的一部分。

如果只是利用库代码进行业务开发, 包装后作判断的作法可以被理解和接受的。

而对于API的定义者来说, 这个问题就变得需要格外重视,我们需要不透明的错误处理。它的优势在于:减少代码之间耦合,调用者只需关心成功还是失败,无需关心错误内部

1
2
3
4
5
6
7
8
// 只需返回错误而不假设其内容
func fn()error{
x, err := bar.Foo()
if err != nil {
return err
}
// to do something
}

说白了就是不通过err来判断各种情况,作为调用者只关心是成功还是失败(err是否为nil)

Assert errors for behaviour, not type

在少数情况下,只有这种二分的处理方法是不够(只有成功、失败两种状态)。

比如net包中的操作:https://golang.org/src/net/net.go

1
2
3
4
5
6
7
8
9
// An Error represents a network error.
type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// to do something
}
1
2
3
4
5
6
7
8
type temporary interface {
Temporary() bool
}
// 在不导入包的情况下可以直接使用err相关行为
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}

可以将任何错误传递给 IsTemporary 以确定错误是否可以重试。

如果错误没有实现 temporary 接口; 也就是说,它没有 Temporary 方法,那么错误不是临时的。

如果错误确实实现了 Temporary,那么如果 true 返回true ,调用者可以重试该操作。

这里的关键是,此逻辑可以在不导入定义错误的包,或者直接知道任何关于 err的基础类型的情况下实现 - 我们只是对它的行为感兴趣。

我建议, 尽量避免Sentinel Error、error types,去使用Opaque Error,至少在库文件、公共api中.

3. Handing Error Gracefully 🌟

3.1 Try to eliminate errors handle

1)在不破坏程序正确性和可读性的前提下,尽量减少error处理

例一

  • Bad
    1
    2
    3
    4
    5
    6
    func AuthRequest() error{
    if err:= auth();err!=nil{
    return err
    }
    return nil
    }
  • Good
    1
    2
    3
    func AuthRequest(r, *Request) error{
    return auth(r.User)
    }
    例二

我们的代码里经常会有大量的if error != nil {…}这样的代码,在一些特殊的场景是可以优化的

我们先看一个令人崩溃的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func parse(r io.Reader) (*Point, error) {
var p Point
if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
return nil, err
}
}

我们可以看到这段代码其实主要是在调用binary.Read()方法,我们可以参考bufio.Scanner的操作

1
2
3
4
5
6
7
8
9
10
scanner := bufio.NewScanner(input)

for scanner.Scan() {
token := scanner.Text()
// process token
}

if err := scanner.Err(); err != nil {
// process the error
}

错误会保存到Scanner中,只进行最后一次的判断。应用这个思路,优化代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type Reader struct {
r io.Reader
err error
}

func (r *Reader) read(data interface{}) {
if r.err == nil {
r.err = binary.Read(r.r, binary.BigEndian, data)
}
}

func parse(input io.Reader) (*Point, error) {
var p Point
r := Reader{r: input}

r.read(&p.Longitude)
r.read(&p.Latitude)
r.read(&p.Distance)
r.read(&p.ElevationGain)
r.read(&p.ElevationLoss)

if r.err != nil {
return nil, r.err
}

return &p, nil
}

但是这种优化场景局限,只能运用到同一个业务对象的不断操作,对多种业务对象多种功能还是得老老实实写if err != nil

3.2 Annotating errors

除了直接将错误返回,我们还常常会给错误携带更多的上下文信息再返回,因为足够的信息才能让我们快速的解决出现的问题。 下文会把给error添加上下文的操作叫做error注释

目前error注释的方式如下:

  • fmt.Errorf获得一个格式化的error,可以携带格外的错误信息
  • 自定义error,通过实现 Error() 方法
  • 打包错误,通过 pkg/errors”.Wrap

如何选择适合的error注释方式? 需考虑如下一点:

  1. 是否需要被调用者捕获、处理
    • 无需被调用者捕获、处理;使用 fmt.Errorf
      1
      2
      3
      4
      func Open(filePath string) error {
      _, err := createfile(filePath)
      return fmt.Errorf(“createfile: %s“, err)
      }
  • ​ 需要被客户端捕获、处理
    ​ 使用fmt.Errorf,会破坏相等性检查,导致只能通过错误信息字符串来比较错误是否相等,十分不可靠; 我们可以自定义错误类型,携带上下文的同时,给调用者提供可靠的判断方式;但是,自定义错误相当繁琐,我们可以使用pkg/errors这个包简化操作。 如下:
    1
    2
    3
    4
    5
    6
    // file package
    var FileNotExsist = “file path not exsist: %s“
    func Open(filePath string) error {
    _, err := createfile(filePath)
    return errors.warp(err, "createfile fail: ")
    }
    这里简单介绍一下这个包github.com/pkg/errors,使用它去打包下游信息几乎已经是一个golang中的标准做法了。

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

1
2
3
4
5
6
7
8
9
10
11
12
// fundamental is an error that has a message and a stack, but no caller.
type fundamental struct {
msg string
*stack
}

func New(message string) error {
return &fundamental{
msg: message,
stack: callers(),
}
}

这里的fundamental对象也是实现了 golang 内建 interface error的 Error 方法.

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

1
2
3
4
5
6
7
8
//只附加新的信息, 一般用于在业务代码中替换 fmt.Errorf()
func WithMessage(err error, message string) error

//只附加调用堆栈信息,一般用于包装对第三方代码(标准库或第三方库)的调用。
func WithStack(err error) error

//同时附加堆栈和信息,一般用于包装对第三方代码(标准库或第三方库)的调用。
func Wrap(err error, message string) error

其实上面的包装,很类似于Java的异常包装,被包装的error,就是这个错误的根本原因。所以这个错误处理库为我们提供了Cause函数让我们可以获得最原始的 error 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func Cause(err error) error {
type causer interface {
Cause() error
}

for err != nil {
cause, ok := err.(causer)
if !ok {
break
}
err = cause.Cause()
}
return err
}

使用for循环一直找到最根本(最底层)的那个error。

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

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

⚠️ 不要多次包装错误,堆栈信息会重复。

如果多次使用 WithStack(err),会将 stack 打印多遍,err 信息可能非常长。 可以人肉去 check 下层有没有使用 WithStack(err),如果下层用了上层就不用。但这样会增加心智负担,容易出错。 我们可以在调用是使用一个 wrap 函数,判断一下是否已经执行 WithStack(err)。 但是 github.com/pkg/errors 自定义的 error 类型 withStack 是私有类型,如何去判断是否已经执行 WithStack(err) 呢? 好在 StackTrace 不是私有类型,所以我们可以使用 interface 的一个小技巧,自己定义一个 interface,如果拥有 StackTrace() 方法则不再执行 WithStack(err)。 像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
type stackTracer interface {
StackTrace() errors2.StackTrace
}

// once
func WithStack(err error) error {
_, ok := err.(stackTracer)
if ok {
return err
}

return errors2.WithStack(err)
}

3.3 Only handle errors once

Handling an error means inspecting the error value, and making a decision.

我引用dave.cheney的这句话,意思是 “处理错误意味着你已经检查了错误并做出了决定”.

如果你做出的决定少于一个,那么就是你没有检查错误、忽略了错误,如下:

1
2
3
func Write(w io.Writer, buf []byte) {
w.Write(buf)
}

w.Write(buf)的error被丢弃了

如果针对一个问题做出的决定多于一个,也是有问题的,如下:

1
2
3
4
5
6
7
8
9
10
11
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
// annotated error goes to log file
log.Println("unable to write:", err)

// unannotated error returned to caller
return err
}
return nil
}

在上面的例子中 我们既记录错误日志,又错误返回给调用者,返回的错误可能会被层层记录,一直到最上层。

最后我们会得到一堆重复的日志信息,但在最上层却只能拿到一个最原始的错误。

我们可以根据不同需求去选择error注释(具体选择方式查看[3.2](3.2 Annotating errors)),返回给调用者

1
2
3
4
func Write(w io.Write, buf []byte) error {
_, err := w.Write(buf)
return errors.Wrap(err, "write failed")
}

4. Golang1.13 error

结合社区反馈,Go 团队完成了在 Go 2 中简化错误处理的提案。 Go核心团队成员 Russ Cox 在xerrors中部分实现了提案中的内容。它用与 github.com/pkg/errors相似的思路解决同一问题, 引入了一个新的 fmt 格式化动词: %w,使用 Is 进行判断。:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import (
"database/sql"
"fmt"

"golang.org/x/xerrors"
)

func bar() error {
if err := foo(); err != nil {
return xerrors.Errorf("bar failed: %w", foo())
}
return nil
}

func foo() error {
return xerrors.Errorf("foo failed: %w", sql.ErrNoRows)
}

func main() {
err := bar()
if xerrors.Is(err, sql.ErrNoRows) {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
return
}
if err != nil {
// unknown error
}
}
/* Outputs:data not found, bar failed: foo failed: sql: no rows in result set
bar failed:
main.bar
/usr/four/main.go:12
- foo failed:
main.foo
/usr/four/main.go:18
- sql: no rows in result set
*/

与 github.com/pkg/errors 相比,它有几点不足:

  • 使用 : %w 代替了 Wrap 看似简化, 但失去了编译期检查。 如果没有冒号,或 : %w 不位于于格式化字符串的结尾,或冒号与百分号之间没有空格,包装将失效且不报错
  • 更严重的是, 调用 xerrors.Errorf 之前需要对参数进行nil判断。 这实际完全没有简化开发者的工作

到了 Go 1.13 ,xerrors 的部分功能被整合进了标准库。 它继承了 xerrors的全部缺点, 并额外贡献了一项:不支持调用栈信息输出. 根据官方的说法, 此功能没有明确时间表。因此其实用性远低于 github.com/pkg/errors

因此目前没有使用它的必要

5. 总结

最后,我来帮你梳理一下本文的重点,也就是目前需要掌握的

panic

  • 在程序启动的时候,如果有强依赖的服务出现故障时 panic 退出
  • 在程序启动的时候,如果发现有配置明显不符合要求, 可以 panic 退出(防御编程)
  • 其他情况下只要不是不可恢复的程序错误,都不应该直接 panic 应该返回 error
  • 在程序入口处,例如 gin 中间件需要使用 recover 预防 panic 程序退出
  • 在程序中我们应该避免使用野生的 goroutine
  • 如果是在请求中需要执行异步任务,应该使用异步 worker ,消息通知的方式进行处理,避免请求量大时大量goroutine 创建
  • 如果需要使用 goroutine 时,应该使用同一的 Go 函数进行创建,这个函数中会进行 recover ,避免因为野生goroutine panic 导致主进程退出
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    func Go(f func()){
    go func(){
    defer func(){
    if err := recover(); err != nil {
    log.Printf("panic: %+v", err)
    }
    }()

    f()
    }()
    }

    error

  1. 我们在应用程序中使用 github.com/pkg/errors 处理应用错误,注意在公共库当中,我们一般不使用这个
  2. error 应该是函数的最后一个返回值,当 error 不为 nil 时,函数的其他返回值是不可用的状态,不应该对其他返回值做任何期待
  3. 错误处理的时候应该先判断错误, if err != nil 出现错误及时返回,使代码是一条流畅的直线,避免过多的嵌套
  4. 在应用程序中出现错误时,使用 errors.New 或者 errors.Errorf 返回错误
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func (u *usecese) usecase1() error { 
    money := u.repo.getMoney(uid)
    if money < 10 {
    err := errors.Errorf("用户余额不足, uid: %d, money: %d", uid, money)
    return err
    }
    // 其他逻辑
    return nil
    }
  5. 如果是调用应用程序的其他函数出现错误,请直接返回,如果需要携带信息,请使用 errors.WithMessage
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func (u *usecese) usecase2() error {
    name, err := u.repo.getUserName(uid)
    if err != nil {
    return errors.WithMessage(err, "其他附加信息")
    }

    // 其他逻辑
    return nil
    }
  6. 如果是调用其他库(标准库、企业公共库、开源第三方库等)获取到错误时,请使用 errors.Wrap 添加堆栈信息
  7. 切记,不要每个地方都是用 errors.Wrap 只需要在错误第一次出现时进行 errors.Wrap 即可
  8. 根据场景进行判断是否需要将其他库的原始错误吞掉,例如可以把 repository 层的数据库相关错误吞掉,返回业务错误码,避免后续我们分割微服务或者更换 ORM 库时需要去修改上层代码
  9. 注意我们在基础库,被大量引入的第三方库编写时一般不使用 errors.Wrap 避免堆栈信息重复
  10. 禁止每个出错的地方都打日志,只需要在进程的最开始的地方使用 %+v 进行统一打印,例如 http/rpc 服务的中间件
  11. 错误判断使用 errors.Is 进行比较
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func f() error {
    err := A()
    if errors.Is(err, io.EOF){
    return nil
    }

    // 其他逻辑
    return nil
    }
  12. 错误类型判断,使用 errors.As 进行赋值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func f() error {
    err := A()
    if errA := new(errorA) && errors.As(err, &errA){
    // ...
    }

    // 其他逻辑
    return nil
    }
  13. 如何判定错误的信息是否足够,想一想当你的代码出现问题需要排查的时候你的错误信息是否可以帮助你快速的定位问题,例如我们在请求中一般会输出参数信息,用于辅助判断错误
  14. 对于业务错误,推荐在一个统一的地方创建一个错误字典,错误字典里面应该包含错误的code,并且在日志中作为独立字段打印,方便做业务告警的判断,错误必须有清晰的错误文档
  15. 不需要返回,被忽略的错误必须输出日志信息
  16. 只应该被处理一次,输出错误日志也算处理,一旦确定函数/方法将处理错误,错误就不再是错误。如果函数/方法仍然需要发出返回,则它不能返回错误值。它应该只返回零(比如降级处理中,你返回了降级数据,然后需要 return nil)。
  17. 对同一个类型的错误,采用相同的模式,例如参数错误,不要有的返回 404 有的返回 200
  18. 处理错误的时候,需要处理已分配的资源,使用 defer 进行清理,例如文件句柄
  19. 尽量避免Sentinel Error、error types,去使用Opaque Error,至少在库文件、公共api中.
刘小恺(Kyle) wechat
如有疑问可联系博主