ᕕ( ᐛ )ᕗ Jimyag's Blog

Go如何优雅进行错误处理

介绍Go语言如何优雅的处理错误。

Error vs Exception

Go 的处理异常逻辑是不引入 exception,支持多参数返回,所以你很容易的在函数签名中带上实现了 error interface 的对象,交由调用者来判定。

如果一个函数返回了(value, error),你不能对这个 value 做任何假设,必须先判定 error。唯一可以忽略 error的情况就是,如果你连 value也不关心。

Go 中有 panic的机制,如果你认为和其他语言的 exception一样,那你就错了。

当我们抛出异常的时候,相当于你把 exception 扔给了调用者来处理。比如,你在 C++ 中,把 string 转为 int,如果转换失败,会抛出异常。或者在 Java 中转换 String 为 Date 失败时,会抛出异常。

Go panic 意味着 fatal error(就是挂了)。不能假设调用者来解决 panic,意味着代码不能继续运行。使用多个返回值和一个简单的约定,Go 解决了让程序员知道什么时候出了问题,并为真正的异常情况保留了 panic。panic和recover不要一起使用,自己panic了就不要recover了但是第三方库的panic有时候需要recover

对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才使用 panic。对于其他的错误情况,我们应该是期望使用 error 来进行判定。

You only need to check the error value if you care about the result. – Dave

This blog post from Microsoft’s engineering blog in 2005 still holds true today, namely:

My point isn’t that exceptions are bad. My point is that exceptions are too hard and I’m not smart enough to handle them.

Sentinel Error

预定义的特定错误,我们叫为 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 Go,我们使用特定的值来表示错误。

if err == ErrSomething {  }

类似的 io.EOF,或者更底层的 syscall.ENOENT

使用 sentinel 值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当您想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查

甚至是一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。 所以要不依赖于检查 error.Error 的输出。

Error types

Error type 是实现了 error 接口的自定义类型。例如 MyError 类型记录了文件和行号以展示发生了什么。

type MyError struct {
 Msg  string
 File string
 Line int
}

func (e *MyError) Error() string {
 return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Msg)
}
func test() error {
 return &MyError{"test", "main.go", 10}
}

因为 MyError 是一个 type,调用者可以使用断言转换成这个类型,来获取更多的上下文信息。

func main() {
 err := test()
 switch err.(type) {
 case nil:
  // do nothing
 case *MyError:
  fmt.Println(err.(*MyError).Msg)
 default:
  fmt.Println(err)
 }

与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。 一个不错的例子就是 os.PathError 它提供了底层执行了什么操作、那个路径出了什么问题。

// go1.18/src/io/fs/fs.go:242
// 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 }

// Timeout reports whether this error represents a timeout.
func (e *PathError) Timeout() bool {
 t, ok := e.Err.(interface{ Timeout() bool })
 return ok && t.Timeout()
}

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

结论是尽量避免使用 error types,虽然错误类型比 sentinel errors 更好,因为它们可以捕获关于出错的更多上下文,但是 error types 共享 error values 许多相同的问题。因此,我(啊别人的)的建议是避免错误类型,或者至少避免将它们作为公共 API 的一部分。

Opaque errors

在我看来,这是最灵活的错误处理策略,因为它要求代码和调用者之间的耦合最少。因为虽然您知道发生了错误,但您没有能力看到错误的内部。作为调用者,关于操作的结果,您所知道的就是它起作用了,或者没有起作用(成功还是失败)。这就是不透明错误处理的全部功能–只需返回错误而不假设其内容。

package main

import "github.com/quux/bar"

func fn()error {
 x,err:=bar.Foo()
 if err!=nil {
  return err
 }
 return nil
}

func main() {
 fn()
}

Assert errors for behaviour, not type

在少数情况下,这种二分错误处理方法是不够的。例如,与进程外的世界进行交互(如网络活动),需要调用方调查错误的性质,以确定重试该操作是否合理。在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。考虑这个例子:

type temporary interface {
 Temporary() bool
}

func IsTemporary(err error) bool {
 if temp, ok := err.(temporary); ok {
  return ok && temp.Temporary()
 }
 return false
}

这里的关键是,这个逻辑可以在不导入定义错误的包或者实际上不了解 err 的底层类型的情况下实现——我们只对它的行为感兴趣。

Handling Error

Indented flow is for errors

无错误的正常流程代码,将成为一条直线,而不是缩进的代码。

f,err:=os.Open(path)
if err!=nil{
    // 处理错误
}
// 逻辑


f,err:=os.Open(path)
if err==nil{
 // 逻辑    
}
// 处理错误

Eliminate error handling by eliminating errors

下面的代码有啥问题?

func AuthRequest(r *Request)error{
    err:=auth(r.User)
    if err!=nil{
        return err
    }
    return nil
}

func AuthRequest(r *Request)error{
    return auth(r.User)
}

统计 io.Reader 读取内容的行数,处理了两次错误

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
}

改进版,只需要接受最终有无错误就行

func CountLines(r io.Reader)(int,error){
    sc:=bufio.NewScanner(r)
    lines:=0
    for sc.Scan(){
        lines++
    }
    return lines,sc.Err()
}

例如下面的例子,总共要处理4次错误

type Header struct {
 Key, Value string
}

type Status struct {
 Code   int
 Reason string
}

func WriteResponse(w *io.Writer, status Status, headers []Header, body io.Reader) error {
 _, err := fmt.Fprint(*w, "HTTP/1.1", status.Code, status.Reason)
 if err != nil {
  return err
 }
 for _, h := range headers {
  _, err := fmt.Fprint(*w, "\n", h.Key, ":", h.Value)
  if err != nil {
   return err
  }
 }
 _, err = fmt.Fprint(*w, "\n\n")
 if err != nil {
  return err
 }
 _, err = io.Copy(*w, body)
 return err
}

经过改进,将错误处理统一集中到Write中。并最终返回,看起来好像没有处理错误一样。

type errWrite struct {
 io.Writer
 err error
}

func (e *errWrite) Write(p []byte) (n int, err error) {
 if e.err != nil {
  return 0, e.err
 }
 n, e.err = e.Writer.Write(p)
 return n, e.err
}

func WriteResponse(w *io.Writer, status Status, headers []Header, body io.Reader) error {
 ew := &errWrite{Writer: *w}
 fmt.Fprint(ew, "HTTP/1.1", status.Code, status.Reason)

 for _, h := range headers {
  fmt.Fprint(ew, "\r\n", h.Key, ":", h.Value)
 }
 fmt.Fprint(ew, "\r\n\r\n")
 io.Copy(ew, body)

 return ew.err
}

Wrap erros

如果 authenticate 返回错误,则 AuthenticateRequest 会将错误返回给调用方,调用者可能也会这样做,依此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件中,打印出来的只是:没有这样的文件或目录?????到底是那个文件或者目录没有。

func AuthRequest(r *Request)error{
    return auth(r.User)
}

没有生成错误的 file:line 信息。没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将被迫进行长时间的代码分割,以发现是哪个代码路径触发了文件未找到错误。

func AuthRequest(r *Request)error{
    err:=auth(r.User)
    if err!=nil{
        return fmt.Errorf("auth failed: %v",err)
    }
    return nil
}

但是正如我们前面看到的,这种模式与 sentinel errors 或 type assertions 的使用不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后将其转换回 fmt.Errorf 破坏了原始错误,导致等值判定失败。

You should only handle errors once. Handling an error means inspecting the error value, and making a single decision.

我们经常发现类似的代码,在错误处理中,带了两个任务: 记录日志并且再次返回错误。这个还是看要求,打印之后返回也可以。

func WriteAll(w io.Writer, b []byte) (err error) {
 _, err = w.Write(b)
 if err != nil {
  log.Println("unable WriteAll:", err)
  return err
 }
 return nil
}

在这个例子中,如果在 w.Write 过程中发生了一个错误,那么一行代码将被写入日志文件中,记录错误发生的文件和行,并且错误也会返回给调用者,调用者可能会记录并返回它,一直返回到程序的顶部。

func WriteConfig(w io.Writer, config *Config) (err error) {
 buf, err := json.Marshal(config)
 if err != nil {
  log.Println("unable Marshal:", err)
  return err
 }
 if err := WriteAll(w, buf); err != nil {
  log.Println("unable WriteAll:", err)
  return err
 }
 return nil
}

func main() {
 err := WriteConfig(os.Stdout, &config)
 fmt.Println(err) // io.EOF
}

Go 中的错误处理契约规定,在出现错误的情况下,不能对其他返回值的内容做出任何假设。由于 JSON 序列化失败,buf 的内容是未知的,可能它不包含任何内容,但更糟糕的是,它可能包含一个半写的 JSON 片段。 由于程序员在检查并记录错误后忘记 return,损坏的缓冲区将被传递给 WriteAll,这可能会成功,因此配置文件将被错误地写入。但是,该函数返回的结果是正确的。

func WriteConfig(w io.Writer, config *Config) (err error) {
 buf, err := json.Marshal(config)
 if err != nil {
  log.Println("unable Marshal:", err)
  // oops, forgot to return err
 }
 if err := WriteAll(w, buf); err != nil {
  log.Println("unable WriteAll:", err)
  return err
 }
 return nil
}

日志记录与错误无关且对调试没有帮助的信息应被视为噪音,应予以质疑。记录的原因是因为某些东西失败了,而日志包含了答案。

pkg/errors

使用第三方的包处理错误

package main

import (
 "fmt"
 "os"

 "github.com/pkg/errors"
)

func ReadFile(path string) ([]byte, error) {
 f, err := os.Open(path)
 if err != nil {
  return nil, errors.Wrap(err, "open file failed")
 }
 defer f.Close()
 // ...
 return nil, nil
}

func main() {
 _, err := ReadFile("/tmp/file")
 if err != nil {
  fmt.Printf("original error: %T %v\n\n", errors.Cause(err), errors.Cause(err))
  fmt.Printf("stack trace: \n%+v\n", err)
  os.Exit(1)
 }
}

打印有堆栈信息,以及其他的详细的信息

original error: *fs.PathError open /tmp/file: The system cannot find the path specified.


stack trace: 
open /tmp/file: The system cannot find the path specified.
open file failed
main.ReadFile
        D:/code/go/test/cto/001-error/main.go:13
main.main
        D:/code/go/test/cto/001-error/main.go:21
runtime.main
        C:/Users/jimyag/sdk/go1.18/src/runtime/proc.go:250
runtime.goexit
        C:/Users/jimyag/sdk/go1.18/src/runtime/asm_amd64.s:1571

区别WithMessageWarp。前者是在原有的错误基础上添加一条错误消息,后者是通过当前的错误信息和堆栈信息组成一个新的错误。

package main

import (
 "fmt"
 "os"

 "github.com/pkg/errors"
)

func ReadFile(path string) ([]byte, error) {
 f, err := os.Open(path)
 if err != nil {
  return nil, errors.Wrap(err, "open file failed")
 }
 defer f.Close()
 // ...
 return nil, nil
}

func ReadConfig(path string) ([]byte, error) {
 data, err := ReadFile(path)
 if err != nil {
  return nil, errors.WithMessage(err, "read file failed")
 }
 return data, nil
}

func main() {
 _, err := ReadConfig("/tmp/file")
 if err != nil {
  fmt.Printf("%+v\n", err)
  os.Exit(1)
 }
}

结果

C:\Users\jimyag\AppData\Local\Temp\GoLand\___go_build_test_cto_001_error.exe
open /tmp/file: The system cannot find the path specified.
open file failed
main.ReadFile
        D:/code/go/test/cto/001-error/main.go:13
main.ReadConfig
        D:/code/go/test/cto/001-error/main.go:21
main.main
        D:/code/go/test/cto/001-error/main.go:29
runtime.main
        C:/Users/jimyag/sdk/go1.18/src/runtime/proc.go:250
runtime.goexit
        C:/Users/jimyag/sdk/go1.18/src/runtime/asm_amd64.s:1571
read file failed

在你的应用代码中,使用 errors.New 或者 errros.Errorf 返回错误。

如果调用其他包内的函数,通常简单的直接返回。

func parseArgs(args []string) error {
 if len(args) != 2 {
  return errors.Errorf("two arguments required")
 }
 return nil
}

func fo() error {
 err := parseArgs([]string{"one", "two"})
 if err != nil {
  return err
 }
 return nil
}

如果和其他库进行协作,考虑使用 errors.Wrap 或者 errors.Wrapf 保存堆栈信息。同样适用于和标准库协作的时候。

f,err:=os.Open(path)
if err!=nil{
    return errors.Warpf(err,"failed to open %q",path)
}

直接返回错误,而不是每个错误产生的地方到处打日志。

在程序的顶部或者是工作的 goroutine 顶部(请求入口),使用 %+v 把堆栈详情记录。

func main(){
    err:=app.Run()
    if err!=nil{
        fmt.Printf("FATAL:%+v\n",err)
        os.Exit(1)
    }
}

使用 errors.Cause 获取 root error,再进行和 sentinel error 判定。

总结:

1.13新特性

go1.13为 errors 和 fmt 标准库包引入了新特性,以简化处理包含其他错误的错误。其中最重要的是: 包含另一个错误的 error 可以实现返回底层错误的 Unwrap 方法。如果 e1.Unwrap() 返回 e2,那么我们说 e1 包装 e2,您可以展开 e1 以获得 e2。 按照此约定,我们可以为的 QueryError 类型指定一个 Unwrap 方法,该方法返回其包含的错误。

func (e *QueryError)Unwarp()error{
    return e.Err
}

go1.13 errors 包包含两个用于检查错误的新函数:Is 和 As。

// if err == ErrNotFound
if errors.Is(err,ErrorNotFound){
    //
}

// if e,ok:=err.(*QueryError);ok{ ...}
var e *QueryError
if errors.As(err,&e){
    //
}

如前所述,使用 fmt.Errorf 向错误添加附加信息

if err!=nil{
    return fmt.Errorf("decompress %v: %v",name,err)
}

在 Go 1.13中 fmt.Errorf 支持新的 %w 谓词。

if err!=nil{
    return fmt.Errorf("decompress %v: %w",name,err)
}

用 %w 包装错误可用于 errors.Is 以及 errors.As:

err:=fmt.Errorf("access denied  %w",ErrPermisson)
...
if errors.Is(err,ErrPermisson){
    
}
...

总结

对于错误处理用pkg/errors好一点,它也兼容了1.13IsAs

对于一个底层库的开发不要使用pkg/errors,要生成对应的根错误来进行排查。

版权

以上内容根据极客时间的0.99元的错误处理课程总结而来。本文仅供学习和交流使用,如有不当之处,请联系我删除。

#错误处理