接入日志模块
日志通用理论
级别
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
变量,但是这个全局变量什么功能都木有实现,也就是说,它连打印日志的功能都没有
因此需要使用NewDevelopment()
方法创建一个可以使用的logger
并替换掉全局变量的初始值
打印日志的基本操作——Error
当出现像是系统错误时,就能打印Error级别的日志
if err != nil {
ctx.JSON(http.StatusOK, Result{Code: 5, Msg: "系统错误"})
zap.L().Error("vcxnq[nzvx 用户手机号码登录失败", zap.Error(err))
return
}
打印日志不仅能传入msg信息,还能够传入一些error
、int
之类的字段,最终打印结果形如下图:
注意几个细节:
- 敏感数据(例如手机号码)不能打入日志,勉强能够接受使用debug级别打印敏感数据用于本地开发
- msg要有足够定位bug出处的信息,例如在日志中用一段乱码唯一标识日志,这样只需要全局检索这段乱码就能找到出错的位置
- 绝对不能把error返回给前端,暴露给用户
打印日志的基本操作——Warn
在此处打印了一个Warn日志,因为短信发送频繁是偶尔会发生的,能够接受。但要是频繁出现Warn,就很有可能是有人在攻击系统,那就要告警引起注意了
打印日志的基本操作——Info
Info日志用于记录一些中性信息,例如
打印日志的基本操作——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,并且保持着依赖注入的风格
还有一种更简单的依赖注入方法:注入了,但是没有完全注入
这种做法的好处就是,为将来扩展留下了可操作的空间
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
,这个接口是用来写响应的。那么我们可以“偷梁换柱”,将这个接口换成我们自己的实现:在写响应之前将数据保留下来,这样就有了响应的具体数据
在这里我们只需要重写WriteHeader
、Write
、WriteString
这三个方法,所以装饰器模式在实现的时候可以采用组合的方式
- 组合 —> 装饰部分方法
- 非组合 —> 装饰全部方法
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
接口的实现,这个实现将会用来输出日志
那么现在就是需要将我们自己实现的日志接口适配到gorm的Writer
接口上
好巧不巧,Writer
接口只有一个方法,那么就可以用上衍生函数+函数实现单接口技巧,快速将我们的日志接口适配到此处
这就是为什么我们的衍生函数的原类型是func(msg string, fields...zap.Field)
,这正是l.Debug()
方法的函数类型啊
最后传入Writer
接口的时候,利用衍生类型将l.Debug()
强制类型转换,使其间接地实现了Writer
接口的同时,未动我们自己的接口分毫