vlambda博客
学习文章列表

跟我一起学Go系列:日志系统从入门到晋级

日志模块在如今的应用中地位是如日中天,开发者没有日志就相当于双目失明,对程序的运行状态无法判断。Go 也不例外提供了基础的日志调用模块:log 模块。log 模块主要提供了 3 类接口,分别是 “Print 、Panic 、Fatal ”,下面一起看看基础日志包的使用。

本文较长,可拆为两个部分观看:

原生 log 使用

zap 使用



    Go log 包简单使用    


三类接口基本使用:

  1. log.Print:打印日志,和 fmt 包没什么区别,只是加上了上面的日志格式。
  2. log.Fatal :会先将日志内容打印到标准输出,接着调用系统的 os.exit(1) 接口,退出程序并返回状态 1 。但是有一点需要注意,由于是直接调用系统接口退出,defer 函数不会被调用。
  3. log.Panic:该函数把日志内容刷到标准错误后调用 panic 函数。

Go 原生 log 结构的定义如下:

   
     
     
   
type Logger struct { mu sync.Mutex // ensures atomic writes; protects the following fields prefix string // prefix to write at beginning of each line flag int // properties out io.Writer // destination for output buf []byte // for accumulating text to write}
可见在结构体中有 sync.Mutex 类型字段,所以 log 中所有的操作都是支持并发的。

下面看一下这三种 log 打印的用法:

package main
import ( "log")

func main() { log.Print("我就是一条日志") log.Printf("%s,","我是带格式的日志") log.Panic("哈哈,我好痛")}

输出:

2021/03/29 17:31:03 我是带格式的日志,2021/03/29 17:31:03 哈哈,我好痛panic: 哈哈,我好痛
goroutine 1 [running]:log.Panic(0xc0002dff68, 0x1, 0x1) /usr/local/go/src/log/log.go:351 +0xaemain.main() /Users/yangyue/go/src/go-web-demo/main.go:24 +0xfa

使用非常简单,可以看到 log 的默认输出带了时间,非常的方便。Panic方法在输出后调用了Panic方法,所以抛出了异常信息。上面的示例中没有演示Fatal方法,你可以试着把log.Fatal()放在程序的第一行,你会发现下面的代码都不会执行。因为上面说过,它在打印完日志之后会调用os.exit(1)方法,所以系统就退出了。

      定制打印参数      


上面说到 log 打印的时候默认是自带时间的,那如果除了时间以外,我们还想要别的信息呢,当然 log 也是支持的。

SetFlags(flag int)方法提供了设置打印默认信息的能力,下面的字段是 log 中自带的支持的打印类型:

Ldate = 1 << iota // the date in the local time zone: 2009/01/23Ltime // the time in the local time zone: 01:23:23Lmicroseconds // microsecond resolution: 01:23:23.123123. assumes Ltime.Llongfile // full file name and line number: /a/b/c/d.go:23Lshortfile // final file name element and line number: d.go:23. overrides LlongfileLUTC // if Ldate or Ltime is set, use UTC rather than the local time zoneLstdFlags = Ldate | Ltime // initial values for the standard logger

这是 log 包定义的一些抬头信息,有日期、时间、毫秒时间、绝对路径和行号、文件名和行号等,在上面都有注释说明,这里需要注意的是:如果设置了Lmicroseconds,那么Ltime就不生效了;设置了LshortfileLlongfile也不会生效,大家自己可以测试一下。

LUTC比较特殊,如果我们配置了时间标签,那么如果设置了LUTC的话,就会把输出的日期时间转为 0 时区的日期时间显示。

最后一个LstdFlags表示标准的日志抬头信息,也就是默认的,包含日期和具体时间。

使用方法:

 
  
func init(){ log.SetFlags(log.Ldate|log.Lshortfile)}
使用 init 方法,可以在 main 函数执行之前初始化代码。另外,虽然参数是 int 类型,
但是上例中使用位运算符传递了多个常量为什么会被识别到底传了啥进去了呢。这是因为源码
中去做解析的时候,也是根据不同的常量组合的位运算去判断你传了啥的。所以先看源码,
你就可以大胆的传了。
package main
import ("log")

func main() { log.SetFlags(log.Ldate|log.Lshortfile) log.Print("我就是一条日志") log.Printf("%s,","谁说我是日志了,我是错误")
}
输出:2021/03/29 main.go:23: 我就是一条日志2021/03/29 main.go:24: 谁说我是日志了,我是错误,


  如何传自定义参数进日志  


在 Java 开发中我们会有这样的日志需求:为了查日志更方便,我们需要在一个 http 请求或者 rpc 请求进来到结束的作用链中用一个唯一 id 将所有的日志串起来,这样可以在日志中搜索这个唯一 id 就能拿到这次请求的所有日志记录。

所以现在的任务是如何在 Go 的日志中去定义这样的一个 id。Go 提供了这样的一个方法:SetPrefix(prefix string),通过log.SetPrefix可以指定输出日志的前缀。

package main
import ( uuid "github.com/satori/go.uuid" "log")

func main() { uuids, _ := uuid.NewV1() log.SetPrefix(uuids.String() +" ") log.SetFlags(log.Ldate|log.Lshortfile) log.Print("我就是一条日志") log.Printf("%s,","谁说我是日志了,我是错误")
}
输出:e12ae2a4-9071-11eb-9c8f-acde48001122 2021/03/29 main.go:26: 我就是一条日志e12ae2a4-9071-11eb-9c8f-acde48001122 2021/03/29 main.go:27: 谁说我是日志了,我是错误,

    log 输出的底层实现   


从源码中我们可以看到,无论是 Print,Panic,还是 Fatal 他们都是使用std.Output(calldepth int, s string)方法。std 的定义如下:

func New(out io.Writer, prefix string, flag int) *Logger { return &Logger{out: out, prefix: prefix, flag: flag}}var std = New(os.Stderr, "", LstdFlags)

即每一次调用 log 的时候都会去创建一个 Logger 对象。另外 New 中传入的第一个参数是os.Stderros.Stderr对应的是 UNIX 里的标准错误警告信息的输出设备,同时被作为默认的日志输出目的地。初次之外,还有标准输出设备os.Stdout以及标准输入设备os.Stdin

var ( Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr"))

前两种分别用于输入、输出和警告错误信息。所有的输出都会调用的方法:std.Output(calldepth int, s string)

func (l *Logger) Output(calldepth int, s string) error { now := time.Now() var file string var line int //加锁,保证多goroutine下的安全 l.mu.Lock() defer l.mu.Unlock() //如果配置了获取文件和行号的话 if l.flag&(Lshortfile|Llongfile) != 0 { //因为runtime.Caller代价比较大,先不加锁 l.mu.Unlock() var ok bool _, file, line, ok = runtime.Caller(calldepth) if !ok { file = "???" line = 0 } //获取到行号等信息后,再加锁,保证安全 l.mu.Lock() } //把我们的日志信息和设置的日志抬头进行拼接 l.buf = l.buf[:0] l.formatHeader(&l.buf, now, file, line) l.buf = append(l.buf, s...) if len(s) == 0 || s[len(s)-1] != '\n' { l.buf = append(l.buf, '\n') } //输出拼接好的缓冲buf里的日志信息到目的地 _, err := l.out.Write(l.buf) return err}

formatHeader方法主要是格式化日志抬头信息,就是我们上面提到设置的日志打印格式,解析完之后存储在buf这个缓冲中,最后再把我们自己的日志信息拼接到缓冲buf的后面,然后为一次 log 日志输出追加一个换行符,这样每次日志输出都是一行一行的。

上面我们提到过runtime.Caller(calldepth)这个方法,runtime 包非常有意思,它提供了一个运行时环境,可以在运行时去管理内存分配,垃圾回收,时间片切换等等,类似于 Java 中虚拟机做的活。(是不是很疑惑为什么在 Go 中竟然可以去做 Java 中虚拟机能做的事情,其实想想协程的概念,再对比线程的概念,就不会疑惑为啥会给你提供这么个包)。

Caller 方法的解释是:

Caller 方法查询有关函数调用的文件和行号信息,通过调用 Goroutine 的堆栈。参数 skip 是堆栈帧框架升序方式排列的数字值,0 标识 Caller 方法的调用。(出于历史原因,Skip 的含义在调用者和调用者之间有所不同。)

返回值报告程序计数器、文件名和相应文件中行号的查询。如果无法恢复信息,则 Boolean OK 为 fasle。

Caller 方法的定义:

func Caller(skip int) (pc uintptr, file string, line int, ok bool) {}

参数skip表示跳过栈帧数,0表示不跳过,也就是runtime.Caller的调用者。1的话就是再向上一层,表示调用者的调用者。

log 日志包里使用的是2,也就是表示我们在源代码中调用log.Printlog.Fatallog.Panic这些函数的调用者。

main函数调用log.Println为例,main->log.Println->*Logger.Output->runtime.Caller这么一个方法调用栈,所以这时候,skip 的值分别代表:

  1. 0 表示 *Logger.Output 中调用 runtime.Caller 的源代码文件和行号;
  2. 1 表示 log.Println 中调用 *Logger.Output 的源代码文件和行号;
  3. 2 表示 main 中调用 log.Println 的源代码文件和行号;

所以这也是log包里的这个skip的值为什么一直是2的原因。


如何自定义自己的日志框架


通过上面的学习,你其实知道了,日志的实现是通过 New() 函数构造了 Logger 对象来处理的。那我们只用构造不同的 Logger 对象来处理不同类型的日记即可。下面是一个简单的实现:

package main
import ( "io" "log" "os")
var ( Info *log.Logger Warning *log.Logger Error * log.Logger)
func init(){ infoFile,err:=os.OpenFile("/data/service_logs/info.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666) warnFile,err:=os.OpenFile("/data/service_logs/warn.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666) errFile,err:=os.OpenFile("/data/service_logs/errors.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666)
if infoFile!=nil || warnFile != nil || err!=nil{ log.Fatalln("打开日志文件失败:",err) }
Info = log.New(os.Stdout,"Info:",log.Ldate | log.Ltime | log.Lshortfile) Warning = log.New(os.Stdout,"Warning:",log.Ldate | log.Ltime | log.Lshortfile) Error = log.New(io.MultiWriter(os.Stderr,errFile),"Error:",log.Ldate | log.Ltime | log.Lshortfile)
Info = log.New(io.MultiWriter(os.Stderr,infoFile),"Info:",log.Ldate | log.Ltime | log.Lshortfile) Warning = log.New(io.MultiWriter(os.Stderr,warnFile),"Warning:",log.Ldate | log.Ltime | log.Lshortfile) Error = log.New(io.MultiWriter(os.Stderr,errFile),"Error:",log.Ldate | log.Ltime | log.Lshortfile)

}
func main() { Info.Println("我就是一条日志啊") Warning.Printf("我真的是一条日志哟%s\n","别骗我") Error.Println("好了,我要报错了")}

为什么不使用原生的 log 日志包


上面我们从 Go 原生日志包的入门到源码简析基本能了解到原生日志包的功能,看完原生日志包提供的能力之后你能想到它还缺失哪些能力吗?

有一个很基本的点就是:原生日志包提供的日志级别只有普通不抛错的日志和抛错导致系统退出的日志。问题来了,我们需要日志系统抛错吗?

所以原生日志系统提供的这种能力显然对于日志系统的使用者来说是有问题的。我们的需求是日志系统能提供多种错误级别的日志:debug,info,warning,errror,另外还要有隔离级别的概念,比如在某种环境中用户可以不打印该级别以下的日志输出。这种能力目前原生的日志包是不能提供的。

另外还有一些高端的附加功能,比如:日志文件自动分割,这对于线上系统来说是非常有必要的。异步打印日志,同样对于线上系统毫秒级并发的日志使用场景也是必不可少的。

基于这种朴素的需求,大家还是从一次开发永久使用的角度上去开展这个场景的梳理。业界已经有很多成熟的开源日志框架,seelog 是最早的日志组件之一,功能强大但是性能不佳,但是也给后面的框架开发提供了很多借鉴。后面的新框架 logrus 和 zap 都非常优秀,大家看看自己的使用习惯挑选一款即可。本篇着重介绍一下 zap 的使用。


            zap             


zap 框架是 uber 公司开源的,所以包在 uber 公司名下:

go get -u go.uber.org/zap

使用 zap 很简单:

func main() { logger, _ := zap.NewProduction() logger.Info("我是一条测试日志") logger.Info("我是一条测试日志", zap.Any("test", "testV"))}

输出:

{"level":"info","ts":1617118009.791499,"caller":"go-web-demo/main.go:23","msg":"我是一条测试日志"}
{"level":"info","ts":1617118009.7915719,"caller":"go-web-demo/main.go:24","msg":"我是一条测试日志","test":"testV"}

有没有发现这个日志格式跟我们在 Java 中的不一样,这里是 JSON 格式的,而 Java 中的日志是字符串。

结构化日志和扁平化日志


Java 中的日志输出一般的结构就是遵循我们配置的特定格式,比如 %date [%X{traceId}][%thread] [%file:%line][%level %logger{0}] - %msg%n 将对应的变量用实际值填充,最终汇总成一条日志字符串。这种方式输出的好处就是千言万语汇总一句话,你想表达啥就表达啥。但是坏处也很明显,比如我们使用 ELK 进行日志存储,ELK 系统要求业务系统必须按照特定的格式和字段去输出日志,否则它的正则匹配表达式将无法解析。那如果你没有按照它的要求去匹配日志格式你的日志就没有办法被解析。

现在的日志系统存储的数据经常就是以 TB / PB 记,这么大的数据量如果没有对日志按照关键词拆分,查询走全文检索是很慢的。另外非结构化的日志数据每条都在 logstash 进行拆分计算对资源的消耗也是很大。

这也是为什么如今结构化日志需求旺盛的原因,主要是为了适应如今的生产环境,一切都是从高效出发。无论是 Go 的日志框架 logrus 、zap,还是 Java 的日志框架 Logback、Log4j、Log4j2 都做了结构化日志模式。

当然事情都有两面性,提到结构化日志好的一面也要说不好的。不好的事情就是结构化必然会有很多的字段名,也许这些字段名并不是我们想要打印的,它最终会导致日志文件变大。不过关系似乎并不大,可以开启压缩功能。

zap 通过创建 Logger 结构体来打印日志,创建新 Logger 提供了以下方法:

  • New(core zapcore.Core, options ...Option) *Logger New 方法通过传入的 Core 接口对象和 Options 构造 Logger;
  • NewNop() *Logger NewNop 返回一个不会真正打印日志的 Logger;
  • NewProduction(options ...Option) (*Logger, error) NewProduction 返回一个日志级别为 Info,以 json 形式输出日志到 stderr 的 Logger;
  • NewDevelopment(options ...Option) (*Logger, error) NewDevelopment 返回一个日志级别为 Level,以 json 形式输出日志到 stderr 的 Logger;
  • NewExample(options ...Option) *Logger NewExample 返回用于测试的 Logger,缺少了一些字段并且以 debug 级别输出到 stdout;
  • (cfg Config) Build(opts ...Option) (*Logger, error) 通过配置信息结构体的 Build 方法生成 Logger;

以上创建 Logger 的方式有什么不同呢:

log := zap.NewExample()log1, _ := zap.NewDevelopment()log2, _ := zap.NewProduction()log.Info("NewExample: This is an INFO message")log.Error("NewExample err: This is an INFO message")
log1.Info("NewDevelopment: This is an INFO message")log1.Error("NewDevelopment err: This is an INFO message")
log2.Info("NewProduction: This is an INFO message")log2.Error("NewProduction err: This is an INFO message")

输出:
//NewExample{"level":"info","msg":"NewExample: This is an INFO message"}{"level":"error","msg":"NewExample err: This is an INFO message"}
//NewDevelopment2021-04-02T15:02:05.713+0800 INFO go-web-demo/main.go:32 NewDevelopment: This is an INFO message2021-04-02T15:02:05.713+0800 ERROR go-web-demo/main.go:33 NewDevelopment err: This is an INFO messagemain.main /Users/yangyue/go/src/go-web-demo/main.go:33runtime.main /usr/local/go/src/runtime/proc.go:204
//NewProduction{"level":"info","ts":1617346925.713845,"caller":"go-web-demo/main.go:35","msg":"NewProduction: This is an INFO message"}{"level":"error","ts":1617346925.713865,"caller":"go-web-demo/main.go:36","msg":"NewProduction err: This is an INFO message","stacktrace":"main.main\n\t/Users/yangyue/go/src/go-web-demo/main.go:36\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:204"}

从输出日志上可以看到:

Example 和 Production 使用的是 json 格式输出,Development 使用行的形式输出。另外还有一些细节方面的不同:

  • Development 输出的日志会从警告级别向上打印到堆栈中来跟踪,始终打印包/文件/行(方法);
  • Production 模式下 Error,Dpanic 级别的记录,会在堆栈中跟踪文件,Warn 不会;将调用者添加到日志中。

创建完 Logger 对象之后就可以调用已经实现好了的不同日志级别方法去打印日志,默认提供以下日志级别:

(log *Logger) Debug(msg string, fields ...Field)(log *Logger) Info(msg string, fields ...Field)(log *Logger) Warn(msg string, fields ...Field)(log *Logger) Error(msg string, fields ...Field)(log *Logger) DPanic(msg string, fields ...Field)(log *Logger) Panic(msg string, fields ...Field)(log *Logger) Fatal(msg string, fields ...Field)

   zap 日志格式配置  


我们继续说回 zap,提到结构化日志就继续说日志格式的问题,无论哪种日志都会提供自定义日志输出级别和字段格式的功能。上面提到的简单使用的案例中:zap.NewProduction(),new 出来的 log 对象我们就可以直接使用,这里肯定是做了一些默认配置的事情,我们来看一下源代码:

func NewProduction(options ...Option) (*Logger, error) { return NewProductionConfig().Build(options...)}
func NewProductionConfig() Config { return Config{ Level: NewAtomicLevelAt(InfoLevel), Development: false, Sampling: &SamplingConfig{ Initial: 100, Thereafter: 100, }, Encoding: "json", EncoderConfig: NewProductionEncoderConfig(), OutputPaths: []string{"stderr"}, ErrorOutputPaths: []string{"stderr"}, }}

NewProductionConfig() 方法帮我们执行了默认初始化配置的事情,配置对象是 Config,代码我就不贴出来了,Config 里面包含很多有用的配置信息,这里我们简单解释一下上面默认配置的 Config 信息:

Level: 配置日志的最低输出级别,这里的 AtomicLevel 虽然是个结构体,但是如果使用配置文件直接反序列化,可以支持配置成字符串 “DEBUG”,“INFO”等;
Development:这个字段的含义是用来标记是否为开发者模式,在开发者模式下,日志输出的一些行为会和生产环境上不同;
DisableCaller:用来标记是否开启行号和文件名显示功能。
DisableStacktrace:标记是否开启调用栈追踪能力,即在打印异常日志时,是否打印调用栈。
Sampling:Sampling 实现了日志的流控功能,或者叫采样配置,主要有两个配置参数,Initial 和 Thereafter,实现的效果是在 1s 的时间单位内,如果某个日志级别下同样内容的日志输出数量超过了 Initial 的数量,那么超过之后,每隔 Thereafter 的数量,才会再输出一次。是一个对日志输出的保护功能。
Encoding:用来指定日志的编码器,也就是用户在调用日志打印接口时,zap 内部使用什么样的编码器将日志信息编码为日志条目,日志的编码也是日志组件的一个重点。默认支持两种配置,json 和 console,用户可以自行实现自己需要的编码器并注册进日志组件,实现自定义编码的能力。
EncoderConfig: 是对于日志编码器的配置,支持的配置参数也很丰富。
OutputPaths:用来指定日志的输出路径,不过这个路径不仅仅支持文件路径和标准输出,还支持其他的自定义协议,当然如果要使用自定义协议,也需要使用RegisterSink方法先注册一个该协议对应的工厂方法,该工厂方法实现了Sink接口。
ErrorOutputPaths:与OutputPaths类似,不过用来指定的是错误日志的输出,不过要注意,这个错误日志不是业务的错误日志,而是zap中出现的内部错误,将会被定向到这个路径下。

以上这些配置都是支持我们覆盖配置,想手动配置就不能使用上面提供的三种默认日志配置,可以使用 zap.New()

func main() { //日志文件存放目录 writeSyncer, _ := os.Create("./info.log") //编码器配置 encoderConfig := zap.NewProductionEncoderConfig() //时间格式 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder //日志等级字母大写 encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
//获取编码器,NewJSONEncoder()输出json格式,NewConsoleEncoder()输出普通文本格式 encoder := zapcore.NewConsoleEncoder(encoderConfig) //第三个及之后的参数为写入文件的日志级别,ErrorLevel模式只记录error级别的日志 core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) //AddCaller()为显示文件名和行号 log := zap.New(core,zap.AddCaller())
log.Info("hello world") log.Error("hello world")}

输出如下:

2021-04-02T15:14:55.695+0800 INFO go-web-demo/main.go:49 hello world2021-04-02T15:14:55.696+0800 ERROR go-web-demo/main.go:50 hello world

同时输出到文件和控制台


上面的日志只会输出到日志文件,不会输出到控制台,如果想同时输出到控制台和文件就改造一下 zapcore.NewCore

package main
import ( _ "github.com/go-sql-driver/mysql" "github.com/natefinch/lumberjack" _ "github.com/spf13/viper/remote" "go.uber.org/zap" "go.uber.org/zap/zapcore" "os")
func main() {
//编码器配置 encoderConfig := zap.NewProductionEncoderConfig() //时间格式 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder //日志等级字母大写 encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
//获取编码器,NewJSONEncoder()输出json格式,NewConsoleEncoder()输出普通文本格式 encoder := zapcore.NewConsoleEncoder(encoderConfig)
//文件writeSyncer fileWriteSyncer := zapcore.AddSync(&lumberjack.Logger{ Filename: "./info.log", //日志文件存放目录 MaxSize: 1, //文件大小限制,单位MB MaxBackups: 5, //最大保留日志文件数量 MaxAge: 30, //日志文件保留天数 Compress: false, //是否压缩处理 }) //第三个及之后的参数为写入文件的日志级别,ErrorLevel模式只记录error级别的日志 fileCore := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(fileWriteSyncer, zapcore.AddSync(os.Stdout)), zapcore.DebugLevel)
//AddCaller()为显示文件名和行号 log := zap.New(fileCore, zap.AddCaller())
log.Info("hello world") log.Error("hello world")}

注意到上面使用了 zapcore.NewMultiWriteSyncer() 方法来接收日志同时 写入文件和控制台。

另外上面的示例中写入文件的方式也有变化,这里引入了一个新的日志自动压缩和切割的中间件:github.com/natefinch/lumberjack,zap 包本身不提供文件切割的功能,但是可以用 zap 官方推荐的lumberjack包处理。


不同级别的日志物理隔离


上面的示例中不同级别的日志还是混合在一个文件中,想要物理隔离应该怎么配置呢?

package main
import ( _ "github.com/go-sql-driver/mysql" "github.com/natefinch/lumberjack" _ "github.com/spf13/viper/remote" "go.uber.org/zap" "go.uber.org/zap/zapcore" "os")

func main() { var coreArr []zapcore.Core
//编码器配置 encoderConfig := zap.NewProductionEncoderConfig() //时间格式 encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder //日志等级字母大写 encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
//获取编码器,NewJSONEncoder()输出json格式,NewConsoleEncoder()输出普通文本格式 encoder := zapcore.NewConsoleEncoder(encoderConfig)
//日志级别 error 级别 highPriority := zap.LevelEnablerFunc(func(lev zapcore.Level) bool { return lev >= zap.ErrorLevel }) //info 和 debug 级别,debug 级别是最低的 lowPriority := zap.LevelEnablerFunc(func(lev zapcore.Level) bool { return lev < zap.ErrorLevel && lev >= zap.DebugLevel }) //info文件 writeSyncer infoFileWriteSyncer := zapcore.AddSync(&lumberjack.Logger{ Filename: "./log/info.log", //日志文件存放目录,如果文件夹不存在会自动创建 MaxSize: 1, //文件大小限制,单位MB MaxBackups: 5, //最大保留日志文件数量 MaxAge: 30, //日志文件保留天数 Compress: false, //是否压缩处理 })
//第三个及之后的参数为写入文件的日志级别,ErrorLevel模式只记录error级别的日志 infoFileCore := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(infoFileWriteSyncer, zapcore.AddSync(os.Stdout)), lowPriority)
//error文件writeSyncer errorFileWriteSyncer := zapcore.AddSync(&lumberjack.Logger{ Filename: "./log/error.log", //日志文件存放目录 MaxSize: 1, //文件大小限制,单位MB MaxBackups: 5, //最大保留日志文件数量 MaxAge: 30, //日志文件保留天数 Compress: false, //是否压缩处理 })
//第三个及之后的参数为写入文件的日志级别,ErrorLevel模式只记录error级别的日志 errorFileCore := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(errorFileWriteSyncer, zapcore.AddSync(os.Stdout)), highPriority)

coreArr = append(coreArr, infoFileCore) coreArr = append(coreArr, errorFileCore) //zap.AddCaller()为显示文件名和行号,可省略 log := zap.New(zapcore.NewTee(coreArr...), zap.AddCaller())
log.Info("hello world") log.Error("hello world")}

以上示例中我们为不同级别的日志配置了不同的 writeSyncer,最终使用 zapcore.NewTee()进行汇总。大家可以运行一下看看效果。


如何在控制台高亮日志


zap 支持将日志级别字段进行不同颜色展示,想修改颜色的话修改如下配置即可:

encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder

        zap 语法糖     


上面示例中我们定义的 log 对象:log *zap.Logger 是 zap.Logger 类型, zap 提供了两种类型的 Logger 对象:

  • *zap.Logger:正常的 json 格式的日志输出;
  • *zap.SugaredLogger:zap 提供的语法糖模式,可以像 Printf 一样支持变量替换

Sugar 的使用也很简单:

sugar := zap.NewExample().Sugar()defer sugar.Sync()sugar.Infow("sugar fail test", "name", "xiaoming", "age", 39, "sex", 1)sugar.Infof("test sugar info: %s", "sugar")
输出:{"level":"info","msg":"sugar fail test","name":"xiaoming","age":39,"sex":1}{"level":"info","msg":"test sugar info: sugar"}

可以看到 Sugar 语法糖帮我们将模糊语义的 K-V 自动转换。

zap 的基本使用我们就聊这么多,还有很多特性大家有兴趣的可以去看看 zap 的官方文档[1]

参考资料

[1]

官方文档: https://pkg.go.dev/go.uber.org/zap