Golang错误处理的姿势

文章讨论了Golang中错误处理的常见问题,包括在每个层级重复处理错误和记录日志。介绍了error接口的使用,以及自定义错误类型应实现此接口以保持干净的调用者代码。推荐使用`fmt.Errorf`或`github.com/pkg/errors`包进行错误包装,添加上下文信息和调用栈,以便于问题排查。文章强调了只在外层处理错误和避免滥用`panic`和`recover`的原则。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景

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,文章编写总结不易,转载注明出处,喜欢本篇文章的小伙伴欢迎点赞、关注,有问题可以评论区留言或者私信我,相互交流!!!

参考
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sheliutao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值