Golang之接入日志模块

接入日志模块

日志通用理论

级别

DEBUG:记录一些辅助排查问题的信息。一般不会在线上使用

INFO:中性地描述发送了什么。线上一般都是用这个级别

WARN:系统发生了一些不好但是能够容忍的事情。至少会打印这个级别的日志

ERROR:系统发生了一些需要保持关注的事情

什么时候打日志?打什么级别?

两个原则

  • 如果怀疑某个地方要不要打日志,那就打上
  • 宁滥勿缺,宁愿多打一些日志,也不要偷懒

前人的经验

  • 统一利用AOP机制,记录任何与第三方打交道的请求与响应,包括数据库、缓存、RPC调用,使用DEBUG级别记录日志
  • 统一利用AOP机制,记录系统接收的请求与放回的响应,使用DEBUG级别
  • 在开发阶段,使用DEBUG级别记录中间流程中的关键结果
  • 怀疑系统可能有问题,但是问题又不大,记录WARN
  • 任何不可能出现问题,或者表达有人攻击系统的地方,记录ERROR

接入日志模块

在Go里面,可以使用zap作为日志框架

uber-go/zap: Blazing fast, structured, leveled logging in Go. (github.com)

初始化方法:

  • 直接设置一个全局的logger即可
logger, err := zap.NewDevelopment()
if err != nil {
	panic(err)
}
zap.ReplaceGlobals(logger)

zap中含有一个全局的globalL变量,但是这个全局变量什么功能都木有实现,也就是说,它连打印日志的功能都没有

屏幕截图 2024-09-02 193645

因此需要使用NewDevelopment()方法创建一个可以使用的logger并替换掉全局变量的初始值

屏幕截图 2024-09-02 193655

打印日志的基本操作——Error

当出现像是系统错误时,就能打印Error级别的日志

if err != nil {
	ctx.JSON(http.StatusOK, Result{Code: 5, Msg: "系统错误"})
	zap.L().Error("vcxnq[nzvx 用户手机号码登录失败", zap.Error(err))
	return
}

打印日志不仅能传入msg信息,还能够传入一些errorint之类的字段,最终打印结果形如下图:

屏幕截图 2024-09-02 194610

注意几个细节:

  • 敏感数据(例如手机号码)不能打入日志,勉强能够接受使用debug级别打印敏感数据用于本地开发
  • msg要有足够定位bug出处的信息,例如在日志中用一段乱码唯一标识日志,这样只需要全局检索这段乱码就能找到出错的位置
  • 绝对不能把error返回给前端,暴露给用户

打印日志的基本操作——Warn

image-20240902200117065

在此处打印了一个Warn日志,因为短信发送频繁是偶尔会发生的,能够接受。但要是频繁出现Warn,就很有可能是有人在攻击系统,那就要告警引起注意了

打印日志的基本操作——Info

Info日志用于记录一些中性信息,例如

image-20240902203836373

打印日志的基本操作——Debug

Debug日志可以用于记录和第三方交互的动作

func (s *Service) Send(ctx context.Context, tpl string, args []sms.NameData, numbers ...string) error {
	zap.L().Debug("avnasio 即将发送短信", zap.String("tpl", tpl), zap.Any("args", args))
	err := s.svc.Send(ctx, tpl, args, numbers...)
	if err != nil {
		zap.L().Debug("发送短信出现异常", zap.Error(err))
		return err
	}
	return err
}

可以考虑使用装饰器模式扩展Write()方法的功能

例如,自动将key为phone的字段进行数据脱敏

type MyCore struct {
	zapcore.Core
}

func (c *MyCore) Write(e zapcore.Entry, fs []zapcore.Field) error {
	// 装饰器模式,进行数据脱敏
	for _, f := range fs {
		if f.Key == "phone" {
			number := f.String
			f.String = number[:3] + "****" + number[7:]
		}
	}
	return c.Core.Write(e, fs)
}

装饰器模式实现一个core,能够自动将key为phone的字段对应的值进行脱敏

不使用包变量——保持依赖注入风格

有的时候不同的业务不愿意共享同一个全局的logger,这个时候就需要在不同地方创建属于他们自己的logger

一个很直接的方法就是在service层添加logger字段,为不同业务引入各自的logger,并且保持着依赖注入的风格

屏幕截图 2024-09-03 214039

还有一种更简单的依赖注入方法:注入了,但是没有完全注入

这种做法的好处就是,为将来扩展留下了可操作的空间

func NewUserService(repo repository.UserRepository) UserService {
	return &BasicUserService{
		repo:   repo,
        // 预留了变动的空间
        logger: zap.L(),
	}
}

抽象日志API

做到这里不难发现,不管是配置模块也好,日志模块也好,我们的代码都强耦合viper框架和zap框架。这就会有潜在的风险:万一连viper和zap框架都想换掉呢?

因此,我们需要定义自己logger接口,弱化对zap框架的依赖

具体有三种风格

  • type Logger interface {
        Debug(msg string, args...any)
        Info(msg string, args...any)
        Warn(msg string, args...any)
        Error(msg string, args...any)
    }
    
    func Example() {
        var l Logger
        // 在msg中留有占位符
        l.Info("用户未注册,用户信息为 %v", "ababab")
    }
    
    
    - ```go
      type LoggerV1 interface {
      	Debug(msg string, args ...Field)
      	Info(msg string, args ...Field)
      	Warn(msg string, args ...Field)
      	Error(msg string, args ...Field)
      }
      
      type Field struct {
      	Key string
      	Val any
      }
      
      func ExampleV1() {
      	var l LoggerV1
      	// 输出的val有对应的key,和zap采用相同的模式
      	l.Info("这个msg", Field{Key: "这个是key", Val: "这个是Val"})
      }
      
  • // LoggerV2 要求args的个数是偶数,以key-val形式交替出现
    type LoggerV2 interface {
        Debug(msg string, args ...any)
        Info(msg string, args ...any)
        Warn(msg string, args ...any)
        Error(msg string, args ...any)
    }
    
    func ExampleV2() {
        var l LoggerV2
        l.Info("这是msg", "key", "val")
    }
    
    
    - 第一种风格**兼容性最好**
    - 第二种风格认同**参数就应该有个名字**
    - 第三种风格**如果有完善的代码评审流程的话可以考虑考虑**,不然不推荐
    
    ### 适配器模式
    
    既然已经抽象出了日志接口,那么具体该如何实现呢?
    
    答案就是采用**适配器模式**
    
    所谓适配器模式,就是**将一个类型适配到另一个接口上**。例如,将A适配到B,这里的A可以是接口,也可以是一个具体的类型
    
    **典型的使用场景**
    
    - 有两个用途类似,但是细节上略有不同的接口,将一个接口适配到另一个接口上
    - 由于版本迭代,出现了两个接口,需要将一个接口适配到另一个接口上
    
    **与装饰器模式的区别**
    
    - 装饰器模式用于**扩展功能**,自始至终**都是一个接口**
    - 适配器模式**必然是两个不同的接口**
    
    ### 利用Gin的middleware打印日志
    
    在handler文件中,有很多个方法,如果一一为这些方法打印日志需要花费很多时间和精力
    
    可是发现,handler中的方法都是与请求和响应有关的,那么就可以采用`AOP`机制,利用gin的middleware来统一打日志
    
    #### 使用builder模式构建中间件
    
    ```go
    // 几个注意点:
    // 小心日志内容过多:URL可能很长,请求体、响应体都可能特别特别大,需要考虑是否要进行截取
    // 用户可能换用不同的日志框架,所以要有足够的灵活性
    // 考虑结合viper实现动态开关,但是要小心并发安全
    func (b *Builder) Build() gin.HandlerFunc {
    	return func(ctx *gin.Context) {
    		start := time.Now()
    		al := AccessLog{}
    		al.Method = ctx.Request.Method
    		if b.allowURL && ctx.Request.URL != nil {
    			url := ctx.Request.URL.String()
    			if len(url) > 1024 {
    				url = url[:1024]
    			}
    			al.URL = url
    		}
    		if b.allowReqBody && ctx.Request.Body != nil {
    			// 这里忽略错误,毕竟我只是打印个日志,有错误也无所谓了
    			data, _ := ctx.GetRawData()
    			// Request.Body是一个流对象,读完之后Request里面就木有了
    			// 所以还得把数据放回Request.Body,不然后续步骤就读不到这个数据了
    			ctx.Request.Body = io.NopCloser(bytes.NewBuffer(data))
    			body := string(data)
    			if len(body) > 1024 {
    				al.ReqBody = body
    			}
    		}
    		// defer 打日志,即使有panic也不会影响日志的输出
    		defer func() {
    			al.Duration = time.Since(start).String()
    			// 具体怎么打印日志,由用户决定
    			b.logFunc(ctx, al)
    		}()
    		// 执行到业务代码(不是很懂)
    		ctx.Next()
    	}
    }
    
    type AccessLog struct {
    	// 请求方法
    	Method string
    	// url
    	URL string
    	// 请求体
    	ReqBody string
    	// 处理时间
    	Duration string
    	// 响应体
    	RespBody string
    }

一个有意思的点:为什么要让用户传进来一个打日志的方法呢?

如果不传递方法,那么在实现中不管我们是用Info()Debug()还是Error()都是写死在代码中的,不能灵活地应对不同的场景,例如开发环境要用Debug(),可能线上环境我就要用Info()

那么不如就让用户传进来一个方法,只为用户提供一个接口,屏蔽底层的实现,用户只需要考虑在不同环境选择使用不同的方法就行了

打印响应

正常来说,打印响应就应该和上文中打印请求一样简单:直接从context里面拿请求就行

不幸的是,ctx并没有暴露出Response,也就是不能直接读响应

但是,ctx暴露出了ResponseWriter,这个接口是用来写响应的。那么我们可以“偷梁换柱”,将这个接口换成我们自己的实现:在写响应之前将数据保留下来,这样就有了响应的具体数据

在这里我们只需要重写WriteHeaderWriteWriteString这三个方法,所以装饰器模式在实现的时候可以采用组合的方式

  • 组合 —> 装饰部分方法
  • 非组合 —> 装饰全部方法
if b.allowRespBody {
	// 偷梁换柱
	ctx.Writer = &responseWriter{
		al: &al,
		ResponseWriter: ctx.Writer,
	}
}

type responseWriter struct {
	// 这里用指针,为了能留住“偷”过来的数据
	al *AccessLog
	gin.ResponseWriter
}

func (r *responseWriter) WriteString(data string) (int, error) {
	r.al.RespBody = data
	return r.ResponseWriter.WriteString(data)
}

func (r *responseWriter) Write(data []byte) (int, error) {
	r.al.RespBody = string(data)
	return r.ResponseWriter.Write(data)
}

func (r *responseWriter) WriteHeader(statusCode int) {
	r.al.Status = statusCode
	r.ResponseWriter.WriteHeader(statusCode)
}

GORM打印日志

直接上代码:

// 衍生类型,有点像适配器模式
type gormLoggerFunc func(msg string, fields ...zap.Field)

// Printf 实现单接口
func (g gormLoggerFunc) Printf(msg string, fields ...interface{}) {
	g(msg, zap.Any("args", fields))
}



func InitDB(l *zap.Logger) *gorm.DB {
	type Config struct {
		DSN string `yaml:"dsn"`
	}
	var config Config
	config.DSN = "abab"
	err := viper.UnmarshalKey("db", &config)
	if err != nil {
		panic(err)
	}
	db, err := gorm.Open(mysql.Open(config.DSN), &gorm.Config{
		// 强制类型转换
		Logger: logger.New(gormLoggerFunc(l.Debug), logger.Config{
			// 慢查询阈值,只有语句执行时间超过这个阈值才会打印日志
            // 一般为50ms 100ms,一次磁盘io操作大概是10ms
			SlowThreshold:             time.Millisecond * 10,
			LogLevel:                  logger.Info,
			IgnoreRecordNotFoundError: true,
			ParameterizedQueries:      true,
		}),
	})
	if err != nil {
		panic(err)
	}
	err = dao.InitTable(db)
	if err != nil {
		panic(err)
	}
	return db
}

gorm已经为我们提供了有关日志的配置,所以我们可以直接使用配置实现打日志功能

这里有个小问题,logger.New()方法的第一个参数要求传入Writer接口的实现,这个实现将会用来输出日志

image-20240907163634983

那么现在就是需要将我们自己实现的日志接口适配到gorm的Writer接口上

好巧不巧,Writer接口只有一个方法,那么就可以用上衍生函数+函数实现单接口技巧,快速将我们的日志接口适配到此处

这就是为什么我们的衍生函数的原类型是func(msg string, fields...zap.Field),这正是l.Debug()方法的函数类型啊

最后传入Writer接口的时候,利用衍生类型将l.Debug()强制类型转换,使其间接地实现了Writer接口的同时,未动我们自己的接口分毫