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 {
…
}
}
复制代码
Go Error 也是接口
在 Go 语言中,Go Error 也是一个接口:
type error interface {
Error() string
}
复制代码
所以,实现、创建或抛出错误,实际上就是实现这个接口。最常见的三种方式是:
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.Is
和 errors.As
方法. 从 Go 1.13 开始添加。 if errors.Is(err, ErrRecordNotExist)
涉及错误换行,解决了定位嵌套错误的麻烦。
遵循的规则
理解了 Go 错误的基本概念之后,是时候讨论可以遵循的规则以进行更好的实践了。让我们从定义开始,然后到错误处理。
定义错误
fmt.Errorf
提供拼接参数功能,并对错误进行包装。虽然我们在处理简单错误时发现这两种方法没有区别,但始终将 fmt.Errorf
设置为您的偏好可以保持代码统一。
封装相同的错误
封装同样的错误,比如上面提到的 ErrorCallFailed
,是一种常见的代码优化,结合 errors.Is
或者errors.As
可以解包层,更好的判断错误的真正原因。 至于 errors.Is
和 errors.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.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.EOF
和 sql.ErrNoRows
这两个 error 都被忽略了,后者是一个典型的用 error 来表示业务逻辑(数据不存在)的例子。 我反对这样的设计但支持大小的优化, err:= row.Scan(&name) if size == 0 {log.Println(“no data”) }
,通过添加返回参数而不是直接抛出错误来提供帮助。
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 非常有名。
结语
Go 设计哲学的初衷是简化,但有时会使事情复杂化。 然而,你永远不能认为 Go 错误处理是没有用的,即使它不是那么用户友好。 至少,逐个错误返回是一个很好的设计,在最高层的调用处统一处理错误。 此外,我们仍然可以期待即将发布的版本中的这些改进将带来更简单的应用程序。
感谢阅读!
引用
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)
评论