任何语言,错误处理都是至关重要的,开发人员只有学会正确地处理错误,才可能写出健壮的程序。本文主要介绍使用 Golang 这门语言在错误处理方面的一些实践,首先来看一下我们可能遇到的一些问题。
问题
Go 代码中常见的错误处理片段,被不少人诟病,可能处理业务逻辑的核心代码没几行,类似语句却写了一大堆 :-(
if err != nil {
    return err
}
或者(当然,还有这样的,仅抛给上层调用方哪儿够,得自己也打印一份日志 :-) )
if err != nil {
    logs.CtxError(ctx, "failed to xxx: %s", err)
    return err
}
错误处理是很重要的,Go 语言鼓励开发人员当可能发生错误时,去明确地检查错误,这可能使得代码很冗长,不过本文介绍的一些方法可以简化重复的错误处理工作。
(吐槽时间) (balabalabala…)
总结一下:
- 缺少错误上下文和堆栈信息;
 - 冗余,代码冗长,主逻辑割裂;
 - 分层开发,日志泛滥;
 
是什么
接下来先了解一下 Go 的 error 类型,然后我们再想办法逐一解决上述问题。Go 社区流行很多“谚语”,其中有一句和“错误”有关的,来自 Go 语言合作者 Rob Pike 大神。
Errors are values.
by Rob Pike
error 是内置类型,定义为一个 interface。
type error interface {
    Error() string
}
标准库 errors 包提供了一个默认错误类型 errorString,借助一个字符串字段保存错误信息,开发者可以通过 errors.New("xyz") 创建一个“标准”错误。
// 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
}
func (e *errorString) Error() string {
        return e.s
}
此外,fmt 包还提供了一个函数 Errorf,用来创建格式化“错误”,允许开发人员添加一些上下文信息,这种方法更常用。
当然,开发者也可以自定义错误类型,通过实现 error 接口,为程序“错误”提供更详细的上下文信息, 如标准库的 PathError:
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
    Op string    // "open", "unlink", etc.
    Path string  // The associated file.
    Err error    // Returned by the system call.
}
func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}
调用出错(如打开一个不存在的文件)时,可能看到的提示:
open /not_exists: no such file or directory
此外,还可以通过类型断言获取具体错误类型,进行检查并处理。
for try := 0; try < 2; try++ {
    file, err = os.Create(filename)
    if err == nil {
        return
    }
    if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
        deleteTempFiles()  // Recover some space.
        continue
    }
    return
}
为什么
我们再来简单看下 Go 语言的前辈们是如何处理错误的?一般有两种方法:
- 返回值检查
 - 异常机制
 
分别以 C 和 Java 两种语言将“字符串转为整型”为例进行说明:
// 当返回值=0,无法区分转换是否成功
int atoi(const char *str);
// 通过errno、返回值以及出参endptr判断
long int strtol(const char *nptr, char **endptr, int base);
// errno:
// EINVAL
// ERANGE
 // 
    result = strtol(value, &eptr, 10);
    if (result == 0)
    {
        if (errno == EINVAL)
        {
            printf("Conversion error occurred: %d\n", errno);
            exit(0);
        }
    }
    if (result == LONG_MIN || result == LONG_MAX)
    {
        if (errno == ERANGE)
            printf("The value provided was out of range\n");
    }
    //...
C 只能有一个返回值
- 无法区分正常和异常返回;
 - 引入“出参”形式,函数声明变得更复杂;
 
// 抛异常
public static int parseInt(String s, int radix)
                    throws NumberFormatException
                    
    try {
            int result = Integer.parseInt("123abcd", 10);
            System.out.println(result);
            // other logic
        } catch (Exception e) {
            e.printStackTrace();
        }
 // java.lang.NumberFormatException: For input string: "123abcd"
 //       at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:68)
 //       at java.base/java.lang.Integer.parseInt(Integer.java:652)
 //       at Hello.main(Hello.java:6)
Java 异常机制 try-catch-finally
- 错误处理和返回值完全分开;
 - 正常代码与错误处理逻辑分开,提高可读性;
 - “受检”异常不能被忽略,需要显示声明并处理。
 
func Atoi(s string) (int, error)
func ParseInt(s string, base int, bitSize int) (i int64, err error)
    result, err := strconv.ParseInt("123abcd", 10, 64)
    if err != nil {
        fmt.Println(err)
        return
    }
    // strconv.ParseInt: parsing "123abcd": invalid syntax
Go 错误处理本质上也是通过检查返回值实现的,不过:
- Go 支持多值返回,可以将业务返回值和错误返回值区分开;
 - 可以用 
_显式忽略错误; - 并且定义了内置的 
error类型,方便开发者进行扩展; 
使用“异常”有不少优势,为啥 Go 不这么做呢?
我们认为,将“异常”处理耦合到控制结构(如 try-catch-finally)会导致代码混乱。它还倾向于鼓励程序员将太多的常见错误(例如,无法打开文件)标记为“异常”的。
From Go 官方 FAQ
那么 Go 认为的“异常”情况是怎样的呢?
当 Go 程序出现不可恢复的运行时错误,会抛出 panic(直译“恐慌”),如:
- 索引越界;
 - 类型断言失败等;
 
注:一般不建议使用 panic 作为 Go 的错误处理方法。
不过初始化程序时可以使用,如程序启动依赖的必要条件不能满足时:
var user = os.Getenv("USER")
func init() {
    if user == "" {
        panic("no value for $USER")
    }
}
怎么做
接下来我们来看下如何解决一开始提到的问题:
1. 常用包
- Dave pkg/errors
 - Go 1.3 或以上标准库 errors和 fmt
 

推荐第1种,能记录错误堆栈信息;


标准库 errors 使用举例:
func main() {
        e1 := errors.New("1st error")
        e2 := fmt.Errorf("2nd: %w", e1)
        e3 := fmt.Errorf("3rd: %w", e2)
        e4 := fmt.Errorf("4th: %w", e3)
        fmt.Println(e1)
        fmt.Println(e2)
        fmt.Println(e3)
        fmt.Println(e4)
        fmt.Println("====================================")
        e5 := errors.Unwrap(e4)
        fmt.Println("e5 == e3?", e5 == e3)
        fmt.Println("e5 Is e3?", errors.Is(e5, e3))
        fmt.Println("====================================")
        fmt.Println("e5 == e1?", e5 == e1)
        fmt.Println("e5 Is e1?", errors.Is(e5, e1))
  
// output:      
// 1st error
// 2nd: 1st error
// 3rd: 2nd: 1st error
// 4th: 3rd: 2nd: 1st error
// ====================================
// e5 == e3? true
// e5 Is e3? true
// ====================================
// e5 == e1? false
// e5 Is e1? true
}
type MyError struct {
        err string
}
func (e *MyError) Error() string {
        return e.err
}
func main() {
        e1 := &MyError{"1st error"}
        e2 := fmt.Errorf("2nd: %w", e1)
        e3 := fmt.Errorf("3rd: %w", e2)
        e4 := fmt.Errorf("4th: %w", e3)
        fmt.Println(e1)
        fmt.Println(e2)
        fmt.Println(e3)
        fmt.Println(e4)
        
        fmt.Println("====================================")
        var err5 *MyError
        fmt.Println(errors.As(e4, &err5))
        fmt.Println(err5)
// output: 
// 1st error
// 2nd: 1st error
// 3rd: 2nd: 1st error
// 4th: 3rd: 2nd: 1st error
// ====================================
// true
// 1st error
}
2. 更优雅地处理错误
示例 1:统计文件行数
func CountLines(r io.Reader) (int, error) {
        var (
                br    = bufio.NewReader(r)
                lines int
                err   error
        )
        for {
                 _, err = br.ReadString('\n')
                 lines++
                 if err != nil {
                     break
                 }
        }
        if err != io.EOF {
                return 0, err
        }
        return lines, nil
 }
(注:仅作错误处理说明用,以上代码存在 bug,如空文件,统计行数为 1)
我们也许可以从标准库 bufio 包 Scanner 类型找找灵感,Scan 方法并未直接返回 error 类型,而是返回了一个 boolean 类型,还提供了一个 Err 方法返回发生的错误。
func CountLines(r io.Reader) (int, error) {
        sc := bufio.NewScanner(r)
        lines := 0
        for sc.Scan() {
                lines++
        }
        return lines, sc.Err()
}
将错误处理与主流程分开,提升代码可读性。
示例 2:
type Header struct {
        Key, Value string
}
type Status struct {
        Code   int
        Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
        if err != nil {
                return err
        }
        for _, h := range headers {
                _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
                if err != nil {
                        return err
                }
        }
        if _, err := fmt.Fprint(w, "\r\n"); err != nil {
                return err
        } 
        _, err = io.Copy(w, body) 
        return err
}
改造之后
type errWriter struct {
        io.Writer
        err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
        if e.err != nil {
                return 0, e.err
        }
        var n int
        n, e.err = e.Writer.Write(buf)
        return n, nil
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        ew := &errWriter{Writer: w} 
        fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
        for _, h := range headers {
               fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
        }
        fmt.Fprint(ew, "\r\n")
        io.Copy(ew, body)
        return ew.err
}
另外大家用到的 Gorm 框架,能进行链式调用,也采用了类似的实现思路。
// DB GORM DB definition
type DB struct {
   *Config
   Error        error
   RowsAffected int64
   Statement    *Statement
   clone        int
}
lead := &Lead{LockState: Unlocked}
db.WithContext(ctx).Table(tbl).
   Select("lock_state").
   Where("id=? AND lock_state=?", id, Locked).Updates(lead).Error
每一个方法都返回*DB类型,没有额外返回error。
3. 仅在最上层调用打日志
func main() {
   err := parseConf("not_exist.json")
   if err != nil {
      log.Printf("parse conf: %s", err)
      return
   }
}
func parseConf(name string) error {
   content, err := readFile(name)
   if err != nil {
      log.Printf("read file: %s", err)
      return err
   }
   // Parse JSON content.
   _ = content
   return nil
}
func readFile(name string) ([]byte, error) {
   f, err := os.Open(name)
   if err != nil {
      log.Printf("open file: %s", err)
      return nil, err
   }
   defer f.Close()
   buf := make([]byte, 0, 512)
   // Read the file.
   return buf, nil
}
// 2021/05/14 18:43:05 open file: open not_exist.json: no such file or directory
// 2021/05/14 18:43:05 read file: open not_exist.json: no such file or directory
// 2021/05/14 18:43:05 parse conf: open not_exist.json: no such file or directory
package main
import (
   "log"
   "os"
   "github.com/pkg/errors"
)
func main() {
   err := parseConf("not_exist.json")
   if err != nil {
      log.Printf("parse conf: %+v", err)
      return
   }
}
func parseConf(name string) error {
   content, err := readFile(name)
   if err != nil {
      return err
   }
   // Parse JSON content.
   _ = content
   return nil
}
func readFile(name string) ([]byte, error) {
   f, err := os.Open(name)
   if err != nil {
      return nil, errors.Wrap(err, "open file")
   }
   defer f.Close()
   buf := make([]byte, 0, 512)
   // Read the file.
   return buf, nil
}
// 2021/05/14 18:46:36 parse conf: open not_exist.json: no such file or directory
// open file
// main.readFile
//        /Users/marvel/Workspace/errors/test.go:32
// main.parseConf
//        /Users/marvel/Workspace/errors/test.go:19
// main.main
//        /Users/marvel/Workspace/errors/test.go:11
// runtime.main
//         /Users/marvel/sdk/go1.16/src/runtime/proc.go:225
// runtime.goexit
//        /Users/marvel/sdk/go1.16/src/runtime/asm_amd64.s:1371
HTTP 服务还可以利用日志中间件实现,在 API 最顶层打印日志。
4. 通过 panic 和 recover 进行错误处理
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
    return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
    panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // doParse will panic if there is a parse error.
    defer func() {
        if e := recover(); e != nil {
            regexp = nil    // Clear return value.
            err = e.(Error) // Will re-panic if not a parse error.
        }
    }()
    return regexp.doParse(str), nil
}
recover 只能在 defer 函数体内使用。
if pos == 0 {
    re.error("'*' illegal at start of expression")
}
局限性:只能用在同一个包内,将内部产生的 panic 转为 error 返回给调用者;而不能主动抛 panic 给调用者。
5. 并发场景下的错误处理
可以使用官方工具包 errgroup ,对 sync.WaitGroup 进行了巧妙的封装,简化了同步处理逻辑,同时允许并发执行的子任务将可能出现的错误返回给调用方。
func main() {
        g := new(errgroup.Group)
        var urls = []string{
                "http://www.golang.org/",
                "http://www.google.com/",
                "http://www.somestupidname.com/",
        }
        for _, url := range urls {
                // Launch a goroutine to fetch the URL.
                url := url // https://golang.org/doc/faq#closures_and_goroutines
                g.Go(func() error {
                        // Fetch the URL.
                        resp, err := http.Get(url)
                        if err == nil {
                               resp.Body.Close()
                        }
                        return err
                })
        }
        // Wait for all HTTP fetches to complete.
        if err := g.Wait(); err == nil {
                fmt.Println("Successfully fetched all URLs.")
       }
}
6. 一些规范
错误描述小写开头,加前缀(如包名),如
image: unknown format自定义错误类型以
Error结尾,变量以Err或err开头;函数(或方法)最后一个返回值返回
error类型,不要返回具体的错误类型;自定义
Error接口,包含其他判别具体错误的方法;
package net
type Error interface {
    error
    Timeout() bool   // Is the error a timeout?
    Temporary() bool // Is the error temporary?
}
7. 特别注意的坑
type MyError struct {
  code int
  msg string
}
func (e *MyError) Error() string {
  return fmt.Sprintf("code=%d, msg=%s", e.code, e.msg) 
}
var ErrBad = &MyError{code: 500, msg: "something bad occurs"}
// Bad
func Handle() error {
  var err *MyError = nil
  if bad() {
    err = ErrBad
  }
  return err
}
// Good
func Handle() error {
  if bad() {
    return ErrBad
  }
  return nil
}
参考
- https://golang.org/doc/effective_go#errors
 - https://blog.golang.org/error-handling-and-go
 - https://blog.golang.org/errors-are-values
 - https://github.com/golang/go/wiki/Errors
 - https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully
 - https://dave.cheney.net/2019/01/27/eliminate-error-handling-by-eliminating-errors
 - https://blog.golang.org/go1.13-errors
 - https://www.sohu.com/a/342949702_657921
 - Go语言中的错误处理(Error Handling in Go)
 - https://dave.cheney.net/paste/gocon-spring-2016.pdf
 - https://coolshell.cn/articles/21140.html
 - https://blog.golang.org/defer-panic-and-recover
 
