美文网首页Go
Go开发关键技术指南:Errors

Go开发关键技术指南:Errors

作者: winlinvip | 来源:发表于2020-01-10 21:09 被阅读0次

    Errors

    错误处理是现实中经常碰到的、难以处理好的问题,下面会从下面几个方面探讨错误处理:

    错误和异常

    我们总会遇到非预期的非正常情况,有一种是符合预期的,比如函数返回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 HandlingError ValuesGenerics泛型,两个点都是错误处理的,这说明了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.Copyw.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.Iserrors.Aserrors.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要好,但是还不够好:

    1. 和Go的错误一样,比较啰嗦,有重复的信息。如果能提供堆栈信息,可以省去很多需要手动写的信息。
    2. 对于应用程序可以打日志,但是对于库,信息都应该包含在error中,不应该直接打印日志。如果底层的库都要打印日志,那会导致底层库都要依赖日志库,这是很多库都有日志打印函数供调用者重写。
    3. 对于多线程,看不到线程信息,或者看不到业务层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,错误处理应该注意的点:

    1. 凡是有返回错误码的函数,必须显式的处理错误,如果要忽略错误,也应该显式的忽略和写注释。
    2. 错误必须带丰富的错误信息,比如堆栈,发生错误时的参数,调用链给的描述等等。特别要强调变量,我看过太多日志描述了一对常量,比如"Verify the nonce, timestamp and token of specified appid failed",而这个消息一般会提到工单中,然后就是再问用户,哪个session或request甚至时间点?这么一大堆常量有啥用呢,关键是变量,关键是变量呐。
    3. 尽量避免重复的信息,提高错误处理的开发体验,糟糕的体验会导致无效的错误处理代码比如拷贝和漏掉关键信息。
    4. 分离错误和日志,发生错误时返回带完整信息的错误,在调用的顶层决定是将错误用日志打印,还是发送到监控系统,还是转换错误,或者忽略。

    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.Wraperrors.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添加了这一层的错误信息,包括srcdst,所以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
    

    但是这个库也有些小毛病:

    1. CopyFile中还是有可能会有重复的信息,还是Go2的handlecheck方案是最终解决。
    2. 有时候需要用户调用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的又是怎样?是拍的大腿,还是拍的脑袋?

    相关文章

      网友评论

        本文标题:Go开发关键技术指南:Errors

        本文链接:https://www.haomeiwen.com/subject/hhbmactx.html