Go错误处理

  • 在项目开发中,我经常头疼的一个事情就是错误处理,主要体现在两个点
+ 写代码时调用其他函数,它返回了一个error,该如何处理?


    - 我以往都是先`log`再`return`。然后这个A函数被B函数调用了,B函数又一样的处理方式,最终导致日志一大堆重复信息,而且还没有堆栈。
+ 自己写函数,出现一种非正常情况,我该怎么定义error?


    - 一般都随心所欲`return errors.New("一堆文字")`。调用方几乎很难判断错误的原因。
  • 为了解决问题,参考了毛剑老师的分享和很多业内大佬的文章,越来越意识到错误处理方式的演进过程中,每一个新方案的提出都是为了解决上一个方案在使用中产生的坑,并且自身也不可避免的产生了一些新问题,所以go语言目前的错误处理哲学和方案也终将有一天会被更好的思想给替代。
  • 本文主要围绕两个头疼的问题展开,过程中简单对比下try-catch-exception机制,来分析go对于错误处理的新思路以及列出一些解决问题的办法。

go的错误概念

  • 很多语言中采用了exception和error两个概念和一套try-catch-exception机制。
  • 我不打算深究这套机制,只讨论go的概念。
  • 在go中,没有exception,只有error。
  • error“往外抛”的姿势一般是return。这种错误被认为是可恢复的错误。
  • 通过panic方法往外抛的错误,被认为是不可恢复的错误。panic方法接收任意类型,也可以是erorr,它的具体内容在后面讲。
  • 总结:
+ go将错误分为可恢复的错误和不可恢复的错误,它们的区分方式是“往外抛”的姿势。

error类型

  • Go的Error就是一个很普通的interface。

Error接口定义.png

  • error的实现体也是很普通的值。最常用的err := errors.New("string")就是:

errors.New.png

  • errorString设计有一个很巧妙的地方:New方法返回的是一个指针而不是直接返回errorString。
    • 每一次errors.New方法创建的error是不相等的,即使内部包含的s是一样的,也过不了判等操作。
    • 这个特性对于保证错误处理的一方不混乱是至关重要的。
    • 开发中”我遇到一个很像你的人,但始终不是你“这种需求就可以考虑使用指针来实现。

panic

1
2
3
go复制代码func main() {
panic("main panic")
}
  • go中一旦使用的panic,就是想要程序终止。
  • 虽然go有recover的机制来恢复panic,但是在写panic时,不能假设调用者会使用recover。这和try-catch-exception机制中,抛出的exception可认为调用者有义务try-catch的思想是不同的。

使用panic的建议

  • 在项目启动时,资源初始化如加载配置文件、数据库连接等这种一旦失败了,整个项目没有进行下去的意义时,会使用panic。但是也有一些被弱依赖的资源初始化,可能会有一些不同,这要根据项目的实际情况来定。
  • 在写一般函数方法时,如果不是类似索引越界,栈内存溢出等无法恢复的错误,尽量不去使用panic。

recover

  • 一般来说,出现了panic就不需要再救了。
  • 但在类似web服务的框架中,不能由于某一个请求中出现了panic而导致整个服务gg。所以在框架层面会在每一个请求调用链的最上游统一做recover。

recover基本用法

  • recover与defer一起使用
+ 
1
2
3
4
5
6
7
8
go复制代码func main() {
defer func() {
// recover()返回的就是panic()中的入参,不一定时error类型
if err := recover();err != nil {
// 可以根据不同err做不同的处理,如果处理不了这里任可以继续panic
}
}()
}

recover不了的场景

  • recover能处理到情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码func DoSomething() {
defer func() {
if err := recover();err != nil {
// handle
log.Printf("panic被recover了 %v",err)
}
}()
// 在函数内部出现的panic能被recover
// panic(errors.New("panic in DoSomething"))

// 发生在调用函数中出现的panic也能被recover,因为还在一个goroutine中。
// do()
}

func do() {
panic(errors.New("panic in do"))
}
  • recover不能处理的情况
1
2
3
4
5
6
7
8
9
10
go复制代码func DoSomething() {
defer func() {
if err := recover();err != nil {
// handle
log.Printf("panic被recover了 %v",err)
}
}()
// 启动的goroutine中如果出现了panic,DoSomething的recover是没有办法奏效的。
go do()
}
  • 如何处理
+ recover不到的情况其实就是recover的goroutine和panic发生的goroutine不是同一个goroutine。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
go复制代码// 把需要异步启动的方法包装一下,在异步函数内recover
func Go(f func()) {
go func() {
defer func() {
if err := recover();err != nil {
// handle panic
}
}()
// 实际的异步函数执行
f()
}()
}

// DoSomething开启goroutine的方式变成:
func DoSomething() {
defer func() {
if err := recover();err != nil {
// handle
log.Printf("panic被recover了 %v",err)
}
}()
// 启动的goroutine中如果出现了panic,DoSomething的recover是没有办法奏效的。
Go(do())
}
+ 把goroutine的启动包装一下后,goroutine内的panic就能被recover。但在业务代码中,往往被goroutine包裹的异步函数不会像例子中那样没有入参和返参。上面提供的包装方法中f函数内部的逻辑如果想要使用其外部的变量,只能通过闭包。这里就需要特别注意,开启goroutine时我们认为的外部变量的值和goroutine实际运行时的值是可能不同的。为了减少心智负担,可以在需要的情况下重新定义包装函数(修改包装函数的入参函数的签名),或者确保外部变量只读的。
  • 总结
+ 不建议写这种野生goroutine,它们很难被集中控制,也可能是根本不受控制。建议当需要开启异步任务时,使用channel发送一个信号,由专门的程序监听channel并决定如何开启异步任务,这样可以做到统一的管理。

if err != nil

  • 很多人觉得 if err != nil 很烦,可能由于我自己习惯了,所以觉得还好。在本节就说明下它的好处。

以太坊大佬眼中的error philosophy

if err != nil

  • 我们写的go函数一般在返回一个或者多个正常值的最后会带上一个error类型的值。
  • 当这些正常值要被使用时,必须对error做检查,否则无法保证这些正常值的正确性。

go的错误处理思想

if err != nil的好处

  • No hidden control-flows
+ 清晰的控制流
+ 这是相对于try-catch-exception机制的


    - 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码// try中如果出现了异常,会走到catch分支中。但从代码层面是看不到究竟是哪一个函数遇上了异常。
try
function1()
function2()
function3()
catch 异常
// do something

// go中的做法
err := function1()
if err != nil {
// 处理criticalOperation1()产生的error
}

// ......
- 当然这种对比有失公允,因为只要对每一函数做try-catch效果就和if err != nil 一个效果了,但是if err != nil 经常被吐槽冗余代码多吗,这种情况下try-catch-exception更冗余。
  • No unexpected uncaught exception logs blowing up your terminal (aside from actual program crashes via panics)
+ 不会出现未被捕获的错误使程序崩溃。
+ 还是相对于try-catch-exception机制


    - 
1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码// 下面有多个catch分支去捕获不同的异常,但是如果出现了一个不在catch分支中的异常程序就会崩掉
// 由于在try-catch-exception机制中,抛异常是安全的,所以抛的这一方是很有可能抛出一个catch方没有捕获的异常
// 而go中,error不会导致崩掉,而panic又有严格的规定。
try
function1()
function2()
function3()
catch io异常
// do something
catch 空指针异常
// do something
catch 数据库异常
// do something
  • full-control of errors in your code as values you can handle, return, and do anything you want with
+ 这点显而易见,检查到了error,在分支内可以做处理。

Plan for failure,not success

  • 我认为这句话是整个if err != nil之所以存在的关键所在。这鼓励我们在每一个有可能产生错误的地方都先去检验,然后处理和决定要不要继续往下走。

减少if err != nil的技巧

  • 关于if err != nil的吐槽声一直没有停过,我的认为是,这是它实现上述思想所造成难以避免的问题。但是也能通过一些编程技巧来规避一些重复的error检查。比如Rob Pike(go语言作者)的建议
  • 直接上代码
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
39
40
41
42
43
44
45
go复制代码_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
// ......

// Write方法绑定在fd上,它会返回一个err。那么这个fd执行多少次Writer就要多少次 if err != nil 操作
// 只要做一个改造就能减少多余的 if err != nil

// 定义一个结构体包裹了fd(io.Writer)和err
type errWriter struct {
w io.Writer
err error
}

// errWriter的write方法相当于包装了之前的fd.Write方法,不过要在真正执行前,检验下其内部的err是否为空
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}

func (ew *errWriter) Err() error {
return ew.err
}

// 然后代码就变成了
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// ...
// 统一返回error
if b.Err() != nil {
return b.Err()
}
+ 标准库bufio.Scanner也做了类似的事情



1
2
3
4
5
6
7
8
9
10
11
go复制代码scanner := bufio.NewScanner(input)

// Scan函数的工作是”翻页“,普通的逻辑应该是返回“翻到哪了”, 而标准库的设计是返回“翻成功了没”,即bool类型
// 至于翻到哪了,翻错的原因是什么都包装在scanner内部,按需取就行。这种设计就会节省很多if err != nil
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
+ 有一些场景是很适合这种建议的: - 数据库操作一般都是先select然后多个join然后多个where,还有可能加个排序,分页之类的操作,如果每一步都做if err != nil 代码会非常繁琐,gorm的解决方法就类似。 + Rob Pike大佬的建议引发我的一个思考: - 这种设计是不是很像try-catch-exception?只是在上面很多个b.Write()调用中的某一个调用出现错误时,try-catch-exception机制会直接跳到catch分支,而这种机制会继续尝试执行之后的代码,但会在最开始的错误检查被挡,最后落到类似于catch分支的if b.Err() != nil。 - go的错误处理理念中`Plan for failure,not success`这里没做到,`No hidden control-flows`这里也没做到。有点穿新鞋走老路的意思。 - 这让我有一种思考:设计哲学,语言之禅这类的规范,在实际的开发,是不可能做到完全遵守的,相反它们只是一个指导思想,并不是需要强制执行的“法律”。

定义error的几种姿势

Sentinel error

  • 称为哨兵error,在代码中预定义好的错误类型。
  • 如标准库io包中:

哨兵error.png

  • 抛错误的一方直接return EOF这类定义好的错误
  • 错误处理方一般使用判等的方式如 if err == io.EoF来判断错误原因。

弊端

  • 由于哨兵error的使用者一般都是用判等的方式,那就无法为error添加上下文。比如A函数调用io包中提供的函数时,捕获到io.EOF的错误。在A中如果使用fmt.Errorf方法为其加上错误发生的上下文,由于fmt.Errorf底层使用了errors.New(text string)新建了一个error,则调用A函数的B函数则无法使用if err == io.EoF。在项目中往往会演进成在B函数中使用io.Error方法获取字符串后,再进行字符串匹配。
+ 字符串匹配本身就有风险,很有可能两个错误内部的string恰巧对上了。
+ Dave cheney在文章中也说了,error.Error方法是给人看的,而不是程序。它只应该出现在日志文件、终端打印等这些位置。但是他也说明了是不可能完全做到这一点的,不过要有尽可能避免的意识。
  • 哨兵一旦被定义,就会被使用,一旦被使用,就很难更改。
+ 如果我们使用了哨兵error,在定义接口的时候,就会在接口文档中说明在某种情况下会返回某种哨兵error。那么在接口的所有实现中就必须使用这种error,即使之后定义了更加细致的哨兵error,也不能推广了,因为调用者已经用上了。
  • 导致代码耦合。
+ 一个项目有很多模块,每一个模块中定义了自己的一些哨兵error,那使用者不得不引入这些包,包多了,要么同一意义的错误重复定义,要么极易导致循环引入。
+ 虽然很多基础库使用了哨兵error,但是我们自己程序最好不要模仿。一来我们的业务场景往往不像某一个具体的标准库那样职责单一而且相互之间的关联没那么多,二来我们也不具备标准库作者们那样强大的抽象能力和时间精力去投入一个快速迭代的业务模块。

⚠️:业务错误码也是一种哨兵error,但是业务错误码是系统作为一个整体向外暴露的,不存在耦合问题,是具有很大价值的。

Error type

  • 自定义一种error的实现type也是广泛运用的方式。

error type.png

  • error type实现了error接口,调用需要对函数返回的err做类型断言,而不再是判等。
  • 这种形式的error处理最大的好处就是error type可以包裹原本的error的同时可以附带很多上下文信息,并且还能定义其他方法,具备一定的灵活性。

弊端

  • 它仍然没有解决sentinel error增加api表面积的问题。

Opaque errors

什么是Opaque errors

  • 不透明的error。
  • 在大多情况,在A函数的调用者发现A函数出现了错误的时候,他们能做的都是将error继续return出去,在写代码的时候并不关心error里面有什么。比如os.open(name string)打开一个文件,调用者传入了一个错误的name,以至于open失败了,这个时候的error,我们在代码层面并不需要探到error的原因,然后更改name,然后重试。相反需要做的是直接return,通知调用链的上游这里出现了error。在整条调用链的最上游打印出日志,让开发者或者使用者知道原因(这里也呼应了sentinel error论述中Dave cheney提到的 error.Error方法是给人看的,不是给程序看的思想 )。这样error就是Opaque error。
+ 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go复制代码func main() {
file,err := OpenFileAndDoSomething("/myfile/go语言从入门到精通.pdf")
if err != nil {
// 调用链的终端打印出来,从日志很清楚就能看得出引发错误的原因。
log.Print(err)
return
}
}

func OpenFileAndDoSomething(name string) error {
file,err := os.open(name)
if err != nil {
// 在代码的层面不需要探究error的原因
// 可以在这里记录一些上下文(使用fmt.Errorf())
// 出现错误不要继续往下执行了
return err
}
// ...... 后续操作
}

Assert errors for behaviour, not type

  • 当然,依然存在着我们必须依据error的内容做出处理的情况。比如网络请求时,返回token过期的错误,这种错误是可以通过重新获取token然后发起重试解决的。但它需要探究error原因。Opaque errors做不到。
  • 这就需要断言,如本节的标题说的那样,好的断言方式是断言行为,而不是断言类型。
  • Sentinel error 和 Error type都是我们自定义的error,然后向包外暴露的。上面提到过一旦向外暴露的东西就会被使用,一旦被使用,就很难改。而对于错误定义这种是很难的在最开始的时候就定义精准的,难免需要更改。
  • 如果将向外暴露类型,让调用者对error做类型断言改成向外暴露一种行为断言方法。
  • 举个k8sapi项目的例子说明:

k8s api 错误行为.png

  • 上面这一系列的函数从签名就能看出,主要功能是对传入的error做一个行为的判断,比如func IsTimeOut(err error) bool就是判断err是否是一个超时的错误。
+ 用简单代码说明下用法:


    - 假设调用方,需要对某种特定类型的error做特殊的处理,比如是ownError
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
go复制代码// 对外提供的方法,用于判断类型
func IsOwnError(err error) ok {
// ...类型断言
}

// 特定类型的错误,实现了error接口。
// 甚至可以把ownError类型抽象成接口,这样会更加灵活
type ownError struct {}

func (e *ownError) Error() string {
return "is ownError"
}

func Exec() error {
return &ownError{}
}
  • 这样的好处
+ 包内无需向外暴露具体的错误类型,只需要暴露一个方法就可以做到错误原因的判断,很大程度上减少了向外暴露的面积。
+ 如在`func IsTimeOut(err error) bool`中。由于外部的错误处理时没有和某一个具体的错误类型绑定上关系(Sentinel error的判等 和 Error type的类型断言都绑的死死的),满足TimeOut的错误类型不是固定的,那我们可以很灵活的定义具体的错误类型。
  • 很多库都是使用的类似方法,gorm也是,都可以参考。

处理error — Only handle errors once

  • 处理错误有一个总的理念:Only handle errors once,错误只处理一次。
  • 理解这句话就是两个点,处理和一次。
  • 假设我们有个功能要查询用户信息,包括昵称,头像,个性签名等信息,其中头像的url单独存在一个数据库中。当高峰请求的时候,有可能头像数据库崩了,那在查询头像url的这一步操作肯定会报一个error。那对于这一error的处理会有很多种,下面举出来一些:
+ 1.服务降级,当这个error发生时,就当没有这回事情,吞掉error,继续之后的执行。


    - 
1
2
3
4
5
6
7
8
go复制代码func GetUserInfo(id string) (UserInfo,error) {
url,err := iconDao.GetIconUrl(id)
if err != nil {
log.Infof("iconDao.GetIconUrl(id) error:%+v",err)
err = nil // 直接吞掉 error
}
// ...... 查询其他信息后组装
}
+ 2.不能接受服务降级,直接抛错。 -
1
2
3
4
5
6
7
8
9
10
11
go复制代码func GetUserInfo(id string) (UserInfo,error) {
url,err := iconDao.GetIconUrl(id)
if err != nil {
// return终止后续操作
// iconDao.GetIconUrl(id)的错误直接返回可能外层不能直观看到错误出现的上下文信息
// 这里可以带上一些上下文 err = fmt.Errof("GetUserInfo id = %s iconDao.GetIconUrl failed:%v",err)
// fmt.Errorf()底层会创建一个新的error,但如果这里err使用的是哨兵error,那么上层的判断操作会被打破。
return nil,err
}
// ...... 查询其他信息后组装
}
+ 3.重试一下,再次失败再抛出。 -
1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func GetUserInfo(id string) (UserInfo,error) {
url,err := iconDao.GetIconUrl(id)
if err != nil {
// 再试一次
url,err = iconDao.GetIconUrl(id)
if err != nil {
// 出错抛出
return nil,fmt.Errof("GetUserInfo id = %s iconDao.GetIconUrl failed:%v",err)
}
}
// ...... 查询其他信息后组装
}
  • 在上面举的3个例子,处理error的结果有两种,一种是err变为nil了,可能是不用管,也可能是重试之后解决了,另一种是return出去了,要么是直接return的要是重试之后仍然还有error。不管怎么样,都不能对被调用函数返回的error无动于衷一定得处理。
  • 另外处理只需要一次,return是一种处理方式,不管error是一种处理方式,重试也是一种处理方式。上面例子中是没有重复处理的。
  • 我们经常犯的重复处理的错误就是log+return。这种方式导致的后果就是一个error的信息打印了很多次,看日志排查时非常麻烦。事实上,log就是一种处理方式,为什么满屏冗余的error日志,因为对于同一个error,处理了两次。违背了Only handle errors once原则。比如:
+ 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码func GetUserInfo(id string) (UserInfo,error) {
url,err := GetIconUrl(id)
if err != nil {
log.Printf("查询用户头像失败 error:%v",err)
return nil,err
}
// ...... 查询其他信息后组装
}

func GetIconUrl(id string) (string,error) {
// 权限校验
ok,err :=verifyAuth(id)
// 这里即打印了日志,又返回了err。对于err处理了两次
if err != nil {
log.Printf("权限校验失败 error:%v",err)
return "",err
}
// iconDao.GetIconUrl(id)的返回值和此函数的返回值是match的,直接return就又少了一次if err != nil 生活小妙招
return iconDao.GetIconUrl(id)
}

// GetIconUrl中处理了两次error,其中包括一次打印。调用它的GetUserInfo又处理了两次,也包括一次打印。而GetUserInfo的上游调用又有可能处理两次,日志重复打印,形成日志噪音,不仅很难找需要的日志,还有可能让我们不小心忽视掉有用的错误。
  • 总结:错误处理的核心是:一个错误只处理一次,遇上错误不能恢复(如重试等)也不能吞掉(如服务降级)的时候,最好的方式就是创建新的error,包含此处的上下文和旧error的错误信息后向上抛出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码func GetUserInfo(id string) (UserInfo,error) {
url,err := GetIconUrl(id)
if err != nil {
err = fmt.Errorf("GetUserInfo 权限GetIconUrl校验失败 error:%s",err.Error())
return nil,err
}
// ...... 查询其他信息后组装
}

func GetIconUrl(id string) (string,error) {
ok,err :=verifyAuth(id)
if err != nil {
err = fmt.Errorf("GetIconUrl 权限校验失败 error:%s",err.Error())
return nil,err
}
return iconDao.GetIconUrl(id)
}
+ 打印日志的活交给调用链的最顶端集中处理。最终的打印出来的error是:
+ `GetUserInfo 权限GetIconUrl校验失败 error:GetIconUrl 权限校验失败 error:权限不足`
+ 其实这里error内部也包含了一些重复没必要的东西,后面讲wrap的时候会提到解决方法。
+ 但是go的error不像其他语言的exception,go创建新的error后,会丢失旧error的堆栈信息,不便于排查是找到出问题的那一行代码,倒是直接panic会保存住堆栈信息。很显然不可能因为需要堆栈的缘故用panic取代替error。[github.com/pkg/errors](https://github.com/pkg/errors)解决了这一问题。

Wrap errors

  • github.com/pkg/errors包让我们在创建新error的时候,不是直接放弃旧error。而是通过wrap的方式,新的error包装旧的,层层包装,哪一个error也不会被丢掉。

怎么用

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
go复制代码package main

import (
"fmt"
"log"
"os"

"github.com/pkg/errors"
)

// 这里存在一个重复wrap的问题,后面会说明如何避免。

// 这里只会简单的说明几个关键方法的用法,后文会分析它们的代码。

// 整个调用链是 main -> func3 -> func2 -> func1 main是调用链源头
// func1是出现根错误的地方,func2调用func1出现错误后秉承只处理一次的原则,直接return。func3类似操作。
// main作为调用链的源头,处理error的方式选择的是打印日志。
func main() {
err := func3()
if err != nil {
// Cause可以找到被Wrap的根err
log.Printf("original error:%T %v\n",errors.Cause(err),errors.Cause(err))
// 打印Wrap过的err 可以通过%+v获取到调用的堆栈信息
log.Printf("stack trace:\n%+v\n",err)
os.Exit(1)
}
}

func func3() error {
err := func2()
if err != nil {
// Wrap error可以携带上下文
return errors.Wrap(err,"fun3 call func2 error")
}
fmt.Println("i am func3")
return nil
}

func func2() error {
err := func1()
if err != nil {
// Wrap error可以携带上下文
return errors.Wrap(err,"func2 call func1 error")
}
fmt.Println("i am func2")
return nil
}

func func1() error {
// 产生根error
return errors.New("fun1 root error")
}

Wrap方法源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码// copy出Wrap的源码
func Wrap(err error, message string) error {
if err == nil {
return nil
}
// withMessage是附带上下文但是不丢失根error的原因
// 不像fmt.Errorf那样取出根error内部的string,组合上下文创建一个新error。
err = &withMessage{
cause: err,
msg: message,
}
// withStack是保住堆栈信息的原因
// err是withMessage,callers是堆栈信息
return &withStack{
err,
callers(),
}
}

Cause方法源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码// copy出Cause的源码
// Cause和Wrap方法是相反的,它的目的是找被包住的根error
func Cause(err error) error {
type causer interface {
Cause() error
}

// 一直往里面解开wrap,找到根error
for err != nil {
cause, ok := err.(causer)
if !ok {
break
}
err = cause.Cause()
}
return err
}

Wrap的使用注意

  • 在上面的例子中,如果在运行了就会发现,打印的堆栈信息特别多,而且重复了。这是因为对同一个error重复Wrap了。如果打印重复的堆栈,比起重复打印error本身造成的日志噪音问题更麻烦,毕竟堆栈信息一般都是很多的。
  • 这里举出几种使用注意事项,未来在代码中发现了新的坑也可以加上来。
    • 在开发应用代码时(比如一个web项目,而非是基础/工具库),使用errors.New或errors.Errorf返回一个新的错误。注意这里的errors不是标准库的errors,而是github.com/pkg/errors,它创建error时已经带上了堆栈信息了。
      • 如果是开发基础/工具库的时候,使用普通的error就好了。因为调用者会对error做Wrap的。
    • 如果调用同一包内的函数,简单返回error,不需要wrap。
      • 因为包内的函数在它的内部或者它调用链的下游发现根因的时候已经Wrap过了。
        • 这个思想就解决了之前处理error — Only handle errors once中总结部分出现的问题。那里对应包内函数返回的错误也加上了上下文信息,其实就重复了。
    • 和其他库协作 wrap保存根因
      • 当引入标准库或者github上的一些基础库时,如gorm。其他库返回的错误需要Wrap起来,并且带上足够的上下文信息。
  • 在程序的顶部或者是工作的goroutine顶部,使用%+v把堆栈详情记录
    • 示列代码中有过示范。

总结

  • 一个错误在他的整个生命流程中只需要处理一次,而不是发现一次处理一次。特别注意打印了错误日志也算处理过了。
  • error一旦被处理过了,就不算error了而是nil。
  • 当发现error又不打算处理时,使用Wrap带上足够的上下文往上抛即可。
  • 避免重复的Wrap。

Go1.13

  • 先看一个背景
+ 
1
2
3
4
5
6
7
8
9
go复制代码// 当我们对Sentinel error做等值判断时
if err == io.EOF {
// ***
}

// 当对Error type做类型断言时
if e,ok := err.(*os.PathError);ok {
// ***
}
  • github.com/pkg/errors的理念被Go官方采用了,于是在Go1.13后的版本,在fmt和errors做了改造。
+ fmt包出现了`%w占位符`:


    - 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go复制代码if err != nil {
return fmt.Errorf("需要附带的上下文信息 %w",err)
}

// 看下 %w背后做的事情 copy出fmt中源码
// 这看上去是不是和github.com/pkg/errors中的Wrap函数做的事情似成相识?
// %v的方式丢弃了原error除了文本信息之外的所有信息,而%w会将旧错误包装起来
type wrapError struct {
msg string
err error
}

func (e *wrapError) Error() string {
return e.msg
}

func (e *wrapError) Unwrap() error {
return e.err
}
+ errors包中出现了Is方法 -
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go复制代码// 在背景中提到过对Sentinel error做等值判断,看起来没什么不好
// 但是如果采用了%w对error做包装,等值判断就是失效了。如果使用的是%v,那就完蛋了,不可能再做判等了。
// 好在%w只是包装了,没有丢弃,而且errors包提供了UnWrap方法拿到内部的error

// errors.UnWrap源码 能看出其实和github.com/pkg/errors的Cause方法差不多
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}

// 如果调用UnWrap去拿根error,由于error可能被重复%w,所以这里会很麻烦。
// errors提供了Is(err,target error)方法。
// target就是指Sentinel error,如io.EOF。只要err中任意一层包含了target,就会返回true。不需要我们手动UnWrap了。

// 并且Is方法还有一个功能
// 我们在自定义Sentinel error时,可以为其自定义判等方法,而不单单是用==。
// 只要Sentinel error实现如下接口,errors提供的Is(err,target error)方法就会使用自定义的判等方法
// 接口 :interface{ Is(error) bool }
+ errors包中还出现了As方法 -
1
2
3
4
5
6
7
8
9
go复制代码// 和Is方法想解决的问题一样,As方法主要用于解决Error type的问题
// Error type需要对错误做类型断言
// errors提供了As(err error, target interface{})方法。
// target传如需要被断言的类型的指针。只要err中任意一层能断言成target,就会返回true。
// 用法:
var e *os.PathError
if errors.As(err,&e) {
// 如果断言成功 e就可以直接使用
}
  • 这样看来,似乎可以完全放弃github.com/pkg/errors了,但是%w没有保存堆栈信息(实现不知官方出于何种原因,都Wrap了还不带上堆栈)。
  • 好在github.com/pkg/errors对Go1.13做了兼容,就可以使用github.com/pkg/errors方法保存堆栈,用标准库errors的Is/As方法处理处理错误。
+ 
1
2
3
4
5
6
go复制代码// copy github.com/pkg/errors代码

// Unwrap provides compatibility for Go 1.13 error chains.
func (w *withStack) Unwrap() error { return w.error }

// 上述Is/As方法内部都是调用UnWrap方法,所以github.com/pkg/errors中的withStack实现了接口,那使用github.com/pkg/errors的Wrap方法替代标准库的%w就可以即留住堆栈又能使用Is/As方法。

总结

  • 错误处理是一个很大的话题,更是一个需要不断改进的话题。可以说没有哪一种语言或者哪一种思想就一定比其他的都要好,得到一些便利的时候往往会造成另一些不想遇到的问题。所以关于错误处理的话题讨论,远远没有结束!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%