Errors
错误处理是现实中经常碰到的、难以处理好的问题,下面会从下面几个方面探讨错误处理:
- 为什么Go没有选择异常,而是返回错误码(error)? 因为异常模型很难看出有没有写对,错误码方式也不容易,相对会简单点。
- Go的error有什么问题,为何Go2草案这么大篇幅说error改进? 因为Go虽然是错误码但还不够好,问题在于啰嗦、繁杂、缺失关键信息。
- 有哪些好用的error库,如何和日志配合使用? 推荐用库pkg/errors;另外,避免日志和错误混淆。
- Go的错误处理最佳实践是什么? 配合日志使用错误。错误需要带上上下文、堆栈等信息。
错误和异常
我们总会遇到非预期的非正常情况,有一种是符合预期的,比如函数返回error并处理,这种叫做可以预见到的错误,还有一种是预见不到的比如除零、空指针、数组越界等叫做panic,panic的处理主要参考Defer, Panic, and Recover。
错误处理的模型一般有两种,一般是错误码模型比如C/C++和Go,还有异常模型比如Java和C#。Go没有选择异常模型,因为错误码比异常更有优势,参考文章Cleaner, more elegant, and wrong 以及Cleaner, more elegant, and harder to recognize。看下面的代码:
try {
AccessDatabase accessDb = new AccessDatabase();
accessDb.GenerateDatabase();
} catch (Exception e) {
// Inspect caught exception
}
public void GenerateDatabase()
{
CreatePhysicalDatabase();
CreateTables();
CreateIndexes();
}
这段代码的错误处理有很多问题,比如如果CreateIndexes
抛出异常,会导致数据库和表不会删除,造成脏数据。从代码编写者和维护者的角度看这两个模型,会比较清楚:
Really Easy | Hard | Really Hard |
---|---|---|
Writing bad error-code-based code Writing bad exception-based code |
Writing good error-code-based code |
Writing good exception-based code |
错误处理不容易做好,要说容易那说明做错了;要把错误处理写对了,基于错误码模型虽然很难,但比异常模型简单。
Really Easy | Hard | Really Hard |
---|---|---|
Recognizing that error-code-based code is badly-written Recognizing the difference between bad error-code-based code and not-bad error-code-based code. |
Recognizing that error-code-base code is not badly-written |
Recognizing that exception-based code is badly-written Recognizing that exception-based code is not badly-written Recognizing the difference between bad exception-based code and not-bad exception-based code |
如果使用错误码模型,非常容易就能看出错误处理没有写对,也能很容易知道做得好不好;要知道是否做得非常好,错误码模型也不太容易。
如果使用异常模型,无论做的好不好都很难知道,而且也很难知道怎么做好。
Errors in Go
Go官方的error介绍,简单一句话就是返回错误对象的方式,参考Error handling and Go,解释了error是什么,如何判断具体的错误,显式返回错误的好处。文中举的例子就是打开文件错误:
func Open(name string) (file *File, err error)
Go可以返回多个值,最后一个一般是error,我们需要检查和处理这个错误,这就是Go的错误处理的官方介绍:
if err := Open("src.txt"); err != nil {
// Handle err
}
看起来非常简单的错误处理,有什么难的呢?骚等,在Go2的草案中,提到的三个点Error Handling、Error Values和Generics泛型,两个点都是错误处理的,这说明了Go1中对于错误是有改进的地方。
再详细看下Go2的草案,错误处理:Error Handling中,主要描述了发生错误时的重复代码,以及不能便捷处理错误的情况。比如草案中举的这个例子No Error Handling: CopyFile,没有做任何错误处理:
package main
import (
"fmt"
"io"
"os"
)
func CopyFile(src, dst string) error {
r, _ := os.Open(src)
defer r.Close()
w, _ := os.Create(dst)
io.Copy(w, r)
w.Close()
return nil
}
func main() {
fmt.Println(CopyFile("src.txt", "dst.txt"))
}
还有草案中这个例子Not Nice and still Wrong: CopyFile,错误处理是特别啰嗦,而且比较明显有问题:
package main
import (
"fmt"
"io"
"os"
)
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()
if _, err := io.Copy(w, r); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return nil
}
func main() {
fmt.Println(CopyFile("src.txt", "dst.txt"))
}
当io.Copy
或w.Close
出现错误时,目标文件实际上是有问题,那应该需要删除dst文件的。而且需要给出错误时的信息,比如是哪个文件,不能直接返回err。所以Go中正确的错误处理,应该是这个例子Good: CopyFile,虽然啰嗦繁琐不简洁:
package main
import (
"fmt"
"io"
"os"
)
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if _, err := io.Copy(w, r); err != nil {
w.Close()
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if err := w.Close(); err != nil {
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
return nil
}
func main() {
fmt.Println(CopyFile("src.txt", "dst.txt"))
}
具体应该如何简洁的处理错误,可以读Error Handling,大致是引入关键字handle和check,由于本文重点侧重Go1如何错误处理,就不展开分享了。
明显上面每次都返回的fmt.Errorf
信息也是不够的,所以Go2还对于错误的值有提案,参考Error Values。大规模程序应该面向错误编程和测试,同时错误应该包含足够的信息。Go1中判断error具体是什么错误有几种办法:
- 直接比较,比如返回的是
io.EOF
这个全局变量,那么可以直接比较是否是这个错误。 - 可以用类型转换type或switch,尝试来转换成具体的错误类型,看是哪种错误。
- 提供某些函数来判断是否是某个错误,比如
os.IsNotExist
判断是否是指定错误。 - 当多个错误被糅合到一起时,只能用
error.Error()
返回的字符串匹配,看是否是某个错误。
在复杂程序中,有用的错误需要包含调用链的信息。例如,考虑一次数据库写,可能调用了RPC,RPC调用了域名解析,最终是没有权限读/etc/resolve.conf
文件,那么给出下面的调用链会非常有用:
write users database: call myserver.Method: \
dial myserver:3333: open /etc/resolv.conf: permission denied
Errors Solutions
由于Go1的错误值没有完整的解决这个问题,才导致出现非常多的错误处理的库,比如:
- 2017, 12, upspin.io/errors,带逻辑调用堆栈的错误库,而不是执行的堆栈,引入了
errors.Is
、errors.As
和errors.Match
。 - 2015.12, github.com/pkg/errors,带堆栈的错误,引入了
%+v
来格式化错误的额外信息比如堆栈。 - 2014.10, github.com/hashicorp/errwrap,可以wrap多个错误,引入了错误树,提供Walk函数遍历所有的错误。
- 2014.2, github.com/juju/errgo,Wrap时可以选择是否隐藏底层错误。和
pkg/errors
的Cause返回最底层的错误不同,它只反馈错误链的下一个错误。 - 2013.7, github.com/spacemonkeygo/errors,是来源于一个大型Python项目,有错误的hierarchies,自动记录日志和堆栈,还可以带额外的信息。打印错误的消息比较固定,不能自己定义。
- 2019.09,Go1.13标准库扩展了error,支持了Unwrap、As和Is,但没有支持堆栈信息。
Go1.13改进了errors,参考如下实例代码:
package main
import (
"errors"
"fmt"
"io"
)
func foo() error {
return fmt.Errorf("read err: %w", io.EOF)
}
func bar() error {
if err := foo(); err != nil {
return fmt.Errorf("foo err: %w", err)
}
return nil
}
func main() {
if err := bar(); err != nil {
fmt.Printf("err: %+v\n", err)
fmt.Printf("unwrap: %+v\n", errors.Unwrap(err))
fmt.Printf("unwrap of unwrap: %+v\n", errors.Unwrap(errors.Unwrap(err)))
fmt.Printf("err is io.EOF? %v\n", errors.Is(err, io.EOF))
}
}
运行结果如下:
err: foo err: read err: EOF
unwrap: read err: EOF
unwrap of unwrap: EOF
err is io.EOF? true
从上面的例子可以看出:
- 没有堆栈信息,主要是想通过Wrap的日志来标识堆栈,如果全部Wrap一层和堆栈差不多,不过对于没有Wrap的错误还是无法知道调用堆栈。
- Unwrap只会展开第一个嵌套的error,如果错误有多层嵌套,取不到最里面的那个error,需要多次Unwrap才行。
- 用
errors.Is
能判断出是否是最里面的那个error。
另外,错误处理往往和log是容易混为一谈的,因为遇到错误一般会打日志,特别是在C/C++中返回错误码一般都会打日志记录下,有时候还会记录一个全局的错误码比如linux的errno,而这种习惯,造成了error和log混淆造成比较大的困扰。考虑以前写了一个C++的服务器,出现错误时会在每一层打印日志,所以就会形成堆栈式的错误日志,便于排查问题,如果只有一个错误,不知道调用上下文,排查会很困难:
avc decode avc_packet_type failed. ret=3001
Codec parse video failed, ret=3001
origin hub error, ret=3001
这种比只打印一条日志origin hub error, ret=3001
要好,但是还不够好:
- 和Go的错误一样,比较啰嗦,有重复的信息。如果能提供堆栈信息,可以省去很多需要手动写的信息。
- 对于应用程序可以打日志,但是对于库,信息都应该包含在error中,不应该直接打印日志。如果底层的库都要打印日志,那会导致底层库都要依赖日志库,这是很多库都有日志打印函数供调用者重写。
- 对于多线程,看不到线程信息,或者看不到业务层ID的信息。对于服务器来说,有时候需要知道这个错误是哪个连接的,从而查询这个连接之前的上下文信息。
改进后的错误日志变成了在底层返回,而不在底层打印在调用层打印,有调用链和堆栈,有线程切换的ID信息,也有文件的行数:
Error processing video, code=3001 : origin hub : codec parser : avc decoder
[100] video_avc_demux() at [srs_kernel_codec.cpp:676]
[100] on_video() at [srs_app_source.cpp:1076]
[101] on_video_imp() at [srs_app_source:2357]
从Go2的描述来说,实际上这个错误处理也还没有考虑完备。从实际开发来说,已经比较实用了。
总结下Go的error,错误处理应该注意的点:
- 凡是有返回错误码的函数,必须显式的处理错误,如果要忽略错误,也应该显式的忽略和写注释。
- 错误必须带丰富的错误信息,比如堆栈,发生错误时的参数,调用链给的描述等等。特别要强调变量,我看过太多日志描述了一对常量,比如"Verify the nonce, timestamp and token of specified appid failed",而这个消息一般会提到工单中,然后就是再问用户,哪个session或request甚至时间点?这么一大堆常量有啥用呢,关键是变量,关键是变量呐。
- 尽量避免重复的信息,提高错误处理的开发体验,糟糕的体验会导致无效的错误处理代码比如拷贝和漏掉关键信息。
- 分离错误和日志,发生错误时返回带完整信息的错误,在调用的顶层决定是将错误用日志打印,还是发送到监控系统,还是转换错误,或者忽略。
Best Practice
推荐用github.com/pkg/errors这个错误处理的库,基本上是够用的,参考Refine: CopyFile,可以看到CopyFile中低级重复的代码已经比较少了:
package main
import (
"fmt"
"github.com/pkg/errors"
"io"
"os"
)
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return errors.Wrap(err, "open source")
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return errors.Wrap(err, "create dest")
}
nn, err := io.Copy(w, r)
if err != nil {
w.Close()
os.Remove(dst)
return errors.Wrap(err, "copy body")
}
if err := w.Close(); err != nil {
os.Remove(dst)
return errors.Wrapf(err, "close dest, nn=%v", nn)
}
return nil
}
func LoadSystem() error {
src, dst := "src.txt", "dst.txt"
if err := CopyFile(src, dst); err != nil {
return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
}
// Do other jobs.
return nil
}
func main() {
if err := LoadSystem(); err != nil {
fmt.Printf("err %+v\n", err)
}
}
改写的函数中,用
errors.Wrap
和errors.Wrapf
代替了fmt.Errorf
,我们不记录src和dst的值,因为在上层会记录这个值(参考下面的代码),而只记录我们这个函数产生的数据,比如nn
。
import "github.com/pkg/errors"
func LoadSystem() error {
src, dst := "src.txt", "dst.txt"
if err := CopyFile(src, dst); err != nil {
return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
}
// Do other jobs.
return nil
}
在这个上层函数中,我们用的是
errors.WithMessage
添加了这一层的错误信息,包括src
和dst
,所以CopyFile
里面就不用重复记录这两个数据了。同时我们也没有打印日志,只是返回了带完整信息的错误。
func main() {
if err := LoadSystem(); err != nil {
fmt.Printf("err %+v\n", err)
}
}
在顶层调用时,我们拿到错误,可以决定是打印还是忽略还是送监控系统。
比如我们在调用层打印错误,使用%+v
打印详细的错误,有完整的信息:
err open src.txt: no such file or directory
open source
main.CopyFile
/Users/winlin/t.go:13
main.LoadSystem
/Users/winlin/t.go:39
main.main
/Users/winlin/t.go:49
runtime.main
/usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:185
runtime.goexit
/usr/local/Cellar/go/1.8.3/libexec/src/runtime/asm_amd64.s:2197
load src=src.txt, dst=dst.txt
但是这个库也有些小毛病:
-
CopyFile
中还是有可能会有重复的信息,还是Go2的handle
和check
方案是最终解决。 - 有时候需要用户调用
Wrap
,有时候是调用WithMessage
(不需要加堆栈时),这个真是非常不好用的地方(这个我们已经修改了库,可以全部使用Wrap不用WithMessage,会去掉重复的堆栈)。
Links
由于简书限制了文章字数,只好分成不同章节:
- Overview 为何Go有时候也叫Golang?为何要选择Go作为服务器开发的语言?是冲动?还是骚动?Go的重要里程碑和事件,当年吹的那些牛逼,都实现了哪些?
- Could Not Recover 君可知,有什么panic是无法recover的?包括超过系统线程限制,以及map的竞争写。当然一般都能recover,比如Slice越界、nil指针、除零、写关闭的chan等。
- Errors 为什么Go2的草稿3个有2个是关于错误处理的?好的错误处理应该怎么做?错误和异常机制的差别是什么?错误处理和日志如何配合?
- Logger 为什么标准库的Logger是完全不够用的?怎么做日志切割和轮转?怎么在混成一坨的服务器日志中找到某个连接的日志?甚至连接中的流的日志?怎么做到简洁又够用?
- Interfaces 什么是面向对象的SOLID原则?为何Go更符合SOLID?为何接口组合比继承多态更具有正交性?Go类型系统如何做到looser, organic, decoupled, independent, and therefore scalable?一般软件中如果出现数学,要么真的牛逼要么装逼。正交性这个数学概念在Go中频繁出现,是神仙还是妖怪?为何接口设计要考虑正交性?
- Modules 如何避免依赖地狱(Dependency Hell)?小小的版本号为何会带来大灾难?Go为什么推出了GOPATH、Vendor还要搞module和vgo?新建了16个仓库做测试,碰到了9个坑,搞清楚了gopath和vendor如何迁移,以及vgo with vendor如何使用(毕竟生产环境不能每次都去外网下载)。
- Concurrency & Control 服务器中的并发处理难在哪里?为什么说Go并发处理优势占领了云计算开发语言市场?什么是C10K、C10M问题?如何管理goroutine的取消、超时和关联取消?为何Go1.7专门将context放到了标准库?context如何使用,以及问题在哪里?
- Engineering Go在工程化上的优势是什么?为什么说Go是一门面向工程的语言?覆盖率要到多少比较合适?什么叫代码可测性?为什么良好的库必须先写Example?
- Go2 Transition Go2会像Python3不兼容Python2那样作吗?C和C++的语言演进可以有什么不同的收获?Go2怎么思考语言升级的问题?
- SRS & Others Go在流媒体服务器中的使用。Go的GC靠谱吗?Twitter说相当的靠谱,有图有真相。为何Go的声明语法是那样?C的又是怎样?是拍的大腿,还是拍的脑袋?