95.Go设计优雅的错误处理(带堆栈信息)

时间:2024-04-15 14:41:34

在之前的两篇文章中,我们已经介绍过错误的一些优雅处理
75.错误码设计、实现统一异常处理和封装统一返回结果
88.Go设计优雅的错误处理

本文想继续写一篇,可以作为工具包直接使用。也是记录一种新的思路和编码技巧,同时创建错误的时候会自动打印日志,还能提供堆栈信息。

目标

  1. 避免所有错误前都需要手动打印日志,最好自动打印规范化的日志;
  2. 完整的上下文信息,便于排查定位;
  3. 方便response封装,返回标准三元组;
  4. 高扩展性;

代码如下

代码地址:https://gitee.com/lymgoforIT/golang-trick/blob/master/42-bizerror/bizerror/bizerror.go

注:"google.golang.org/appengine/log",需要在谷歌的云计算平台才能使用,所以下面的代码需要根据实际情况替换日志包。否则会报 not an App Engine context错误

package bizerror

import (
	"bytes"
	"context"
	"fmt"
	"google.golang.org/appengine/log"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
)

// BizError 自定义Error类型(实现了go内嵌error接口)
// 特性:
//  1. 包含服务返回三元组(Code + Msg + status), 便于封装response
//  2. 自动日志打印(NewBizError时打印)
//  3. 根据可选参数可控制是否打印堆栈信息
//  4. 其他option拓展见使用说明
type BizError struct {
	code        string                           // 错误码
	msg         string                           // 错误信息
	status      string                           // 状态
	level       BizErrLevel                      // 日志级别,默认是Error级别
	detail      string                           // 需要打印的补充信息
	fnName      string                           // 函数名
	storeStack  bool                             // 是否打印堆栈信息
	stack       []byte                           // 堆栈信息
	stackRows   int                              // 堆栈信息最大打印层次
	depth       int                              // 函数调用深度
	channelCode string                           // 下游错误码
	channelMsg  string                           // 下游错误信息
	asyncFn     func(context.Context, *BizError) // 异步执行函数
}

// BizErrLevel 错误等级, 会影响日志打印时的level
type BizErrLevel int8

// BizErrOption BizError属性设置函数
type BizErrOption func(*BizError)

const (
	// InfoLevel Info级别, 使用logs.CtxInfo打印日志
	InfoLevel BizErrLevel = iota
	// WarnLevel Warn级别, 使用logs.CtxWarn打印日志
	WarnLevel
	// ErrorLevel Error级别, 使用logs.CtxError打印日志
	ErrorLevel
)

func (e BizError) Error() string {
	errInfo := fmt.Sprintf("[%s] code=%s, msg=%s, channelCode=%s, channelMsg=%s, detail=%s",
		e.fnName, e.code, e.msg, e.channelCode, e.channelMsg, e.detail)
	if e.storeStack {
		errInfo = errInfo + "\n" + string(e.stack)
	}
	return errInfo
}

func (e BizError) GetCode() string {
	return e.code
}

func (e BizError) GetStatus() string {
	return e.status
}

func (e BizError) GetMsg() string {
	return e.msg
}
func (e BizError) GetDetail() string {
	return e.detail
}

func (e BizError) GetChannelCode() string {
	return e.channelCode
}

func (e BizError) GetChannelMsg() string {
	return e.channelMsg
}

func NewBizError(ctx context.Context, code, status, msg string, opts ...BizErrOption) *BizError {
	bizErr := &BizError{
		code:       code,
		msg:        msg,
		status:     status,
		level:      ErrorLevel,
		storeStack: true,
		depth:      2, // 为0时是getCurrentFunc,为1时是NewBizError,为2时则是调用NewBizError的函数
		stackRows:  10,
	}

	for _, opt := range opts {
		opt(bizErr)
	}

	if len(bizErr.fnName) == 0 {
		bizErr.fnName = getCurrentFunc(bizErr.depth)
	}

	if bizErr.storeStack {
		bizErr.stack = getStack(bizErr.depth, bizErr.stackRows)
	}

	bizErr.ctxLog(ctx)

	if bizErr.asyncFn != nil {
		go safeGo(ctx, func() {
			bizErr.asyncFn(ctx, bizErr)
		})
	}
	return bizErr
}

func safeGo(ctx context.Context, f func()) {
	defer func() {
		if err := recover(); err != nil {

		}
	}()
	f()
}

// WithLogLevelOption 设置日志打印等级, 不设置时默认为ErrorLevel
func WithLogLevelOption(level BizErrLevel) BizErrOption {
	return func(e *BizError) {
		e.level = level
	}
}

// WithDetailOption 设置报错详细信息, 如单号/Uid等参数
func WithDetailOption(format string, v ...interface{}) BizErrOption {
	return func(e *BizError) {
		e.detail = fmt.Sprintf(format, v...)
	}
}

// WithFuncNameOption 设置打印日志时的报错函数名, 不设置时默认打印调用NewBizError的函数名
func WithFuncNameOption(funcName string) BizErrOption {
	return func(e *BizError) {
		e.fnName = funcName
	}
}

// WithStackOption 设置是否保存函数栈信息, 不设置时默认保存
func WithStackOption(storeStack bool) BizErrOption {
	return func(e *BizError) {
		e.storeStack = storeStack
	}
}

// WithSkipDepthOption 设置跳过的函数栈深度, 当你封装NewBizError时应该设置
func WithSkipDepthOption(skipDepth int) BizErrOption {
	return func(e *BizError) {
		e.depth += skipDepth
	}
}

// WithChannelRespOption 设置下游返回的错误码/消息, 当异常是下游导致的可以设置
func WithChannelRespOption(channelCode, channelMsg string) BizErrOption {
	return func(e *BizError) {
		e.channelCode = channelCode
		e.channelMsg = channelMsg
	}
}

// WithAsyncExecutor 产生错误后异步执行器, 如进行上报metrics打点
func WithAsyncExecutor(fn func(context.Context, *BizError)) BizErrOption {
	return func(e *BizError) {
		e.asyncFn = fn
	}
}

// WithStackRows 函数堆栈保存的行数, 默认保存10行
func WithStackRows(stackRows int) BizErrOption {
	return func(e *BizError) {
		if stackRows > 0 {
			e.stackRows = stackRows
		}
	}
}

// logFunc 定义日志打印函数,根据getLogFunc返回的实际指定的日志等级决定使用哪个函数
type logFunc func(ctx context.Context, format string, v ...interface{})

// ctxLog 实际打印日志
func (e BizError) ctxLog(ctx context.Context) {
	e.getLogFunc()(ctx, "%s", e.Error())
}

// getLogFunc 根据日志等级获取日志打印函数,默认为Error级别
func (e BizError) getLogFunc() logFunc {
	switch e.level {
	case InfoLevel:
		return log.Infof
	case WarnLevel:
		return log.Warningf
	case ErrorLevel:
		return log.Errorf
	}
	return log.Errorf
}

// getCurrentFunc 返回文件路径,函数所在行数以及函数名
func getCurrentFunc(skip int) string {
	pc, file, line, ok := runtime.Caller(skip)
	if !ok {
		return "??:0:??()"
	}
	funcName := runtime.FuncForPC(pc).Name()
	// 如 函数为/XXX/util.CallerTest,则扩展名为.CallerTest,去掉左侧的.后为CallerTest
	funcName = strings.TrimLeft(filepath.Ext(funcName), ".") + "()"
	return filepath.Base(file) + ":" + strconv.Itoa(line) + ":" + funcName
}

// getStack 返回一个格式良好的堆栈帧,跳过跳过帧
func getStack(skip, rows int) []byte {
	buf := new(bytes.Buffer) // 返回数据
	// 在循环时,打开文件并读取它们,使用变量记录当前加载的文件
	for i := skip; i-skip < rows; i++ { // 跳过最里层的skip帧
		pc, file, line, ok := runtime.Caller(i)
		if !ok {
			break
		}
		// 拼接当前所在栈的信息,并换回,继续去循环下一栈信息,直到堆栈信息都打完或者达到rows层
		fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc)
	}
	return buf.Bytes()
}

单元测试

package bizerror

import (
	"context"
	"github.com/smartystreets/goconvey/convey"
	"google.golang.org/appengine/log"

	"testing"
	"time"
)

func TestBizError(t *testing.T) {
	ctx := context.Background()

	convey.Convey("NewBizError-无额外选项", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg")
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
	})

	convey.Convey("NewBizError-增加详情", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithDetailOption("query failed with order id: %d", 123))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
		convey.So(bizErr.GetDetail(), convey.ShouldEqual, "query failed with order id: 123")
	})

	convey.Convey("NewBizError-设置日志打印级别", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithLogLevelOption(InfoLevel))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")

		bizErr2 := NewBizError(ctx, "code", "status", "msg", WithLogLevelOption(WarnLevel))
		convey.So(bizErr2, convey.ShouldNotBeNil)
	})

	convey.Convey("NewBizError-设置函数名称", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithFuncNameOption("TestBizError"))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
	})

	convey.Convey("NewBizError-设置不存储堆栈信息", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithStackOption(false))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
	})

	convey.Convey("NewBizError-设置堆栈行数", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithStackRows(2))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
	})

	convey.Convey("NewBizError-设置忽略的函数栈深度", t, func() {
		newCodeErr := func() *BizError {
			return NewBizError(ctx, "code01", "status01", "msg01", WithSkipDepthOption(1))
		}
		bizErr := newCodeErr()
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code01")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status01")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg01")
	})

	convey.Convey("NewBizError-设置下游错误码/信息", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithChannelRespOption("channelCode", "channelMsg"))
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
		convey.So(bizErr.GetChannelCode(), convey.ShouldEqual, "channelCode")
		convey.So(bizErr.GetChannelMsg(), convey.ShouldEqual, "channelMsg")
	})

	convey.Convey("NewBizError-设置异步执行器", t, func() {
		bizErr := NewBizError(ctx, "code", "status", "msg", WithAsyncExecutor(func(ctx context.Context, bizError *BizError) {
			log.Infof(ctx, "AsyncExecutor executed: bizError=%s", bizError.Error())
		}))
		time.Sleep(1 * time.Second)
		convey.So(bizErr, convey.ShouldNotBeNil)
		convey.So(bizErr.GetCode(), convey.ShouldEqual, "code")
		convey.So(bizErr.GetStatus(), convey.ShouldEqual, "status")
		convey.So(bizErr.GetMsg(), convey.ShouldEqual, "msg")
	})
}