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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

对于开发者来说要是不爽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/errorsGo 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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: go

“爆肝3天只为Golang 错误处理最佳实践” 的相关文章