背景
Golang在错误处理上是日常被大家吐槽的地方,我在开发也看到过很多做法,比较多的是在各个层级的逻辑代码中对错误的重复处理。
比如:有的代码会在每一层级上都判断错误并记录日志,从代码层面上看,没什么问题,貌似很严谨,但是如果查看日志的话会发现有一堆重复的信息,等到你真正去排查问题时反而会造成干扰。
一、了解error
error类型是一个内置的接口类型,该接口只规定了一个返回字符串值的Error方法。Golang通过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
}
使用Golang在开发过程中经常会返回一个error值,调用者通过判断error值是否为nil来进行错误处理
num, err := strconv.Atoi("666")
if err != nil {
fmt.Printf("could not convert to number: %v\n", err)
return
}
fmt.Println("converted value:", num)
error为nil时表示成功;非nil的error表示出现错误
自定义错误实现error接口
我们经常会自定义符合自己需要的错误类型,切记要让这些自定义的错误类型实现error接口,这样的好处是:不用在调用方的代码中再引入额外的类型
例如,下面我们自定义了errorx类型,如果不实现error接口的话,调用方的代码中就会被errorx类型侵入:
package errorx
import (
"fmt"
"time"
)
type errorx struct {
Code int
CreatedAt time.Time
Msg string
}
func (e *errorx) Error() string {
return fmt.Sprintf("at %v, %s, code %d",
e.CreatedAt, e.Msg, e.Code)
}
func run() error {
return errorx{
4002,
time.Now(),
"An error occurred",
}
}
func Do() {
if err := run(); err != nil {
fmt.Println(err)
}
}
如果errorx不实现error接口的话,上面的run方法的返回值就要定义成errorx类型。然后调用方的代码中就要被errorx侵入,通过可导出类型的Errorx.Code == xxx 来判断到底是哪种具体的错误。
那调用方如何判断自定义的错误类型返回的error是哪种具体的错误呢? 方案有很多,比如:
- 对外暴露检查错误的方法
errorx.Is(err, target error)
// ...
- 对外暴漏自定义的错误常量,通过比较error值是否与错误常量相等进行判断,比如:gorm的查无数据(gorm.ErrRecordNotFound)、文件操作的结束符(io.EOF)…
if err != gorm.ErrRecordNotFound {
return err
}
// ...
错误处理的常见问题
下面先来看一个简单例子,模拟出现错误的场景:
type Config struct {
Username string
Password int
}
func JsonToMap(byteSlice []byte, data *map[string]string) error {
err := json.Unmarshal(byteSlice, &data)
if err != nil {
log.Println("Failed to convert json to map:", err)
return err
}
return nil
}
func StructToMap(obj interface{}) (map[string]string, error) {
byteSlice, err := json.Marshal(obj)
if err != nil {
log.Println("Failed to convert structure to json:", err)
return nil, err
}
var data map[string]string
err1 := JsonToMap(byteSlice, &data)
if err1 != nil {
log.Println("Failed to convert structure to map:", err1)
return nil, err1
}
return data, nil
}
func main () {
result, err := StructToMap(errorx.Config{
Username: "user",
Password: 123456,
})
fmt.Println(result)
fmt.Println(err)
}
上面例子中代码的错误处理出现了两个问题:
- 1、底层函数JsonToMap出现错误后,除了向上一层返回错误外还向日志里记录了错误,上层调用者也做了同样的事情,记录日志然后把错误再返回给上层。
可能你们会觉得这样做没什么,但当你的项目不再是单个小项目单体,而是分布式、微服务的架构时,在联调/排查问题的时候,这绝对会让你裂开,即便是有链路追踪,也能让你人都麻了(趟过浑水来的[emo])。千万不要小瞧了log的作用!!!
在单体项目中当你把一个复杂功能的接口拆成多个逻辑方法去整合调用时,多个error往上层接力的时候也会出现这个情况(前提是你的接口不再是像之前写的那样一个function从头写到尾几百行…)。
所以在日志中出现一堆重复内容:
Failed to convert json to map: json: cannot unmarshal number into Go value of type string
Failed to convert structure to map: json: cannot unmarshal number into Go value of type string
...
- 2、在代码的最顶层,虽然得到了原始错误,但没有相关的层级错误内容,换句话说就是没有把JsonToMap、StructToMap记录到log里的那些信息整合到错误中,最后再返回给上层,这样就不用每个地方都打印一次log。
针对以上两个问题的解决方案:在底层函数JsonToMap、StructToMap中给错误添加上下文信息,然后将错误整合返回给上层,由最上层方法最后统一处理错误。
最简单的可以直接使用fmt.Errorf方法,给错误添加注释信息。
type Config struct {
Username string
Password int
}
func JsonToMap(byteSlice []byte, data *map[string]string) error {
err := json.Unmarshal(byteSlice, &data)
if err != nil {
return fmt.Errorf("failed to convert json to map: %v", err)
}
return nil
}
func StructToMap(obj interface{}) (map[string]string, error) {
byteSlice, err := json.Marshal(obj)
if err != nil {
return nil, fmt.Errorf("failed to convert structure to json: %v", err)
}
var data map[string]string
err1 := JsonToMap(byteSlice, &data)
if err1 != nil {
return nil, fmt.Errorf("failed to convert structure to map: %v", err1)
}
return data, nil
}
fmt.Errorf方法,只是给error添加注释标记信息,如果还想给error附加上下文信息,也就是error的调用栈,可以引用github.com/pkg/errors包,它提供了很多错误包装的方法,通过调用栈可以清楚的找到错误的位置,十分强大。
// 附加新的注释信息
// WithMessage annotates err with a new message.
func WithMessage(err error, message string) error
// 附加调用的堆栈信息
// WithStack annotates err with a stack trace at the point WithStack was called.
func WithStack(err error) error
// 同时附加调用栈信息和注释信息
// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
func Wrap(err error, message string) error
以上是其中的一些错误包装方法,有包装就有对应的拆包解包
// Cause方法会返回包装错误对应的最根本的错误,通俗说就是会递归地进行解包
// Cause returns the underlying cause of the error, if possible.
func Cause(err error) error {
下面是一个使用"github.com/pkg/errors"包的简单demo:
package errorx
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
// 模拟读取配置
func ReadConfig() ([]byte, error) {
_ = os.Setenv("FILE_PATH", "./test.yaml")
config, err := ReadFile(os.Getenv("FILE_PATH"))
return config, errors.WithMessage(err, "could not read config")
}
package main
func main() {
_, err := errorx.ReadConfig()
if err != nil {
fmt.Printf("original error Type: %T \noriginal error: %v\n", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:\n%+v\n", err)
os.Exit(1)
}
}
当你的配置文件
test.yaml
不存在时,展示错误如下:
➜ test go run err.go
original error Type: *fs.PathError # 原始错误的类型
original error: open ./test.yaml: no such file or directory # 原始错误原因
stack trace: # 调用栈信息
open ./test.yaml: no such file or directory
open failed
demo/errorx.ReadFile
/Users/sheliutao/www/golang/src/test/errorx/errorx.go:67
demo/errorx.ReadConfig
/Users/sheliutao/www/golang/src/test/errorx/errorx.go:79
main.main
/Users/sheliutao/www/golang/src/test/err.go:41
runtime.main
/usr/local/go/src/runtime/proc.go:250
runtime.goexit
/usr/local/go/src/runtime/asm_amd64.s:1571
could not read config # 附加注释信息
exit status 1
通过引入"github.com/pkg/errors"包既能给错误附加调用栈信息,又能保留原始错误的展示,通过Cause方法可以还原到最根本引发错误的详细信息和文件位置。
注意:Golang官方不推荐使用panic和recover去做错误处理。只有当程序不能继续运行的时候,才应该使用panic和recover机制。注意不要滥用panic和recover,可能会导致性能问题。应该尽可能地使用error去做错误处理,而不是使用panic和recover。
总结
最后总结下错误处理原则:
- 只在逻辑的最外层处理(拆解)一次错误,底层只返回error错误。
- 底层除了返回error外,要对原始错误进行包装,增加注释信息、调用栈等这些有利于排查的上下文信息。
我是六涛sheliutao,文章编写总结不易,转载注明出处,喜欢本篇文章的小伙伴欢迎点赞、关注,有问题可以评论区留言或者私信我,相互交流!!!