四、服务日志和错误处理实现 - 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 服务和工程组织结构》 定义的工程框架,我们将 日志输出和错误处理都集成进框架中,并将《微服务框架中间件(拦截器)的实现》代码示例中标准日志输出和错误处理中间件函数完善。 整合后工程地址