Golang之接入日志模块

Table of contents

  1. 1. 接入日志模块
    1. 1.1. 日志通用理论
      1. 1.1.1. 级别
      2. 1.1.2. 什么时候打日志?打什么级别?
    2. 1.2. 接入日志模块
      1. 1.2.1. 打印日志的基本操作——Error
      2. 1.2.2. 打印日志的基本操作——Warn
      3. 1.2.3. 打印日志的基本操作——Info
      4. 1.2.4. 打印日志的基本操作——Debug
      5. 1.2.5. 不使用包变量——保持依赖注入风格
      6. 1.2.6. 抽象日志API
        1. 1.2.6.1. 打印响应
      7. 1.2.7. GORM打印日志

接入日志模块

日志通用理论

级别

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接口的同时,未动我们自己的接口分毫