前言

Go 语言的错误处理是其经典设计,但是 Go 语言初学者往往为太多的错误处理感到困惑,为什么 Go 不能像其他语言一样可以异常捕获。
这其实是 Go 语言的设计哲学,在 Go 中错误就是值,需要显示的处理,这样代码才能更加健壮,开发人员才能对代码更加有信心。
我们不需要额外的语言机制去处理它们,而只需利用已有的语言机制,像处理其他普通类型值一样去处理错误。这也决定了这样的错误处理机制让代码更容易调试(就像对待普通变量值那样),也更容易针对每个错误处理的决策分支进行测试覆盖;同时,没有try-catch-finally的异常处理机制也让Go代码的可读性更佳。
要写出高质量的Go代码,我们需要始终想着错误处理

构造错误值

Go 提供了两种构造错误值的方法,errors.Newfmt.Errorf:

err := errors.New("demo error info")

// 带有上下文信息的错误值
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
wrapErr = fmt.Errorf("wrap error: %w", err)

Go 中一共有四种错误处理策略,不管是哪种错误处理策略都需要构造错误值:

  1. 透明错误处理策略
    不关心错误的上下文信息,只要返回错误信息就直接进行错误处理,这种错误处理策略的好处就是极大的减少了错误处理方与错误值构造方之间的耦合关系。
err := doSomething()
if err != nil {
    ...
    return err
}
  1. “哨兵”错误处理策略

当需要定义不同的错误类型,但是不需要携带错误上下文信息,根据不同错误类型,进行不同的处理时需要用到该模式。
哨兵错误值变量以 ErrXXX格式命名。
image.png
优点

  • 错误内容更加明晰,错误处理方可以更加有针对的处理。

缺点

  • 对于API的开发者而言,暴露“哨兵”错误意味着这些错误值和包的公共函数/方法一起成为API的一部分。
    一旦发布出去,开发者就要对其进行很好的维护。

使用 Go 1.13 之后的版本,进行错误检视最好使用 Is 方法

package main

import (
	"errors"
	"fmt"
)

var ErrSentinel = errors.New("the underlying sentinel error")

func main() {
	err1 := fmt.Errorf("wrap err1: %w", ErrSentinel)
	err2 := fmt.Errorf("wrap err2: %w", err1)

    // 如果底层错误是一个包装错误,使用 Is 方法可以直接追溯到错误链的底层
	if errors.Is(err2, ErrSentinel) {
		println("err is ErrSentinel")
		return
	}

	println("err is not ErrSentinel")
}
  1. 错误值类型检视策略

该策略是哨兵策略的升级版,可以自定义错误类型,并且携带错误上下文信息。
如果使用的是 Go 1.13 之后的版本,在对自定义错误类型进行检视的时候最好使用 As 方法

package main

import (
	"errors"
	"fmt"
)

type MyError struct {
	e string
}

func (e *MyError) Error() string {
	return e.e
}

func main() {
	var err = &MyError{"my error type"}
	err1 := fmt.Errorf("wrap err1: %w", err)
	err2 := fmt.Errorf("wrap err2: %w", err1)
	var e *MyError
	if errors.As(err2, &e) {
		println("err is a variable of MyError type ")
		println(e == err)
		return
	}

	println("err is not a variable of the MyError type ")
}

As方法类似于通过类型断言判断一个error类型变量是否为特定的自定义错误类型:

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

不同的是,如果error类型变量的底层错误值是一个包装错误,那么errors.As方法会沿着该包装错误所在错误链与链上所有被包装的错误的类型进行比较,直至找到一个匹配的错误类型。

  1. 错误行为特征检视策略

将某个包中的错误类型归类,统一提取出一些公共的错误行为特征(behaviour),并将这些错误行为特征放入一个公开的接口类型中。
相较于前两种错误检视策略,该策略降低了错误处理方与错误构造方的耦合,同时又提供了错误类型。
例如 net 包下错误构造
image.png
下面是http包使用错误行为特征检视策略进行错误处理的代码:
image.png

if err != nil 优化

Go 代码中会出现大量的 if err != nil判断,有两种较好的方式优化,使代码可读性更高。
优化前代码如下:
image.png

check/handle 风格化

利用panic和recover封装一套跳转机制,模拟实现一套check/handle机制。这样在降低复杂度的同时,也能在视觉呈现上有所改善。

package main

import (
	"fmt"
	"io"
	"os"
)

func check(err error) {
	if err != nil {
		panic(err)
	}
}

func CopyFile(src, dst string) (err error) {
	var r, w *os.File

	// error handler
	defer func() {
		if r != nil {
			r.Close()
		}
		if w != nil {
			w.Close()
		}
		if e := recover(); e != nil {
			if w != nil {
				os.Remove(dst)
			}
			err = fmt.Errorf("copy %s %s: %v", src, dst, err)
		}
	}()

	r, err = os.Open(src)
	check(err)

	w, err = os.Create(dst)
	check(err)

	_, err = io.Copy(w, r)
	check(err)

	return nil
}

func main() {
	err := CopyFile("foo.txt", "bar.txt")
	if err != nil {
		fmt.Println("copyfile error:", err)
		return
	}
	fmt.Println("copyfile ok")
}

封装:内置 error 状态

将错误封装在结构体内部,在方法的入口判断错误是否为nil, 一旦为 nil 就不进行后续逻辑。

package main

import (
	"fmt"
	"io"
	"os"
)

type FileCopier struct {
	w   *os.File
	r   *os.File
	err error
}

func (f *FileCopier) open(path string) (*os.File, error) {
	if f.err != nil {
		return nil, f.err
	}

	h, err := os.Open(path)
	if err != nil {
		f.err = err
		return nil, err
	}
	return h, nil
}

func (f *FileCopier) openSrc(path string) {
	if f.err != nil {
		return
	}

	f.r, f.err = f.open(path)
	return
}

func (f *FileCopier) createDst(path string) {
	if f.err != nil {
		return
	}

	f.w, f.err = os.Create(path)
	return
}

func (f *FileCopier) copy() {
	if f.err != nil {
		return
	}

	if _, err := io.Copy(f.w, f.r); err != nil {
		f.err = err
	}
}

func (f *FileCopier) CopyFile(src, dst string) error {
	if f.err != nil {
		return f.err
	}

	defer func() {
		if f.r != nil {
			f.r.Close()
		}
		if f.w != nil {
			f.w.Close()
		}
		if f.err != nil {
			if f.w != nil {
				os.Remove(dst)
			}
		}
	}()

	f.openSrc(src)
	f.createDst(dst)
	f.copy()
	return f.err
}

func main() {
	var fc FileCopier
	err := fc.CopyFile("foo.txt", "bar.txt")
	if err != nil {
		fmt.Println("copy file error:", err)
		return
	}
	fmt.Println("copy file ok")
}
  • 将原CopyFile函数彻底抛弃,而重新将其逻辑封装到一个名为FileCopier结构的CopyFile方法中。
  • FileCopier结构内置了一个err字段用于保存内部的错误状态。
    这样在其CopyFile方法中,我们只需按照正常业务逻辑,顺序执行openSrc、createDst和copy即可,正常业务逻辑的视觉连续性就这样被很好地实现了。
  • 同时该CopyFile方法的复杂度因if检查的“大量缺席”而变得很低。
Logo

欢迎加入我们的广州开发者社区,与优秀的开发者共同成长!

更多推荐