最佳实践之Golang错误处理

1、原生错误处理

Go 语言通过内置的错误接口提供了非常简单的错误处理机制。
error类型是一个接口类型,这是它的定义:

type error interface {
     Error() string
}

我们可以在编码中通过实现 error 接口类型来生成错误信息。
函数通常在最后的返回值中返回错误信息。使用errors.New 可返回一个错误信息:

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // 实现
}

在下面的例子中,我们在调用Sqrt的时候传递的一个负数,然后就得到了non-nil的error对象,将此对象与nil比较,结果为true,所以fmt.Println(fmt包在处理error时会调用Error方法)被调用,以输出错误,请看下面调用的示例代码:

result, err:= Sqrt(-1)
if err != nil {
   fmt.Println(err)
}

2、开源error包

github.com/pkg/errors包在原生error包基础上增加了以下常用的功能:

  • 可以打印error的堆栈信息:打印错误需要%+v才能详细输出
  • 使用Wrap或Wrapf,初始化一个error
  • 使用errors.WithMessage可以在原来的error基础上再包装一层,包含原有error信息
  • errors.Is,用于判断error类型,可根据error类型不同做不同处理
  • errors.As,用于解析error

具体使用案例见全局错误处理一节。

3、工程中错误处理

3.1 需求整理

  • 自定义error信息,并进行编码整理
    • controller层可以判断自定义error类型,最终判断是按info处理,还是按error处理
  • 可以打印error初始发生的位置(获取error的调用栈)
  • 确认当前系统定位:
    • 用户,获取TagMessage
    • 上游服务,需要错误码映射
    • 日志监控、监控TagMessage

下面在一个工程化的项目中利用github.com/pkg/errors包,完整实现一套的错误处理机制

3.2 方式一:Map保存错误码与Message的映射

3.2.1 定义错误信息

新建error_handler.go

package error_handle

import (
    "github.com/pkg/errors"
)

// 1、自定义error结构体,并重写Error()方法
// 错误时返回自定义结构
type CustomError struct {
    Code       int    `json:"code"`    // 业务码
    TagMessage string `json:"message"` // 描述信息
}

func (e *CustomError) Error() string {
    return e.TagMessage
}

// 2、定义errorCode
const (
    // 服务级错误码
    ServerError        = 10101
    TooManyRequests    = 10102
    ParamBindError     = 10103
    AuthorizationError = 10104
    CallHTTPError      = 10105
    ResubmitError      = 10106
    ResubmitMsg        = 10107
    HashIdsDecodeError = 10108
    SignatureError     = 10109

    // 业务模块级错误码
    // 用户模块
    IllegalUserName = 20101
    UserCreateError = 20102
    UserUpdateError = 20103
    UserSearchError = 20104

    // 授权调用方
    AuthorizedCreateError    = 20201
    AuthorizedListError      = 20202
    AuthorizedDeleteError    = 20203
    AuthorizedUpdateError    = 20204
    AuthorizedDetailError    = 20205
    AuthorizedCreateAPIError = 20206
    AuthorizedListAPIError   = 20207
    AuthorizedDeleteAPIError = 20208

    // 管理员
    AdminCreateError             = 20301
    AdminListError               = 20302
    AdminDeleteError             = 20303
    AdminUpdateError             = 20304
    AdminResetPasswordError      = 20305
    AdminLoginError              = 20306
    AdminLogOutError             = 20307
    AdminModifyPasswordError     = 20308
    AdminModifyPersonalInfoError = 20309

    // 配置
    ConfigEmailError        = 20401
    ConfigSaveError         = 20402
    ConfigRedisConnectError = 20403
    ConfigMySQLConnectError = 20404
    ConfigMySQLInstallError = 20405
    ConfigGoVersionError    = 20406

    // 实用工具箱
    SearchRedisError = 20501
    ClearRedisError  = 20502
    SearchRedisEmpty = 20503
    SearchMySQLError = 20504

    // 菜单栏
    MenuCreateError = 20601
    MenuUpdateError = 20602
    MenuListError   = 20603
    MenuDeleteError = 20604
    MenuDetailError = 20605

    // 借书
    BookNotFoundError        = 20701
    BookHasBeenBorrowedError = 20702
)

// 3、定义errorCode对应的文本信息
var codeTag = map[int]string{
    ServerError:        "Internal Server Error",
    TooManyRequests:    "Too Many Requests",
    ParamBindError:     "参数信息有误",
    AuthorizationError: "签名信息有误",
    CallHTTPError:      "调用第三方 HTTP 接口失败",
    ResubmitError:      "Resubmit Error",
    ResubmitMsg:        "请勿重复提交",
    HashIdsDecodeError: "ID参数有误",
    SignatureError:     "SignatureError",

    IllegalUserName: "非法用户名",
    UserCreateError: "创建用户失败",
    UserUpdateError: "更新用户失败",
    UserSearchError: "查询用户失败",

    AuthorizedCreateError:    "创建调用方失败",
    AuthorizedListError:      "获取调用方列表页失败",
    AuthorizedDeleteError:    "删除调用方失败",
    AuthorizedUpdateError:    "更新调用方失败",
    AuthorizedDetailError:    "获取调用方详情失败",
    AuthorizedCreateAPIError: "创建调用方API地址失败",
    AuthorizedListAPIError:   "获取调用方API地址列表失败",
    AuthorizedDeleteAPIError: "删除调用方API地址失败",

    AdminCreateError:             "创建管理员失败",
    AdminListError:               "获取管理员列表页失败",
    AdminDeleteError:             "删除管理员失败",
    AdminUpdateError:             "更新管理员失败",
    AdminResetPasswordError:      "重置密码失败",
    AdminLoginError:              "登录失败",
    AdminLogOutError:             "退出失败",
    AdminModifyPasswordError:     "修改密码失败",
    AdminModifyPersonalInfoError: "修改个人信息失败",

    ConfigEmailError:        "修改邮箱配置失败",
    ConfigSaveError:         "写入配置文件失败",
    ConfigRedisConnectError: "Redis连接失败",
    ConfigMySQLConnectError: "MySQL连接失败",
    ConfigMySQLInstallError: "MySQL初始化数据失败",
    ConfigGoVersionError:    "GoVersion不满足要求",

    SearchRedisError: "查询RedisKey失败",
    ClearRedisError:  "清空RedisKey失败",
    SearchRedisEmpty: "查询的RedisKey不存在",
    SearchMySQLError: "查询mysql失败",

    MenuCreateError: "创建菜单失败",
    MenuUpdateError: "更新菜单失败",
    MenuDeleteError: "删除菜单失败",
    MenuListError:   "获取菜单列表页失败",
    MenuDetailError: "获取菜单详情失败",

    BookNotFoundError:        "书未找到",
    BookHasBeenBorrowedError: "书已经被借走了",
}

func Text(code int) string {
    return codeTag[code]
}

// 4、新建自定义error实例化
func NewCustomError(code int) error {
    // 初次调用得用Wrap方法,进行实例化
    return errors.Wrap(&CustomError{
        Code:       code,
        TagMessage: codeTag[code],
    }, "")
}

3.3 自定义Error使用

新建测试文件:error_handler_test.go

package error_handle

import (
    "fmt"
    "github.com/pkg/errors"
    "testing"
)

func TestText(t *testing.T) {
    books := []string{
        "Book1",
        "Book222222",
        "Book3333333333",
    }

    for _, bookName := range books {
        err := searchBook(bookName)

        // 特殊业务场景:如果发现书被借走了,下次再来就行了,不需要作为错误处理
        if err != nil {
            // 提取error这个interface底层的错误码,一般在API的返回前才提取
            // As - 获取错误的具体实现
            var myError = new(CustomError)
            // As - 解析错误内容
            if errors.As(err, &myError) {
                fmt.Printf("AS中的信息:当前书为: %s ,error code is %d, message is %s\n", bookName, myError.Code, myError.TagMessage)
            }

            // 特殊场景,指定错误(ErrorBookHasBeenBorrowed)时,打印即可,不返回错误
            // Is - 判断错误是否为指定类型
            if errors.Is(err,  NewCustomError(BookHasBeenBorrowedError)) {
                fmt.Printf("IS中的信息:%s 已经被借走了, 只需按Info处理!\n", bookName)
                err = nil
            }else {
                // 如果已有堆栈信息,应调用WithMessage方法
                newErr := errors.WithMessage(err, "WithMessage err")
                fmt.Printf("IS中的信息:%s 未找到,应该按Error处理! ,newErr is %s\n", bookName , newErr)
            }
        }
    }
}

func searchBook(bookName string) error {
    // 1 发现图书馆不存在这本书 - 认为是错误,需要打印详细的错误信息
    if len(bookName) > 10 {
        return NewCustomError(BookHasBeenBorrowedError)
    } else if len(bookName) > 6 {
        // 2 发现书被借走了 - 打印一下被接走的提示即可,不认为是错误
        return NewCustomError(BookHasBeenBorrowedError)
    }
    // 3 找到书 - 不需要任何处理
    return nil
}

3.3 方式二:借助generate简化代码(建议使用)

方式一维护错误码与错误信息的关系较为复杂,我们可以借助go generate来自动生成代码。

3.3.1 安装stringer

stringer不是Go自带工具,需要手动安装。执行如下命令即可

go get golang.org/x/tools/cmd/stringer

3.3.1 定义错误信息

新建error_handler.go。在error_handler中,增加注释//go:generate stringer -type ErrCode -linecomment。执行go generate,会生成新的文件


package error_handle

import (
    "github.com/pkg/errors"
)

// 1、自定义error结构体,并重写Error()方法
// 错误时返回自定义结构
type CustomError struct {
    Code    ErrCode `json:"code"`    // 业务码
    Message string  `json:"message"` // 业务码
}

func (e *CustomError) Error() string {
    return e.Code.String()
}

type ErrCode int64 //错误码

// 2、定义errorCode
//go:generate stringer -type ErrCode -linecomment
const (
    // 服务级错误码
    ServerError        ErrCode = 10101 // Internal Server Error
    TooManyRequests    ErrCode = 10102 // Too Many Requests
    ParamBindError     ErrCode = 10103 // 参数信息有误
    AuthorizationError ErrCode = 10104 // 签名信息有误
    CallHTTPError      ErrCode = 10105 // 调用第三方HTTP接口失败
    ResubmitError      ErrCode = 10106 // ResubmitError
    ResubmitMsg        ErrCode = 10107 // 请勿重复提交
    HashIdsDecodeError ErrCode = 10108 // ID参数有误
    SignatureError     ErrCode = 10109 // SignatureError

    // 业务模块级错误码
    // 用户模块
    IllegalUserName ErrCode = 20101 // 非法用户名
    UserCreateError ErrCode = 20102 // 创建用户失败
    UserUpdateError ErrCode = 20103 // 更新用户失败
    UserSearchError ErrCode = 20104 // 查询用户失败

    // 配置
    ConfigEmailError        ErrCode = 20401 // 修改邮箱配置失败
    ConfigSaveError         ErrCode = 20402 // 写入配置文件失败
    ConfigRedisConnectError ErrCode = 20403 // Redis连接失败
    ConfigMySQLConnectError ErrCode = 20404 // MySQL连接失败
    ConfigMySQLInstallError ErrCode = 20405 // MySQL初始化数据失败
    ConfigGoVersionError    ErrCode = 20406 // GoVersion不满足要求

    // 实用工具箱
    SearchRedisError ErrCode = 20501 // 查询RedisKey失败
    ClearRedisError  ErrCode = 20502 // 清空RedisKey失败
    SearchRedisEmpty ErrCode = 20503 // 查询的RedisKey不存在
    SearchMySQLError ErrCode = 20504 // 查询mysql失败

    // 菜单栏
    MenuCreateError ErrCode = 20601 // 创建菜单失败
    MenuUpdateError ErrCode = 20602 // 更新菜单失败
    MenuListError   ErrCode = 20603 // 删除菜单失败
    MenuDeleteError ErrCode = 20604 // 获取菜单列表页失败
    MenuDetailError ErrCode = 20605 // 获取菜单详情失败

    // 借书
    BookNotFoundError        ErrCode = 20701 // 书未找到
    BookHasBeenBorrowedError ErrCode = 20702 // 书已经被借走了
)

// 4、新建自定义error实例化
func NewCustomError(code ErrCode) error {
    // 初次调用得用Wrap方法,进行实例化
    return errors.Wrap(&CustomError{
        Code:    code,
        Message: code.String(),
    }, "")
}

3.3.2 自定义Error使用

新建测试文件:error_handler_test.go

package error_handle

import (
    "fmt"
    "github.com/pkg/errors"
    "testing"
)

func TestText(t *testing.T) {
    books := []string{
        "Book1",
        "Book222222",
        "Book3333333333",
    }

    for _, bookName := range books {
        err := searchBook(bookName)

        // 特殊业务场景:如果发现书被借走了,下次再来就行了,不需要作为错误处理
        if err != nil {
            // 提取error这个interface底层的错误码,一般在API的返回前才提取
            // As - 获取错误的具体实现
            var customErr = new(CustomError)
            // As - 解析错误内容
            if errors.As(err, &customErr) {
                //fmt.Printf("AS中的信息:当前书为: %s ,error code is %d, message is %s\n", bookName, customErr.Code, customErr.Message)
                if customErr.Code == BookHasBeenBorrowedError {
                    fmt.Printf("IS中的info信息:%s 已经被借走了, 只需按Info处理!\n", bookName)
                } else {
                    // 如果已有堆栈信息,应调用WithMessage方法
                    newErr := errors.WithMessage(err, "WithMessage err1")
                    // 使用%+v可以打印完整的堆栈信息
                    fmt.Printf("IS中的error信息:%s 未找到,应该按Error处理! ,newErr is: %+v\n", bookName, newErr)
                }
            }
        }
    }
}

func searchBook(bookName string) error {
    // 1 发现图书馆不存在这本书 - 认为是错误,需要打印详细的错误信息
    if len(bookName) > 10 {
        return NewCustomError(BookNotFoundError)
    } else if len(bookName) > 6 {
        // 2 发现书被借走了 - 打印一下被接走的提示即可,不认为是错误
        return NewCustomError(BookHasBeenBorrowedError)
    }
    // 3 找到书 - 不需要任何处理
    return nil
}

4 总结

  1. CustomError 作为全局 error 的底层实现,保存具体的错误码和错误信息;
  2. CustomError向上返回错误时,第一次先用Wrap初始化堆栈,后续用WithMessage增加堆栈信息;
  3. error中解析具体错误时,用errors.As提取出CustomError,其中的错误码和错误信息可以传入到具体的API接口中;
  4. 要判断error是否为指定的错误时,用errors.Is + Handler Error的方法,处理一些特定情况下的逻辑;

Tips:

  1. 不要一直用errors.Wrap来反复包装错误,堆栈信息会爆炸,具体情况可自行测试了解
  2. 利用go generate可以大量简化初始化Erro重复的工作
  3. github.com/pkg/errors和标准库的error完全兼容,可以先替换、后续改造历史遗留的代码
  4. 一定要注意打印error的堆栈需要用%+v,而原来的%v依旧为普通字符串方法;同时也要注意日志采集工具是否支持多行匹配

我是简凡,一个励志用最简单的语言,描述最复杂问题的新时代农民工。求点赞,求关注,如果你对此篇文章有什么疑惑,欢迎在我的微信公众号中留言,我还可以为你提供以下帮助:

  • 帮助建立自己的知识体系
  • 互联网真实高并发场景实战讲解
  • 不定期分享Golang、Java相关业内的经典场景实践

我的博客:https://besthpt.github.io/
微信公众号:"简凡丶"

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容

  • 在实际工程项目中,总是通过程序的错误信息快速定位问题,但是又不希望错误处理代码写的冗余而又啰嗦。Go语言没有提供像...
    drunkery阅读 791评论 0 0
  • 最佳实践 panic 程序在启动时,如果有强依赖的服务出现故障时,需要panic退出 在程序启动时,如果发现有配置...
    匿名回复123阅读 1,192评论 0 1
  • 问题 错误处理,是非常重要的。在go语言中,错误处理被设计的十分简单。如果做得好,会在排查问题等方面很有帮助;如果...
    每天一个俯卧撑阅读 424评论 0 1
  • 错误处理 在实际工程项目中,我们希望通过程序的错误信息快速定位问题,但是又不喜欢错误处理代码写的冗余而又啰嗦。Go...
    那钱有着落吗阅读 352评论 0 0
  • 最近对项目进行了重构,将以前诟病的代码全部删了,重新写了,这里介绍下Lumen里面如何简单的拦截掉所有错误,达到“...
    yieldHL阅读 3,121评论 0 1