Go Error 最佳实践

2022年8月24日 268点热度 0人点赞 0条评论

Go 语言由于没有 try...catch 结构屡屡被诟病,Go 中的每一个错误都需要处理,而且错误经常是层层嵌套的。如下面的结构:

a, err := fn()
if err != nil {
  return err
}
func fn() error {
  b, err := fn1()
  if  err != nil {
    …
    return err
  }
  if _, err = fn2(); err != nil {
    …
  }
}

1Go Error 也是接口

在 Go 语言中,Go Error 也是一个接口:

type error interface {
  Error() string
}

所以,实现、创建或抛出错误,实际上就是实现这个接口。最常见的三种方式是:

  • errors.New

  • fmt.Errof

  • implement error interface

var ErrRecordNotExist   = errors.New("record not exist")
func ErrFileNotExist(filename string) error {
  return fmt.Errorf("%s file not exist", filename)
}
type ErrorCallFailed struct {
  Funcname string
}
func (*ErrorCallFailed) Error() string {
  return fmt.Sprintf(“call %s failed”, funcname)
}
var ErrGetFailed error = &ErrorCallFailed{ Funcname: "getName", }

Go 错误只涉及以下两个逻辑:

  • 抛出错误,这涉及到我们如何定义错误。在实现功能时,异常情况需要返回合理的错误

  • 处理错误。调用函数时,要根据函数的返回实现不同的逻辑,考虑是否有错误,错误是否属于某种类型,是否忽略错误等。

func (d *YAMLToJSONDecoder) Decode(into interface{}) error {
 bytes, err := d.reader.Read()
 if err != nil && err != io.EOF {
  return err
 }

 if len(bytes) != 0 {
  err := yaml.Unmarshal(bytes, into)
  if err != nil {
   return YAMLSyntaxError{err}
  }
 }
 return err
}

type YAMLSyntaxError struct {
 err error
}

func (e YAMLSyntaxError) Error() string {
 return e.err.Error()
}

Kubernetes decode.go 中的这篇文章不仅可以直接返回错误,还可以包装错误,要么返回 YAMLSyntaxError,要么直接忽略 io.EOF

通常,有三种方法可以确定错误类型:

  • 直接通过 == , 例如:if err == ErrRecordNotExist {}

  • 类型推断,if _, ok := err.(*ErrRecordNotExist); ok {}

  • errors.Iserrors.As 方法. 从 Go 1.13 开始添加。if errors.Is(err, ErrRecordNotExist) 涉及错误换行,解决了定位嵌套错误的麻烦。

2遵循的规则

理解了 Go 错误的基本概念之后,是时候讨论可以遵循的规则以进行更好的实践了。让我们从定义开始,然后到错误处理。

定义错误

  • 使用 fmt.Errorf 而不是 errors.New

fmt.Errorf 提供拼接参数功能,并对错误进行包装。虽然我们在处理简单错误时发现这两种方法没有区别,但始终将 fmt.Errorf 设置为您的偏好可以保持代码统一。

封装相同的错误

封装同样的错误,比如上面提到的 ErrorCallFailed,是一种常见的代码优化,结合 errors.Is 或者errors.As 可以解包层,更好的判断错误的真正原因。至于 errors.Iserrors.As 的区别,前者既需要类型匹配又需要消息匹配,而后者只需要类型匹配。

func fn(n string) error {
  if _, err := get(n); err != nil {
    return ErrorCallFailed("get n")
  }
}

func abc() error {
  _, err = fn("abc")
  if err != nil {
    return fmt.Errorf("handle abc failed, %w", err)
  }
}

func main() {
  _, err := abc()
  if errors.Is(err, ErrorCallFailed){
    log.Fatal("failed to call %v", err)
    os.Exist(1)
  }
}

使用 %w 而不是 %v

一个方法被多处调用时,为了得到完整的调用链,开发者会在返回错误的地方一层一层的包裹起来,通过fmt.Errorf 不断添加当前调用的唯一特征,可以是日志也可以是一个参数。在错误拼接中偶尔使用 %v 而不是 %w 会导致 Go 的错误包装功能在 Go1.13 和之后的版本中失效。正确换行后的错误类型如下

图片

使错误信息简洁

合理的错误信息可以通过逐层包装让我们远离冗余信息。

很多人有在下面的事情上打印日志的习惯,加上参数,当前方法的名字,调用方法的名字,这是不必要的。

func Fn(id string) error {
  err := Fn1()
  if err != nil {
    return fmt.Errorf("Call Fn1 failed with id: %s, %w", id, err
  }
  ...
  return nil
}

但是,清晰明了的错误日志只包含当前操作错误的信息、内部参数和动作,以及调用者不知道但调用者不知道的信息,例如当前的方法和参数。这是 Kubernetes 中 endpoints.go 的错误日志,一个非常好的例子,只打印内部 Pod 相关参数和 Unable to get Pod 的失败动作:

func (e *Controller) addPod(obj interface{}) {
  pod := obj.(*v1.Pod)
  services, err := e.serviceSelectorCache.GetPodServiceMemberships(e.serviceLister, pod)
  if err != nil {
    utilruntime.HandleError(fmt.Errorf("Unable to get pod %s/%s's service memberships: %v", pod.Namespace, pod.Name, err))
    return
  }
  for key := range services {
    e.queue.AddAfter(key, e.endpointUpdatesBatchPeriod)
  }
}

处理 error 的黄金五法则

以下介绍作者认为的黄金五法则。

  • **errors.Is** 优于**==**

== 比较容易出错,只能比较当前的错误类型而不能解包。因此,errors.Iserrors.As 是更好的选择。

package main

import (
 "errors"
 "fmt"
)

type e1 struct{}

func (e e1) Error() string {
 return "e1 happended"
}

func main() {
 err1 := e1{}

 err2 := e2()

 if err1 == err2 {
  fmt.Println("Equality Operator: Both errors are equal")
 } else {
  fmt.Println("Equality Operator: Both errors are not equal")
 }

 if errors.Is(err2, err1) {
  fmt.Println("Is function: Both errors are equal")
 }
}

func e2() error {
 return fmt.Errorf("e2: %w", e1{})
}
// Output
Equality Operator: Both errors are not equal
Is function: Both errors are equal
  • 打印错误日志,但不打印正常日志
buf, err := json.Marshal(conf)
if err != nil {
  log.Printf(“could not marshal config: %v”, err)
}

新手常犯的错误是使用 log.Printf 打印所有日志,包括错误日志,导致我们无法通过日志级别正确处理日志,调试难度大。我们可以从应用 log.Fatalf 的 dependencycheck.go 中学习正确的方法。

if len(args) != 1 {
  log.Fatalf(“usage: dependencycheck <json-dep-file> (e.g. ‘go list -mod=vendor -test -deps -json ./vendor/…’)”)
}
if *restrict == “” {
  log.Fatalf(“Must specify restricted regex pattern”)
}
depsPattern, err := regexp.Compile(*restrict)
if err != nil {
  log.Fatalf(“Error compiling restricted dependencies regex: %v”, err)
}
  • 永远不要通过错误处理逻辑

这是错误过程的说明。

bytes, err := d.reader.Read()
if err != nil && err != io.EOF {
  return err
}
row := db.QueryRow(“select name from user where id= ?”, 1)
err := row.Scan(&name)
if err != nil && err != sql.ErrNoRows{
  return err
}

可以看到,io.EOFsql.ErrNoRows 这两个 error 都被忽略了,后者是一个典型的用 error 来表示业务逻辑(数据不存在)的例子。我反对这样的设计但支持大小的优化, err:= row.Scan(&name) if size == 0 {log.Println(“no data”) } ,通过添加返回参数而不是直接抛出错误来提供帮助。

  • bottom 方法返回错误,upper 方法处理错误
func Write(w io.Writer, buf []byte) error {
  _, err := w.Write(buf)
  if err != nil {
    log.Println(“unable to write:”, err)
    return err
  }
  return nil
}

与上面类似的代码有一个明显的问题。如果打印日志后返回错误,则很可能存在重复日志,因为调用者也可能打印日志。

那么如何避免呢?让每个方法只执行一个功能。而这里的一个常见选择是底层方法只返回错误,上层方法处理错误。

  • 包装错误消息并添加有利于故障排除的上下文。

在 Go 中没有原生的 stacktrace 可以依赖,我们只能通过自己实现或第三方库来获取那些异常的堆栈信息。比如 Kubernetes 实现了一个比较复杂的 klog 包来支持日志打印、堆栈信息和上下文。如果您开发 Kubernetes 相关的应用程序,例如 Operator,您可以参考 Kubernetes 中的结构化日志记录。此外,那些第三方错误封装库,如 pkg/errors 非常有名。

3结语

Go 设计哲学的初衷是简化,但有时会使事情复杂化。然而,你永远不能认为 Go 错误处理是没有用的,即使它不是那么用户友好。至少,逐个错误返回是一个很好的设计,在最高层的调用处统一处理错误。此外,我们仍然可以期待即将发布的版本中的这些改进将带来更简单的应用程序。

感谢阅读!

4引用

https://go.dev/blog/go1.13-errors

https://errnil.substack.com/p/wrapping-errors-the-right-way

https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

原文链接:Go Error Best Practices. Create and handle your errors the right… | by Stefanie Lai | Level Up Coding (gitconnected.com)

84230Go Error 最佳实践

这个人很懒,什么都没留下

文章评论