四、服务日志和错误处理实现 - FeifeiyuM/go-microservices-boilerplate GitHub Wiki

服务日志和错误处理的实现

本文将主要介绍在服务中都必不可缺的两个部分:日志输出和错误处理。错误和日志这两者也是相符相成的,一般情况代码执行过程中如果遇到错误,我们需要将错误以日志的形式输出,以便于后期问题的排查。 本文下面将介绍如何设计封装和使用日志输出和错误处理。

一、服务日志的集成

在一个任何一个服务应用中,如何输出日志都是一个绕不开的话题,在 JAVA 应用中就有非常知名的日志组件log4j。在 Golang 的标准库中就已经包含了 log 的库,功能也比较丰富。
其他第三方比较知名的日志组件有 logrus 和 uber 推出的 zap,其号称是性能最高的一款日志输出框架。本文基于 zap 日志封装,将其集成到我们的微服务框架中。如 code1 所示, code1 是参考了 uber 推出的链路追踪工具 jeager 中的 example 中的 hotrod 示例下的日志封装 修改简化而来的。

// 定义 log Factory 对象
type Factory struct {
	logger *zap.Logger
}
// 新建 log factory
func NewFactory(logger *zap.Logger) Factory {
	return Factory{logger: logger}
}
// 获取 zap logger 对象
func (b Factory) GetLogger() *zap.Logger {
	return b.logger
}
// 不携带 上下文 context 对象的日志输出
func (b Factory) Bg() *zap.Logger {
	return b.logger
}
// 这边才是关键,可以携带上下文 context 的日志输出
// 这边我们实现了从 context 中抽取 request_id, 并将其作为一个日志输出字段
func (b Factory) For(ctx context.Context) *zap.Logger {
	rID := ctx.Value("_requestID")
	if rID != nil {
		requestID, ok := rID.(string)
		if ok {
			return b.logger.With(zap.String("request_id", requestID))
		}
	}
	return b.Bg()
}

// 新增一个日志输出字段
func (b Factory) With(fields ...zapcore.Field) Factory {
	return Factory{logger: b.logger.With(fields...)}
}

code1

code1 的封装中最关键的一点就是 func (b Factory) For(ctx context.Context) *zap.Logger 方法。在《三、微服务框架中间件(拦截器)的实现》中一直将 request_id 添加到 context.Context 上下文作为例子就是为了配合日志输出的介绍。采用 Factory 的 For 方法输出日志就是为了将 context.Context 对象中的 request_id 一起输出到每条日志中,这样在排查问题时可以根据 request_id 过滤出每一个请求从进入到返回的整个生命周期的日志。

下面我们演示下如何使用 code1 中定义的 log factory,如 code2 所示

// 初始化 zap logger 对象
var logger Factory
func initLogger() {
	// 初始化 zap logger
	zapLogger, err := zap.NewDevelopment(zap.AddStacktrace(zapcore.FatalLevel))
	if err != nil {
		// 如果是日志都初始化失败了,直接抛出 panic 异常
		panic(err)
	}
	logger = NewFactory(zapLogger)
}
// 模拟 handler 函数
func sayHello(ctx context.Context, name string) error {
	// 携带 context 对象输出日志
	logger.For(ctx).Info("with request_id", zap.String("hello", name))
	// 不携带 context
	logger.Bg().Info("without request_id", zap.String("hello", name))
	return nil
}

func main() {
	// 初始化日志
	initLogger()
	// 初始化 context 对象
	requestId := uuid.New().String()
	ctx := context.WithValue(context.TODO(), "_requestID", requestId)
	// 调用 handler 函数
	sayHello(ctx, "world")
}

code2

日志输出结果 如 result1 所示, 第一条是携带 context.Context 对象的日志输出, 在输出结果中就比第二条不携带 context.Context 日志中多了 request_id 字段,其值是我们再初始化 context.Context 对象时设置的 uuid。

2021-04-17T16:10:41.687+0800    INFO    web/logger.go:64        with request_id {"request_id": "52306e34-8758-4abe-9d8f-db2e2de24ee5", "hello": "world"}
2021-04-17T16:10:41.688+0800    INFO    web/logger.go:66        without request_id      {"hello": "world"}

result1 最后,在输出 Fatal 级别的日志时,程序会直接结束进程; 输出 Panic 级别日志时会触发 panic 异常,没有正常捕获这些异常的话,可能会直接导致整个进程挂起。输出这两种级别日志是,一定要谨慎,在业务代码中尽量少用或不用。

二、服务的错误处理

在很多 Web 工程代码中都会一全局地约定一组错误类型,以便在代码出现异常或逻辑错误时选择对应的错误类型抛出。这样做的好处是,让服务调用方可以获得更加标准格式化的错误类型,而不是随意定义,一团乱麻。

如果要定义这样的错误类型, 我们需要在错误类型中包含哪些信息呢?

对于 http 接口来说, 我们一般期待错误类型中包含,1、rest 状态码;2、错误原因;3、code 错误编码等;更进一步我们可以将原始的错误堆栈也加入其中。

对于 gRPC 而言,错误类型是框架定义好的 status.Status,其实例化时接收两个字段 1、code.Status gRPC 的状态;2、Message 错误信息。

对于 nsq 而言,消息的消费是异步的,不需要将错误信息返回给消息的生产方,对错误的处理更多只是日志的输出,因此对于错误的类型没有要求,本文也不过多探讨。

2.1 错误类型定义

综合考虑,错误类型以 Golang 接口的方式定义如 code3 所示。公包含五个接口方法分别是

  • 1、HttpStatus: 表示 http 状态码,用于指定 http 请求返回时的状态码,例如 400: BadeRequest, 500: InternalServerError 等。
  • 2、Code: 表示错误的在服务中的唯一编号,值采用 httpStatus 是无法定位至某个具体错误的。
  • 3、SendMsg:返回的是一个 bool 类型的数据,表示是否需要发送监控通知,例如邮件短信等。
  • 4、Message: 表示错误具体的原因,对用户相对友好可读。
  • 5、Error: 这个命名为 Error 是为了与 Golang 原生的 error 接口相互兼容,其存储的内容是错误的原始信息或错误堆栈。
// 定义 
type GMBError interface {
    // http 状态码
	HttpStatus() int
    // 错误唯一编号
	Code() string
    // 是否需要监控告警
	SendMsg() bool
    // 错误描述,这个应该是对用户友好的错误提示
    Message() string
    // 存放原始错误,错误堆栈
	Error() string
}

code3

下面我们来接单实现下 GMBError 接口,如 code4 所示

type errInfo struct {
	httpStatus int    // http status
	code       string // 自定义错误code
	message      string // 错误描述
	sendMsg  bool   // 是否发送邮件
	err      error  // 错误信息
}
// HttpStatus 获取http 状态值
func (e *errInfo) HttpStatus() int {
	return e.httpStatus
}
// Code 获取自定义 code
func (e *errInfo) Code() string {
	return e.code
}
// SendMail 是否发送告警
func (e *errInfo) SendMsg() bool {
    return e.sendMsg
}
// Alert 获取错描述
func (e *errInfo) Message() string {
	return e.message
}
// Error 错误详情或堆栈
func (e *errInfo) Error() string {
	return e.Error()
}
// NewGMBError 新建错误对象
func NewGMBError(httpCode int, code, message string, sendMsg bool, err error) GMBError {
	return &errInfo{
		httpStatus: httpCode,
		code:       code,
		message:    message,
		sendMsg:    sendMsg,
		err:        err,
	}
}

code4 在实际应用中,我们可以全局初始化一组 GMBError, 在使用的时候直接调用即可。这样 code 编码也不容易出现重复的问题。

2.2 GMBError 到 gRPC error 转化

可以看出在 code3 中定义的错误类型,是专门为 rest 接口设计的,对于 gRPC 又有自身一套错误封装。在逻辑开发中,同时使用两套错误定义是相对混乱的,在逻辑层(service 层),rest 接口 和 gRPC 接口就无法共用一套代码了,这是不可接受的。

有没有可能性将 code3 中定义的 GMBError 与 gRPC 定义的 status.Status 相互转换呢?
在上文中我们介绍了 status.Status 实例化的时候接收 status.Code 和 Message 两个字段。其中 Message 可以对应 GMBError 中的 Message 或者是 Error。

对于 status.Code 我们先来看看其枚举,共16个,例如 code5 中所示。

const (
	// OK 表示成功
	OK Code = 0
    // 表示请求取消
	Canceled Code = 1
    // 表示未知错误,缺乏足够的错误描述
	Unknown Code = 2
	// 无效的请求参数
	InvalidArgument Code = 3
	// 超时
	DeadlineExceeded Code = 4
    // 不存在
	NotFound Code = 5
    // 已经存在
	AlreadyExists Code = 6
    // 权限不足
	PermissionDenied Code = 7
    // 资源耗尽
	ResourceExhausted Code = 8
    // 条件不足(这个相对抽象)
	FailedPrecondition Code = 9
    // 废弃请求
	Aborted Code = 10
    // 数组或其他迭代越界
	OutOfRange Code = 11
    // 方法未实现
	Unimplemented Code = 12
    // 服务内部错误
	Internal Code = 13
    // 服务不可用,有 gRPC 框架本身产生改状态码
	Unavailable Code = 14
    // 数据丢失,不可恢复
	DataLoss Code = 15
    // 未授权
	Unauthenticated Code = 16
)

code5

通过对比 gRPC 的 status.Code 枚举和 http 状态枚举,我们可以发现两个还是在一定程度的相似性,可以相互转化。如 表1 所示。

表1 常用 http 状态码和 gRPC 状态码对应 (n 表示非标准的 http code, ?表示持保留意见)

http code gRPC code 含义
2xx 0 成功 (OK)
499(n) 1 客户端取消请求
400 3 参数错误
408, 502 4 超时
404 5 资源不存在
409 6 资源已经存在
403 7 权限不足
507 8 资源耗尽
412, 422?, 428 9 条件不足
406 10 请求废弃
416? 11 越界
501 12 未实现方法
500 13 服务内部错误
503 14 服务不可用
410? 15 数据丢失
401 16 未授权
其他? 2 未知错误

表1 可以发现,大部分常用的 http 状态码我们都可以找的与之对应的 gRPC 状态码。其他 http 状态码在业务服务中也相对不常用,剩下的状态码在含义是可以大致找到含义相近的 gRPC 状态码,实在不行将其设置为 gRPC 的 Unknown 状态码。

根据表1 我可以实现一个 GMBError 到 gRPC status.Status 的转换函数, 如 code6所示

func GMBErrToGRPCErr(ge GMBError) (error, bool) {
	switch ge.HttpStatus() {
	case http.StatusOK:
		return nil, false
	case http.StatusBadRequest:
		return status.Error(codes.InvalidArgument, ge.Error()), ge.SendMsg()
	case http.StatusRequestTimeout, http.StatusBadGateway:
		return status.Error(codes.DeadlineExceeded, ge.Error()), ge.SendMsg()
	case http.StatusNotFound:
		return status.Error(codes.NotFound, ge.Error()), ge.SendMsg()
	case http.StatusConflict:
		return status.Error(codes.AlreadyExists, ge.Error()), ge.SendMsg()
	case http.StatusForbidden:
		return status.Error(codes.PermissionDenied, ge.Error()), ge.SendMsg()
	case http.StatusInsufficientStorage:
		return status.Error(codes.ResourceExhausted, ge.Error()), ge.SendMsg()
	case http.StatusPreconditionFailed, http.StatusUnprocessableEntity, http.StatusPreconditionRequired:
		return status.Error(codes.FailedPrecondition, ge.Error()), ge.SendMsg()
	case http.StatusNotAcceptable:
		return status.Error(codes.Aborted, ge.Error()), ge.SendMsg()
	//case http.StatusRequestedRangeNotSatisfiable:
	//	return status.Error(codes.OutOfRange, ge.Error()), ge.SendMsg()
	case http.StatusNotImplemented:
		return status.Error(codes.Unimplemented, ge.Error()), ge.SendMsg()
	case http.StatusInternalServerError:
		return status.Error(codes.Internal, ge.Error()), ge.SendMsg()
	case http.StatusServiceUnavailable:
		return status.Error(codes.Unavailable, ge.Error()), ge.SendMsg()
	//case http.StatusGone:
	//	return status.Error(codes.DataLoss, ge.Error()), ge.SendMsg()
	default:
		return status.Error(codes.Unknown, ge.Error()), ge.SendMsg()
	}
}

code6

2.3 自定义的 GMBError 应用在 框架的哪一层

按道理,GMBError 在框架的 任意一层,model,dao,service,interface 层都使用。但是考虑到 GMBError 定义的特点来看,承载的更多的是业务逻辑上功能。

比如:当 dao 层数据 E 查询结果为空时,在查询逻辑A中,我们可以将 HttpCode 设置为 404 NotFound, message 设置为 XX不存在, 但是在某个写入逻辑B中需要 E 作为前置条件,假如 E 不存在我们,则我们需要 将 HttpCode 设置为 412 StatusPreconditionFailed, message 设置 E 存在设置条件不足。 如果我们将这层判断放在 service 层,dao 层的查询方法就可以被不同逻辑内复用。

因此,将 GMBError 这个错误类型应用于 service 和 interface 是比较合理的选择。 dao 和 model 层 都使用 Golang 原生的 error, 向上层抛出,这样兼容性,扩展性都更好。

三、代码示例

按 《基于 echo 搭建的简单 http 服务和工程组织结构》 定义的工程框架,我们将 日志输出和错误处理都集成进框架中,并将《微服务框架中间件(拦截器)的实现》代码示例中标准日志输出和错误处理中间件函数完善。 整合后工程地址