爆肝3天只为Golang 错误处理最佳实践

对于开发者来说,要是不爽Go错误处理,那就看看最佳实践。Go可能引入try catch吗?那可能估计有点难度。本文简单介绍Go为什么选择这样的错误处理和目前常见处理方式,并梳理常见Go错误处理痛点,给出最佳实践,相信看完本文你会觉得Go错误处理好像也没那么糟糕,甚至好像还挺自然。

错误处理前世今生

关于错误处理,目前就两种方式

  1. 函数返回值判断,参考C语言
  2. try-catch-finally结构化异常处理,参考Java

当前主流语言都是选择结构化异常处理方式,结构化异常处理的优势在于

  1. 专注于业务处理逻辑,所有错误处理放在catch集中处理
  2. 提供了一套业务处理框架,相当于沉淀业务最佳实践,什么时候处理异常(catch)什么时候处理清理回收(finally)各个语言都差不多,快速上手

理想是丰满的,但现实是骨感的,如果你用过C++/Java异常处理,那么想想遇到多少无脑try catch的场景。

  • 什么?发生错误了,加个try catch看看
  • 为什么会出错?管它呢,加个try catch看看
  • 错误在哪里发生的?管它呢,包个大try catch
  • 抓到异常怎么办?懒得处理,先catch起来
  • 异常时怎么清理?先不考虑!

既然是错误处理,那么使用方应该明确知道是否可能有错误发生,错误在哪里发生,发生时应该怎么处理,这个逻辑应该是透明的。try catch的简单性很大一部分是因为鼓励开发者做不精确的错误处理思考,托管了部分错误处理流程,为开发者兜底结果开发确实方便很多,由此带来的错误也不少,往往程序员的过度滥用造成的后果往往可能比处理错误本身更严重

  • 无脑try catch带来性能损耗
  • 该处理的错误没有被正确处理或者忽略
  • 出现bug时,很难定位错误的发生点

大道至简

基于此,Go更鼓励开发者明确知道错误的处理过程,所谓大道至简就是对于整个过程开发者知道自己在干啥,不要企图依赖错误处理来兜底自己的开发错误
当然,并不能说Go的错误处理方式更优,只能说相对可控性强一点。事实上对于业务处理,肯定是try catch更方便,毕竟相当于编译器帮开发者做了很多处理工作,大多数时候我们只想一把梭,只要代码写的快,bug就追不上我,这也为什么Go用于业务开发常被抱怨的原因。

最佳实践

事实上,关于错误处理,Go官方也一直在迭代,也给出一些最佳实践。总的原则就是,Go的错误应该被当做值,既然是值,那么错误处理的方式完全取决于开发者,推荐一些最佳实践(套路),躺平的理直气壮。
当然,现在官方也一直在讨论,这部分最佳实践该怎么沉淀成语法。但Go的卖点就在足够简单,所以对于如何不破坏简单性并增强功能上很慎重,官方宁可先搞点最佳实践,开发者比着用用,也拒绝过早承诺和引入没想好的特性。

常见处理方式

常用处理方式足以应付大多数简单开发场景,比如一个简单的cli工具等等。

简单处理

函数中一般使用errors.New和fmt.Errorf,上层if err != nil 判断是否发生错误,对于简单的封装调用,这种方式即可。

// 简单处理
func funcA() error {
	// do something
	return errors.New("funcA error")
    // return fmt.Errorf("funcB error %d", 1)
}

func TestSimple(t *testing.T) {
	err := funcA()
	if err != nil {
		t.Logf("err %v", err)
		return
	}
}

标准错误匹配判断

类似 try catch,提前定义好不同的标准Error,统一调用可能返回不同的错误,上游调用针对不同类型不同处理
简单的可直接判断类型


// 分支判断
var (
	ErrA = errors.New("A error")
)

func funcA2() error {
	// do something
	if true {
		return ErrA
	}

	// do something
	return nil
}

func TestSimple2(t *testing.T) {
	err := funcA2()
	if err == ErrA {
		t.Logf("err %v", err)
		return
    }
}

复杂多分支处理,通过switch匹配判断,如下


// 分支判断
var (
	ErrA = errors.New("A error")
	ErrB = errors.New("B error")
	ErrC = errors.New("C error")
)

func funcB2(param int) error {
	if param == 0 {
		return ErrA
	} else if param == 1 {
		return ErrB
	} else if param == 2 {
		return ErrC
	}

	// do something
	return nil
}

func TestSimple2(t *testing.T) {
	err := funcB2(0)
	if err != nil {
		switch err {
		case ErrA:
			//..
			return
		case ErrB:
			//...
			return
		case ErrB:
			//..
			return
		default:
			//...
			return
		}
	}
}

自定义错误匹配判断

对于需要复杂逻辑的Error或屏蔽底层细节的需求,我们可以实现自定义的error,实现如下接口即可。

type error interface {
	Error() string
}

返回结果,通过类型匹配做分支判断

// 自定义错误
type ErrMyA struct {
	Param string
}

func (e *ErrMyA) Error() string {
	return fmt.Sprintf("invalid param: %+v", e.Param)
}

type ErrMyB struct {
	Param string
}

func (e *ErrMyB) Error() string {
	return fmt.Sprintf("invalid param: %+v", e.Param)
}

// 函数调用
func funcB3(param int) error {
	if param == 0 {
		return &ErrMyA{"A"}
	} else if param == 1 {
		return &ErrMyB{"B"}
	}

	// do something
	return nil
}

// 类型匹配判断
func TestSimple3(t *testing.T) {
	err := funcB3(0)
	if v, ok := err.(*ErrMyA); ok {
		t.Logf("err %v", v)
		return
	}

	err = funcB3(0)
	if err != nil {
		switch err.(type) {
		case *ErrMyA:
			//..
			return
		case *ErrMyB:
			//...
			return
		default:
			//...
			return
		}
	}
}

最佳实践

error定义的包依赖

无论是标准Error的列表,还是自定义错误的define声明,检测错误导致了两个包(package)之间产生代码级的依赖。比如,检查某个错误是否是 io.EOF(预定义Error),不得不依赖 io 包;检查某个错误是否为自定义ErrA,不得不依赖ErrA的定义。
事实上,理想的情况是:代码实现上最好可以不用 import 定义该错误的包,从而导致的耦合,毕竟调用方只关心错误的行为,并不关系底层实现细节,这也就是所谓的透明型错误(Opaque errors)。

  • 透明型错误检测

这里参考Dave Cheney的方法,要想上层不依赖下层错误定义,最简单的就是上层只判断是否出错,压根不关心具体信息。如下

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

为了支持不同错误的具体信息判断怎么做呢?Golang的接口是鸭子类型,只需要通过预定义接口,在上层调用中判断是否实现了某个接口而即可。注意,接口定义需要两个文件都要。看起来能解决问题,但是实际中增加使用复杂度,很少用。

type temporary interface {
        Temporary() bool
}
 
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
        te, ok := err.(temporary)
        return ok && te.Temporary()
}
  • 独立定义和声明

实际上,实际业务中前一种方式用得很少。
目前最广泛使用的,特别是微服务中,就是单独定义一个错误包,项目组统一维护,其它相关使用方引入该包使用即可

业务上下文输出

error除了输出错误之外,往往我们需要输出当时的相关业务信息,比如业务模块/错误码/错误消息等等,最佳实践是同一项目,在基础error基础上封装一套统一的自定义error。目前各个业务实现中最常用。
参考如下,

  • 定义业务相关信息,这里简单定义错误码和消息,一般单独模块维护
// 单独保存的业务error code和msg等

const (
	ErrSuccess   = 0
	ErrInvalid   = 1
	ErrWrongUser = 2
)

var code2msg = map[int]string{
	ErrSuccess:   "Success",
	ErrInvalid:   "Invalid Param",
	ErrWrongUser: "Wrong User",
}

// 独立的自定义error
type MyError struct {
	mod  string
	code int
	msg  string
}
  • 然后自定义MyError,自定义业务信息输出和记录,可以在此基础上增加信息和方法实现
// 独立的自定义error
type MyError struct {
	mod  string
	code int
	msg  string
}

func NewMyError(mod string, code int) *MyError {
	return &MyError{
		mod,
		code,
		code2msg[code],
	}
}

func (e *MyError) Error() string {
	return fmt.Sprintf("Error detail: %+v %+v %+v", e.mod, e.code, e.msg)
}

func (e *MyError) Code() int {
	return e.code
}

func (e *MyError) Msg() string {
	return e.msg
}

func doSomethingA() error {
	return NewMyError("A", ErrSuccess)
}

func doSomethingB() error {
	return NewMyError("B", ErrInvalid)
}
  • 最后实际使用中记录和输出业务相关
func TestDoSomething(t *testing.T) {
	err := doSomethingA()

	if err != nil {
		// 分错误处理
		switch err.(type) {
		case *MyError:

			// 分业务处理
			myerr := err.(*MyError)
			switch myerr.Code() {
			case ErrSuccess:
				// ...
				t.Logf("MyError - %v", err)
			case ErrInvalid:
				// ...
			case ErrWrongUser:
				// ...
			}

		default:
			t.Log("Other error")
		}
	}
}

到处可见的err!=nil

Go 错误处理最大特点恐怕就是满屏飘的if err != nil。典型的代码调用,如下

// 简单处理
func funcA() error {
	// do something
	return errors.New("funcA error")
}

func funcB() error {
	// do something
	return fmt.Errorf("funcB error %d", 1)
}

func funcC() error {
	// do something
	return fmt.Errorf("funcC error %d", 2)
}

func TestSimple(t *testing.T) {
	err := funcA()
	if err != nil {
		t.Logf("err %v", err)
		return
	}

	err = funcB()
	if err != nil {
		t.Logf("err %v", err)
		return
	}

	err = funcC()
	if err != nil {
		t.Logf("err %v", err)
		return
	}
}

关于如何优化,Rob Pike给了两种典型优化和处理套路。

  • 嵌套函数

计算机中没什么是加一层不能解决的,这里引入嵌套函数——一次run过程中,每一步都过统一的err检查,最后做统一的err判断处理

// 简单处理
func funcAA() error {
	// do something
	return errors.New("funcA error")
}

func funcBB() error {
	// do something
	return errors.New("funcB error")
}

func funcCC() error {
	// do something
	return errors.New("funcC error")
}

func TestSimpleTidy(t *testing.T) {

	// 外包函数判断
	var err error
	callFunc := func(f func() error) {
		if err != nil {
			return
		}

		err = f()
	}

	// 顺序调用
	callFunc(funcAA)
	callFunc(funcBB)
	callFunc(funcCC)

	// 统一判断
	if err != nil {
		t.Logf("Error - %v", err)
	}

}
  • 嵌套接口实现

和嵌套函数实现类似,这里接口封装定义的结构体保存了错误处理相关信息,对外暴露信息少,而且很容易实现链式调用和错误处理,如下:

type WorkRunner struct {
	err error
}

func NewWorkRunner() *WorkRunner {
	return &WorkRunner{}
}

func (w *WorkRunner) run(f func() error) {
	if w.err == nil {
		w.err = f()
	}
}

func (w *WorkRunner) funcAA() *WorkRunner {
	// do something
	w.run(func() error {
		return errors.New("funcA error")
	})
	return w
}

func (w *WorkRunner) funcBB() *WorkRunner {
	// do something
	w.run(func() error {
		return errors.New("funcB error")
	})
	return w
}

func (w *WorkRunner) funcCC() *WorkRunner {
	// do something
	w.run(func() error {
		return errors.New("funcC error")
	})
	return w
}

func TestSimpleTidy2(t *testing.T) {

	// 对象统一管理判断
	w := NewWorkRunner()

	// 顺序链式调用
	w.funcAA().funcBB().funcCC()

	// 统一判断
	if w.err != nil {
		t.Logf("Error - %v", w.err)
	}
}

跟踪错误堆栈

在Go应用里,一个逻辑往往要经多多层函数的调用才能完成,那在程序里我们的建议Error Handling 尽量留给上层的调用函数做,中间和底层的函数通过错误包装把自己要记的错误信息附加再原始错误上再返回给外层函数

期望的功能:

  • 错误包装(Wrap)和解包装(UnWarp),返回的是一个error堆栈(Stack)
  • 可以打印error堆栈(Stack)
  • 被包装的error无法直接=或type判断是否为具体错误类型,需要支持error堆栈中查找判断

Go 1.13中常依赖github.com/pkg/errors,Go 1.13后官方引入类似机制处理。

  • 官方库

Go 1.13后推荐直接使用官方库。
如下是一个服务请求过程模拟,control->service->dao->db逐级调用,返回原始error或自定义error。
扩展fmt.Errorf__函数,使用__%w__来生成包装错误

var ErrDbOrigin = errors.New("ErrDbOrigin")

type ErrDbDefine struct {
	info string
}

func (e ErrDbDefine) Error() string {
	return fmt.Sprintf("Error detail: %+v ", e.info)
}

func controlFunc(param interface{}) error {
	if err := serviceFunc(param); err != nil {
		return fmt.Errorf("error when controlFunc...: [%w]", err)
	}

	return nil
}

func serviceFunc(param interface{}) error {
	if err := daoFunc(param); err != nil {
		return fmt.Errorf("error when serviceFunc...: [%w]", err)
	}

	return nil
}

func daoFunc(param interface{}) error {
	if err := dbFunc(param); err != nil {
		return fmt.Errorf("error when daoFunc...: [%w]", err)
	}

	return nil
}

func dbFunc(param interface{}) error {
	// do something
	return ErrDbOrigin
	//return ErrDbDefine{"ErrDbOrigin error info"}
}

UnWrap逐级解包装,fmt.Print直接输出error堆栈

fmt.Printf("error -> %v\n", err)
fmt.Printf("error unwrap -> %v\n", errors.Unwrap(err))

输出如下

error -> error when controlFunc…: [error when serviceFunc…: [error when daoFunc…: [ErrDbOrigin]]]
error unwrap -> error when serviceFunc…: [error when daoFunc…: [ErrDbOrigin]]

包装的后的原始error和自定义eror分别使用Is/As判断

		// 无法匹配
		if err == ErrDbOrigin {
			fmt.Printf("error print with equal -> %v\n", err)
		}

		if errors.Is(err, ErrDbOrigin) {
			fmt.Printf("error print with Is -> %v\n", err)
		}

		// 无法匹配
		if v, ok := err.(ErrDbDefine); ok {
			fmt.Printf("error print with type -> %v\n", v)
		}

		var b ErrDbDefine
		if errors.As(err, &b) {
			fmt.Printf("error print with As -> %v\n", err)
		}
  • github库

相对官方库,github提供的方法更多,支持更多细节,整体差不多,目前很多地方还在使用。
提供多种Wrap方式来包装错误

var ErrDbOrigin2 = errors.New("ErrDbOrigin")

type ErrDbDefine2 struct {
	info string
}

func (e ErrDbDefine2) Error() string {
	return fmt.Sprintf("Error detail: %+v ", e.info)
}

func controlFunc2(param interface{}) error {
	if err := serviceFunc2(param); err != nil {
		return giterrors.Wrap(err, "error when controlFunc")
	}

	return nil
}

func serviceFunc2(param interface{}) error {
	if err := daoFunc2(param); err != nil {
		return giterrors.Wrap(err, "error when serviceFunc")
	}

	return nil
}

func daoFunc2(param interface{}) error {
	if err := dbFunc2(param); err != nil {
		return giterrors.Wrap(err, "error when daoFunc")
	}

	return nil
}

func dbFunc2(param interface{}) error {
	// do something
	return ErrDbOrigin2
	//return ErrDbDefine2{"ErrDbOrigin error info"}
}

UnWrap逐级解包装,fmt.Print直接输出error堆栈,也可使用WithStack返回堆栈

fmt.Printf("error -> %v\n", err)
fmt.Printf("error unwrap -> %v\n", giterrors.Unwrap(err))

fmt.Println(giterrors.WithStack(err))

输出如下

error -> error when controlFunc: error when serviceFunc: error when daoFunc: ErrDbOrigin
error unwrap -> error when controlFunc: error when serviceFunc: error when daoFunc: ErrDbOrigin

包装的后的原始error和自定义eror分别使用Is/As判断,也可使用Cause返回原始error

		// 无法匹配
		if err == ErrDbOrigin2 {
			fmt.Printf("error print with equal -> %v\n", err)
		}

		if giterrors.Cause(err) == ErrDbOrigin2 {
			fmt.Printf("error print with Cause -> %v\n", err)
		}

		if giterrors.Is(err, ErrDbOrigin2) {
			fmt.Printf("error print with Is -> %v\n", err)
		}

		// 无法匹配
		if v, ok := err.(ErrDbDefine2); ok {
			fmt.Printf("error print with type -> %v\n", v)
		}

		var b ErrDbDefine2
		if giterrors.As(err, &b) {
			fmt.Printf("error print with As -> %v\n", err)
		}

重复的错误日志输出

很多开发者实际开发过程中,函数里遇到error,可能会先打印error,同时把error也返回给上层调用方,结果日志出现大量重复error,影响排查不说,还可能降低性能。

这里提供两种常见的最佳实践思路

  • 利用错误堆栈跟踪错误,上层统一处理和打印

参考上一小节,利用错误堆栈记录,上层统一处理和打印,Go官方支持错误堆栈也是在鼓励这种方式。
Go中的error处理法则就是

An error should be handled only once. Logging an error is handling an error. So an error should either be logged or propagated.

翻译成中文就是:

error只应该被处理一次,打印error也是对error的一种处理。所以对于error,要么打印出来,要么就把error返回传递给上一层。

  • 最原始位置调用日志包记录函数,打印错误信息,其他位置直接返回

事实上,目前更多看到的是这种方式。实际工程中,很多开发者并不习惯把错误打印全部放在一个地方,甚至很多时候只是按需打印error日志这时候结合Wrap功能折中处理会更好。

一般来说当错误发生时,也借助 log 包定位到错误发生的位置。最好如下操作

  1. 只在错误产生的最初位置打印日志,其他地方直接返回错误,一般不需要再对错误进行封装。
  2. 当代码调用第三方包的函数时,第三方包函数出错时打印错误信息。

参考

演示代码 https://gitee.com/wenzhou1219/go-in-prod/tree/master/error_deal

Go官方的错误处理实践 https://go.dev/blog/errors-are-values
Go 2 错误处理增加try语法糖提案 https://github.com/golang/go/issues/56165

Dave Cheney 如何优雅的处理Go的error
https://zhuanlan.zhihu.com/p/500068696
https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

Go error 处理的四种方式 https://zhuanlan.zhihu.com/p/441420411
err!=nil嵌套优化 https://coolshell.cn/articles/21140.html

Go error wrap实现解析 https://mp.weixin.qq.com/s/XdRe_yOiFGI8NiR9eWLEoQ
Go 1.13后wrap使用 https://mp.weixin.qq.com/s/SFbSAGwQgQBVWpySYF-rkw

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值