当前位置: 首页 > 科技观察

从Go日志库到Zap,如何创建好用又实用的Logger

时间:2023-03-14 16:22:56 科技观察

日志对于程序和程序员来说都非常重要。它有多重要?想要长期在公司工作,就必须学会阶段性划水,阶段性划水的要点之一就是工作速度比预期快但假装。..不,这样一开始就不对,我们重新开始吧。日志对于程序和程序员来说都是非常重要的。除了经验,程序员解决问题的速度还取决于日志能否有效记录问题的场景和上下文。所以程序要记录有效的日志,除了程序中有精确的记录点外,还需要有一个随手可得的Logger。一个好的Logger(记录器)。必须提供以下能力:支持将日志写入多个输出流,例如测试和开发环境可以有选择地同时输出日志到控制台和日志文件,生产环境只输出日志到文件。支持多级日志级别,比如常见的有:TRACE、DEBUG、INFO、WARN、ERROR等。支持结构化输出,现在常用的是JSON的形式,这样统一的日志平台可以直接将日志聚合到通过logstash等组件实现日志平台。需要支持日志切割——日志轮换,根据日期、时间间隔或文件大小来切割日志。在LogEntry(即每一行记录)中除了主动记录的信息外,还包括打印日志、所在文件、行号、记录时间等功能。今天就带大家去了解如何在使用Go语言开发的项目中创建一个方便的Logger。在此之前,让我们回到2009年,看看Go语言从一开始就为我们提供的内置Logger。Go语言的原生Logger自带了一个内置的日志包,为我们提供了一个默认的Logger,可以直接使用。这个库的详细用法可以参考官方文档:https://pkg.go.dev/log使用log记录日志,默认会输出到控制台。比如下面这个例子:packagemainimport("log""net/http")funcmain(){simpleHttpGet("www.baidu.com")simpleHttpGet("https://img.ydisp.cn/news/20220901/kvdpsqnpdwx.com")}funcsimpleHttpGet(urlstring){resp,err:=http.Get(url)iferr!=nil{log.Printf("获取url%s时出错:%s",url,err.Error())}else{log.Printf("StatusCodefor%s:%s",url,resp.Status)resp.Body.Close()}return}在这个例程中,分别向两个URL发出GET请求,然后记录返回状态码/请求错误。执行程序后会有类似的输出:2022/05/1515:15:26Errorfetchingurlwww.baidu.com:Get"www.baidu.com":unsupportedprotocolscheme""2022/05/1515:15:26StatusCodeforhttps://www.baidu.com:200OK因为第一个请求的url中的协议头缺失,所以无法成功发起请求,错误信息在日志。当然,Go内置的日志包也支持将日志输出到文件,任何io.Writer的实现都可以通过log.SetOutput设置为日志的输出。接下来,我们修改上面的例程,将日志输出到文件中。packagemainimport("log""net/http""os")funcmain(){SetupLogger()simpleHttpGet("www.baidu.com")simpleHttpGet("https://img.ydisp.cn/news/20220901/kvdpsqnpdwx.com")}funcSetupLogger(){logFileLocation,_:=os.OpenFile("/tmp/test.log",os.O_CREATE|os.O_APPEND|os.O_RDWR,0644)log.SetOutput(logFileLocation)}funcsimpleHttpGet(urlstring){resp,err:=http.Get(url)iferr!=nil{log.Printf("Errorfetchingurl%s:%s",url,err.Error())}else{log.Printf("StatusCodefor%s:%s",url,resp.Status)resp.Body.Close()}return}运行效果大家可以自己试试,这里就不做过多演示了.Go语言原生Logger的缺点原生Logger的优点很明显,简单,开箱即用,不需要引用外部第三方库。我们可以按照一开始提出的Logger的5个标准,看看项目中是否可以使用默认的Logger。只有基本日志级别只有一个打印选项。不支持INFO/DEBUG等多个级别。对于错误日志记录,它有Fatal和PanicFatal日志记录通过调用os.Exit(1)结束程序程序缺乏构造日志格式的能力——只支持简单的文本输出,日志记录不能格式化成JSON格式。不提供记录切割的能力。Zap日志库在Go生态中有很多日志库可以选择。之前我们简单介绍过logrus库的使用:点我查看。在api层面兼容Go内置的日志库,直接实现日志。Logger接口支持将程序的系统级Logger切换到它上面。但是logrus在性能敏感的场景下表现不佳,用的比较多的是Uber开源的zap日志库。由于Uber在当今Go生态中的高贡献度,加上自身业务的性能敏感场景——网约车,Uber的开源库非常受欢迎。现在有很多项目使用Zap作为Logger。程序员的内心OS应该是,不管我的并发高不高,我完蛋了。万一哪天我能突然从2并发工作到2W并发呢。Zap性能高的一个原因是,没有反射,日志中每个要写入的字段都必须携带一个类型。logger.Info("Success..",zap.String("statusCode",resp.Status),zap.String("url",url))向日志写入一条记录,Message为"Success.."另外写入了两个字符串键值对。对于日志中要写入的字段,Zap对于每种类型都有相应的方法将字段转换为zap.Field类型。例如:zap.Int('key',123)zap.Bool('key',true)zap.Error('err',err)zap.Any('arbitraryType',&User{})这样的还有很多类型方法就不一一列举了。这种记录日志的方式导致用户体验稍差,但考虑到性能优势,用户体验的损失是可以接受的。接下来我们先学习如何使用Zap,然后在项目中使用Zap的时候做一些自定义的配置和打包,让它更好用。最重要的是要匹配好我们一开始提出的五个标准的Logger。如何使用Zap安装zap首先说一下zap的安装方法,直接运行如下命令将zap下载到本地依赖库即可。goget-ugo.uber.org/zap设置Logger先说zap提供的配置Logger,后面再自定义。可以通过调用zap.NewProduction()、zap.NewDevelopment()和zap.Example()这三个方法来创建记录器。以上三种方法都可以创建Logger,它们对Logger的配置也各不相同。例如zap.NewProduction()创建的Logger在记录日志时会自动记录调用函数的信息和记录时间。这三种方法都没有使用。纠结,直接使用zap.NewProduction(),而在项目中使用时,我们不会直接使用zap配置的Logger,需要做更细致的定制。zap的Logger提供了记录不同级别日志的方法,比如日志级别从低到高:Debug、Info、Warn、Error这些级别都有相应的方法。它们的使用方式相同,下面是Info方法的方法签名。func(log*Logger)Info(msgstring,fields...Field){如果ce:=log.check(InfoLevel,msg);ce!=nil{ce.Write(fields...)}}方法的第一个参数是日志中msg字段要记录的信息。msg是日志行记录中的固定字段。日志中添加其他字段,直接传入zap.Field类型参数即可。我们已经提到了zap。Field类型的字段是通过zap.String("key","value")等方法创建的。由于Info方法签名中的fileds参数声明是可变参数,所以支持在日志记录中添加任意数量的字段,如例程中:logger.Info("Success..",zap.String("statusCode",resp.Status),zap.String("url",url))即在日志行记录中,除了msg字段外,还增加了statusCode和url两个自定义字段。上述例程中使用的zap.NewProduction()创建的Logger会将JSON格式的日志行输出到控制台。例如,使用上面的Info方法后,控制台会有类似下面的输出。{"level":"info","ts":1558882294.665447,"caller":"basiclogger/UberGoLogger.go:31","msg":"Success..","statusCode":"200OK","url":"https://img.ydisp.cn/news/20220901/kvdpsqnpdwx.com"}自定义Zap的Logger接下来我们进一步自定义Zap的配置,让日志不仅仅输出到控制台,也到文件中,然后将日志时间从时间戳格式更改为人类更容易理解的DateTime时间格式。话不多说,直接上代码,必要的解释放在注释里。varlogger*zap.Loggerfuncinit(){encoderConfig:=zap.NewProductionEncoderConfig()//设置日志记录中时间的格式格式编码器:=zapcore.NewJSONEncoder(encoderConfig)文件,_:=os.OpenFile(“/tmp/test.log”,os.O_CREATE|os.O_APPEND|os.O_WRONLY,644)fileWriteSyncer=zapcore.AddSync(文件)core:=zapcore.NewTee(//把日志同时写到控制台和文件,生产环境记得去掉console写,日志记录基本都是Debug以上的,生产环境记得改它到Infozapcore.NewCore(encoder,zapcore.AddSync(os.Stdout),zapcore.DebugLevel),zapcore.NewCore(encoder,fileWriteSyncer,zapcore.DebugLevel),)logger=zap.New(core)}日志切割Zap本身不支持原木切割,可以使用另一个库伐木工辅助完成切割。funcgetFileLogWriter()(writeSyncerzapcore.WriteSyncer){//使用lumberjack实现logrotatelumberJackLogger:=&lumberjack.Logger{Filename:"/tmp/test.log",MaxSize:100,//单个文件的最大大小是100MMaxBackups:60,//超过60个日志文件后,清理旧日志MaxAge:1,//每天切一次Compress:false,}returnzapcore.AddSync(lumberJackLogger)}EncapsulateLogger我们不能使用日志每次,所以我们可以这样设置范,所以最好把这些配置初始化放在一个单独的包里,这样在项目中就可以初始化一次。除了以上配置,我们的配置还少了一些日志调用者的信息,比如函数名、文件位置、行号等,这样在排查和查看日志的时候,定位问题的及时性会大大提高。这里用到了我们上一篇文章的知识点。如果忘记了,可以看完本文再回去复习。现在不要点击:如何在Go函数中获取调用者的函数名、文件名和行号……我们再封装一下Logger。//私信go-logger给公众号「网管口bi唯」//可以获取完整代码,使用Demopackagezlog//简单封装zap日志库的使用//使用方法://zlog.Debug("hello",zap.String("name","Kevin"),zap.Any("arbitraryObj",dummyObject))//zlog.Info("hello",zap.String("name"),"Kevin"),zap.Any("arbitraryObj",dummyObject))//zlog.Warn("hello",zap.String("name","Kevin"),zap.Any("arbitraryObj",dummyObject))var记录器*zap。Loggerfuncinit(){......}funcgetFileLogWriter()(writeSyncerzapcore.WriteSyncer){......}funcInfo(messagestring,fields...zap.Field){callerFields:=getCallerInfoForLog()fields=append(fields,callerFields...)logger.Info(message,fields...)}funcDebug(messagestring,fields...zap.Field){callerFields:=getCallerInfoForLog()fields=append(fields),callerFields...)logger.Debug(message,fields...)}funcError(messagestring,fields...zap.Field){callerFields:=getCallerInfoForLog()fields=append(fields,callerFields...)logger.Error(message,fields...)}funcWarn(mes圣人字符串,字段...zap.Field){callerFields:=getCallerInfoForLog()fields=append(fields,callerFields...)logger.Warn(message,fields...)}funcgetCallerInfoForLog()(callerFields[]zap.Field){pc,file,line,ok:=runtime.Caller(2)//返回两层,获取写日志的调用者的函数信息if!ok{return}funcName:=runtime.FuncForPC(pc).Name()funcName=path.Base(funcName)//基函数返回路径的最后一个元素,只保留函数名callerFields=append(callerFields,zap.String("func",funcName),zap.String("file",file),zap.Int("line",line))return}为什么不使用zap.New(core,zap.AddCaller())将调用者信息添加到日志行?主要是如果想更灵活一点,可以自己定义对应的日志字段,所以把Caller的几条信息放在一个单独的字段里,日志平台收集日志后,查询的时候更方便检索日志。下面例程尝试使用我们封装好的日志Logger做一个简单的测试。packagemainimport("example.com/utils/zlog")typeUserstrunct{Namestirng}funcmain(){user:=&User{"Name":"Kevin"}zlog.Info("testlog",zap.Any("user",user))}输出类似于以下输出。{"level":"info","ts":"2022-05-15T21:22:22.687+0800","msg":"testlog","re??s":{"Name":"Kevin"},"func":"main.Main","file":"/Users/Kevin/go/src/example.com/demo/zap.go","line":84}ZapLogger定制打包总结,这里只是一些基本的和必要的入门级定制。掌握之后,可以参考官方文档提供的接口进行更多的自定义。