Go工程化08 - 错误处理

最佳实践

先总结一下,在日常开发工作中,我们是怎么使用panic和error的。

panic最佳实践

  1. 在程序启动时,出现明显的配置错误可以直接panic,防止错误的配置产生错误的数据
  2. 在程序启动时,如果关键组件启动失败就可以直接panic,比如mysql和redis连接错误
  3. 在web框架的入口处,都会加recover避免程序panic直接退出程序
  4. 不使用panic和recover做常用的错误处理
    1. 频繁 panic recover 性能不好
    2. 不可控,一旦 panic 就将处理逻辑移交给了外部,我们并不能预设外部包一定会进行处理

error最佳实践

  1. 在应用程序中,我们一般使用github.com/pkg/errors这个库来处理应用错误
    1. 在公共库中,一般不使用这个库,避免报错时产生多次堆栈
    2. 这个库当前已经归档,不少开源项目都不再使用这个库了,但是如果想要有完整的堆栈信息用于排查错误,这个库还是最合适的,等go2中关于错误的新语法出来之后,再决定是否要更换
  2. error应该是函数的最后一个返回值,当error不为nil时,函数的其他返回值我们都不应该再使用
  3. 错误处理的时候应该先判断错误if err != nil, 出现错误及时return,使代码是一条流畅的直线,避免过多的嵌套
  4. 在应用程序中首次出现错误时,使用 errors.New  或者 errors.Errorf  返回错误
  5. 如果是调用应用程序的其他函数出现错误,请直接返回,如果需要携带信息,请使用 errors.WithMessage
  6. 如果是调用其他库(标准库、公共库、开源第三方库等)获取到错误时,请使用 errors.Wrap  添加堆栈信息
    1. 为了避免多次堆栈,不要每个地方都是用 errors.Wrap  只需要在错误第一次出现时进行 errors.Wrap  即可
  7. 根据“错误只处理一次”原则, 禁止每个出错的地方都打日志,只需要在进程的最开始的地方使用 %+v  进行统一打印,例如 http/rpc 服务的中间件,或者是公司的kit库里面封装统一的middleware
  8. 错误判断使用 errors.Is
  9. 错误判断并赋值使用 errors.As
  10. 如何判定错误的信息是否足够,想一想当你的代码出现问题需要排查的时候你的错误信息是否可以帮助你快速的定位问题,例如我们在请求中一般会输出参数信息,用于辅助判断错误
  11. 对于业务错误,推荐在一个统一的地方创建一个错误字典,比如protobuf文件。错误字典里面应该包含错误的 code,并且在日志中作为独立字段打印,方便做业务告警的判断,错误必须有清晰的错误文档
  12. 错误的时候,需要处理已分配的资源,使用 defer  进行清理,例如文件句柄,或者是保存错误的操作记录。

使用 error 处理有哪些好处?

  1. 简单
  2. 考虑失败,而不是成功(Plan for failure, not success)
  3. 没有隐藏的控制流
  4. 完全交给你来控制 error
  5. Error are values
    在写c++或者python代码时,错误往往会隐藏在一些值里面,开发者经常会忽略要去处理对应的错误,但是把error显式的当成value处理时,开发者没有办法不去处理。

error源码分析

Go error就是一个普通的接口

// The error built-in interface type is the conventional interface for  
// representing an error condition, with the nil value representing no error.  
type error interface {  
   Error() string  
}

创建error对象的一个最常用方法就是使用errors.New()

package errors  
  
// 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  
}

这个内置代码中有两个注意点:

  1. errorString就是一个实现了Error方法的struct,也就是实现了error这个interface,所以我们自定义error的时候就只需要实现Error方法
  2. New方法返回的不是errorString对象,而是返回对象的指针,这是为了避免字符串text相同时,无法判定两个error是否相等。

错误类型

总共有3种错误类型

  1. Sentinel Error
  2. Error Type(struct)
  3. Opaque Error

Sentinel Error

哨兵错误,就是定义一些包级别的错误变量,然后在调用的时候外部包可以直接对比变量进行判定,在标准库当中大量的使用了这种方式
例如下方 net/http  库中定义的错误

// Errors used by the HTTP server.
var (
	// ErrBodyNotAllowed is returned by ResponseWriter.Write calls
	// when the HTTP method or response code does not permit a
	// body.
	ErrBodyNotAllowed = errors.New("http: request method or response status code does not allow body")

	// ErrHijacked is returned by ResponseWriter.Write calls when
	// the underlying connection has been hijacked using the
	// Hijacker interface. A zero-byte write on a hijacked
	// connection will return ErrHijacked without any other side
	// effects.
	ErrHijacked = errors.New("http: connection has been hijacked")

	// ErrContentLength is returned by ResponseWriter.Write calls
	// when a Handler set a Content-Length response header with a
	// declared size and then attempted to write more bytes than
	// declared.
	ErrContentLength = errors.New("http: wrote more than the declared Content-Length")

	// Deprecated: ErrWriteAfterFlush is no longer returned by
	// anything in the net/http package. Callers should not
	// compare errors against this variable.
	ErrWriteAfterFlush = errors.New("unused")
)

当返回error之后如果我们想判断error是否时某个Sentinel Error,一般使用errors.Is  进行判断

if errors.Is(err, io.EOF) {}

这种错误处理方式有一个问题是,将 error 当做包的 API 暴露给了第三方,会增大包的表面积,并且会在两个包之间创建了依赖。这样会导致在做重构或者升级的时候很麻烦,并且这种方式包含的错误信息会十分的有限,所以我们应该尽可能避免使用Sentinel Error

Error Type

Error type是实现了error接口的自定义类型,比如io库的PathError记录了操作和文件路径

// PathError records an error and the operation and file path that caused it.
type PathError struct {  
   Op   string  
   Path string  
   Err  error  
}

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

// IsCorruptedMnt return true if err is about corrupted mount point
func IsCorruptedMnt(err error) bool {  
   if err == nil {  
      return false  
   }  
  
   var underlyingError error  
   switch pe := err.(type) {  
   case nil:  
      return false  
   case *os.PathError:  
      underlyingError = pe.Err  
   case *os.LinkError:  
      underlyingError = pe.Err  
   case *os.SyscallError:  
      underlyingError = pe.Err  
   }  
  
   if ee, ok := underlyingError.(syscall.Errno); ok {  
      for _, errno := range errorNoList {  
         if int(ee) == errno {  
            klog.Warningf("IsCorruptedMnt failed with error: %v, error code: %v", err, errno)  
            return true  
         }  
      }   }  
   return false  
}

和Sentinel Error相比,Error Type的一大改进是他们能够包装底层错误以提供更多上下文,但是并没有改进Sentinel Error的劣势

Opaque Error

不透明的错误,这种方式最大的特点就是只返回错误,暴露错误判定接口,不返回类型,这样可以减少 API 的暴露,后续的处理会比较灵活,这个一般用在公共库会比较好。
比如,在net库中有定义这样一个Error,内部的error不对外暴露,但是对外暴露了2个方法。

// An Error represents a network error.
type Error interface {
	error
	Timeout() bool // Is the error a timeout?

	// Deprecated: Temporary errors are not well-defined.
	// Most "temporary" errors are timeouts, and the few exceptions are surprising.
	// Do not use this method.
	Temporary() bool
}

使用Opaque Error我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值

type timeout interface {
	Timeout() bool
}

func IsTimeout(err error) bool {
	te, ok := err.(timeout)
	return ok && te.Timeout()
}

错误处理优化

在 go 中常常会存在大量的 if err  代码,下面介绍两种常见的减少这种代码的方式

Bufio.scan

下面的两个CountLines方法,第一个对于err的判断代码比较多,第二个方法一次err判断都没有,极大的简化了代码,这是因为在 sc.Scan  做了很多处理,像很多类似的,需要循环读取的都可以考虑像这样包装之后进行处理

func CountLines1(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, err
}

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

error writer

下面的2个WriteResponse方法,简洁程度完全不一样,区别在于第二个方法使用了errWriter这个自定义的结构,将重复的逻辑进行了封装,然后把 error 暂存,然后我们就只需要在最后判断一下 error 就行了

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 {
	w   io.Writer
	err error
}

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

func WriteResponse2(w io.Writer, st Status, headers []Header, body io.Reader) error {
	ew := &errWriter{w: 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.Fprintf(ew, "\r\n")
	_, _ = io.Copy(ew, body)
	return ew.err
}

错误包装

原则:你应该只处理一次错误。
处理一个错误意味着检查错误值。并做出一个决定。要么返回错误,要么忽略错误打日志。

使用 errors.Wrap 包装错误

body, err := json.Marshal(payload)  
if err != nil {  
   err = errors.Wrap(err, "failed to marshal the body")  
   return  
}

为什么不使用标准库的 fmt.Errorf("%w")

我们先看一下标准库的源代码,我们可以发现当 p.wrappedErr != nil  的时候(也就是有 %w)的时候,会使用一个 wrapError  将错误包装,看 wrapError  的源码可以发现,这个方法只是包装了一下原始错误,并且可以做到附加一些文本信息,但是没有堆栈信息。

package fmt

import "errors"

// Errorf formats according to a format specifier and returns the string as a
// value that satisfies error.
//
// If the format specifier includes a %w verb with an error operand,
// the returned error will implement an Unwrap method returning the operand. It is
// invalid to include more than one %w verb or to supply it with an operand
// that does not implement the error interface. The %w verb is otherwise
// a synonym for %v.
func Errorf(format string, a ...any) error {
	p := newPrinter()
	p.wrapErrs = true
	p.doPrintf(format, a)
	s := string(p.buf)
	var err error
	if p.wrappedErr == nil {
		err = errors.New(s)
	} else {
		err = &wrapError{s, p.wrappedErr}
	}
	p.free()
	return err
}

type wrapError struct {
	msg string
	err error
}

func (e *wrapError) Error() string {
	return e.msg
}

func (e *wrapError) Unwrap() error {
	return e.err
}

但是在 pkg/errors 的源码,可以发现除了使用 withMessage  附加了错误信息之外还使用 withStack  附加了堆栈信息,这样我们在程序入口处打印日志信息的时候就可以将堆栈信息一并打出了。

// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string) error {
	if err == nil {
		return nil
	}
	err = &withMessage{
		cause: err,
		msg:   message,
	}
	return &withStack{
		err,
		callers(),
	}
}

为什么不允许处处使用 errors.Wrap

因为每一次 errors.Wrap  的调用都会为错误添加堆栈信息,如果处处调用那会有大量的无用堆栈
下面的代码中,我们只有一处 wrap

func TestErrorWrap(t *testing.T) {  
   fmt.Printf("err: %+v", c())  
}  
  
func a() error {  
   return errors.Wrap(fmt.Errorf("xxx"), "test")  
}  
  
func b() error {  
   return a()  
}  
  
func c() error {  
   return b()  
}

print的结果显示一次wrap足够打出全部的堆栈信息

err: xxx
test
backend/pkg.a
	/Users/suncle/pkg/a_test.go:14
backend/pkg.b
	/Users/suncle/pkg/a_test.go:18
backend/pkg.c
	/Users/suncle/pkg/a_test.go:22
backend/pkg.TestErrorWrap
	/Users/suncle/pkg/a_test.go:10
testing.tRunner
	/Users/suncle/.gvm/gos/go1.18/src/testing/testing.go:1439
runtime.goexit
	/Users/suncle/.gvm/gos/go1.18/src/runtime/asm_arm64.s:1259--- PASS: TestErrorWrap (0.00s)

再看下多处wrap的代码

func TestErrorWrap(t *testing.T) {  
   fmt.Printf("err: %+v", c())  
}  
  
func a() error {  
   return errors.Wrap(fmt.Errorf("xxx"), "test")  
}  
  
func b() error {  
   return errors.Wrap(a(), "b")  
}  
  
func c() error {  
   return errors.Wrap(b(), "c")  
}

可以看到每一处wrap都添加了一次堆栈信息

err: xxx
test
backend/pkg.a
	/Users/suncle/pkg/a_test.go:14
backend/pkg.b
	/Users/suncle/pkg/a_test.go:18
backend/pkg.c
	/Users/suncle/pkg/a_test.go:22
backend/pkg.TestErrorWrap
	/Users/suncle/pkg/a_test.go:10
testing.tRunner
	/Users/suncle/.gvm/gos/go1.18/src/testing/testing.go:1439
runtime.goexit
	/Users/suncle/.gvm/gos/go1.18/src/runtime/asm_arm64.s:1259
b
backend/pkg.b
	/Users/suncle/pkg/a_test.go:18
backend/pkg.c
	/Users/suncle/pkg/a_test.go:22
backend/pkg.TestErrorWrap
	/Users/suncle/pkg/a_test.go:10
testing.tRunner
	/Users/suncle/.gvm/gos/go1.18/src/testing/testing.go:1439
runtime.goexit
	/Users/suncle/.gvm/gos/go1.18/src/runtime/asm_arm64.s:1259
c
backend/pkg.c
	/Users/suncle/pkg/a_test.go:22
backend/pkg.TestErrorWrap
	/Users/suncle/pkg/a_test.go:10
testing.tRunner
	/Users/suncle/.gvm/gos/go1.18/src/testing/testing.go:1439
runtime.goexit
	/Users/suncle/.gvm/gos/go1.18/src/runtime/asm_arm64.s:1259--- PASS: TestErrorWrap (0.00s)

错误判断

errors.Is

不断unwrap然后判断是否相等

func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflectlite.TypeOf(target).Comparable()
	// 循环判断是否相等
	for {
		if isComparable && err == target {
			return true
		}
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}
		// APIs, thereby making it easier to get away with them.
		if err = Unwrap(err); err == nil {
			return false
		}
	}
}

errors.As

和is的逻辑类似,也是不断unwrap比较,找到一个相同的err就返回

func As(err error, target any) bool {
	if target == nil {
		panic("errors: target cannot be nil")
	}
	val := reflectlite.ValueOf(target)
	typ := val.Type()
	if typ.Kind() != reflectlite.Ptr || val.IsNil() {
		panic("errors: target must be a non-nil pointer")
	}
	targetType := typ.Elem()
	if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
		panic("errors: *target must be interface or implement error")
	}
	for err != nil {
		if reflectlite.TypeOf(err).AssignableTo(targetType) {
			val.Elem().Set(reflectlite.ValueOf(err))
			return true
		}
		if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
			return true
		}
		err = Unwrap(err)
	}
	return false
}

统一打印错误日志

在http/rpc 服务的中间件,或者是公司的kit库里面,添加上统一打印错误日志的中间
价,就可以统一处理了,再也不需要在代码中每一处错误返回的地方都打印日志。
以kratos框架为例,server的logging middleware可以写成这样:

// Server is an server logging middleware.
func Server(logger log.Logger) middleware.Middleware {
	return func(handler middleware.Handler) middleware.Handler {
		return func(ctx context.Context, req interface{}) (reply interface{}, err error) {
			var (
				code      int32 = 200
				reason    string
				kind      string
				operation string
			)
			startTime := time.Now()
			if info, ok := transport.FromServerContext(ctx); ok {
				kind = info.Kind().String()
				operation = info.Operation()
			}
			reply, err = handler(ctx, req)
			if se := errors.FromError(err); se != nil {
				code = se.Code
				reason = se.Reason
			}
			level, stack := extractError(err)
			_ = log.WithContext(ctx, logger).Log(level,
				"kind", "server",
				"component", kind,
				"operation", operation,
				"args", extractArgs(req),
				"code", code,
				"reason", reason,
				"stack", stack,
				"latency", time.Since(startTime).Seconds(),
			)
			return
		}
	}
}

有了这样的middleware统一处理错误日志之后,我们就不需要再代码中疯狂打日志了,在kibana上也很方便的排查问题


参考:

  1. GO 编程模式:错误处理
  2. pkg/errors

Go工程化08 - 错误处理
https://suncle.me/posts/1358609975/
作者
Suncle Chen
发布于
2022年12月4日
许可协议