相比其他语言,Go 标准库里的 log 模块已经很好用了。但还是缺少一些常用的功能,比如按等级输出。于是又出现了许多第三方库,例如最出名的 logrus,不过已进入维护状态。作者认为 logrus 已经完成了它的使命——推动结构化日志的发展。至于之后的扩展优化,将有更优秀的作品。
这里将记录其中之一—— zap。 zap 是 uber 开源的高性能日志框架,不仅一般场景,zap 也完全可以胜任后端与微服务系统。
本文基于 zap 版本:v1.21.0
快速开始
安装
go get -u go.uber.org/zap
使用
func main() {
baseLogger, _ := zap.NewDevelopment()
defer baseLogger.Sync()
logger := baseLogger.Sugar()
logger.Infof("Check order. id: %d, name: %v", 123, "Fruit")
logger.Infow("Check order.", "id", 123, "name", "Fruit")
}
输出如下:
2022-04-23T11:45:16.400+0800 INFO cert-deployer/deployer.go:36 Check order. id: 123, name: Fruit
2022-04-23T11:45:16.400+0800 INFO cert-deployer/deployer.go:37 Check order. {"id": 123, "name": "Fruit"}
不像标准库 log,zap 没有提供默认 logger,在使用之前必须先创建一个。zap.NewDevelopment() 使用预置的适合开发环境的配置创建一个 logger,它的特点是:
使用人类可读的格式(而不是 json)
DebugLevel (最低等级)
类似的,还有 NewProduction(), NewExample()。具体特点可以看注释。
Sync() 顾名思义,用来刷新缓冲区。默认情况下 zap 没有使用缓冲,不过最佳实践还是在程序退出前刷新一下。
Infof() 使用我们熟悉的格式化字符串来输出日志。根据等级的不同,类似的还有 Debugf() 等。Infow() 则是使用键值对的形式附加上下文参数。
高性能场景
同学们可能已经发现了,上面有一层 Sugar() 调用。这是因为为了极致的性能,zap 默认不支持动态格式化字符串。但是日常使用对性能的要求没那么高,这种情况下可以通过 zap.Logger.Sugar() 获取一个 zap.SugaredLogger。后者虽然性能差一点,但着实方便许多。
在高性能要求场景下,最好使用基础的 zap.Logger:
func main() {
logger, _ := zap.NewDevelopment()
defer logger.Sync()
logger.Info("Check order.", zap.Int("id", 123), zap.String("name", "Fruit"))
}
输出如下:
2022-04-23T11:44:26.146+0800 INFO cert-deployer/deployer.go:35 Check order. {"id": 123, "name": "Fruit"}
不同于 SugaredLogger,Logger 只能传递静态类型数据,字符串格式化自然也不行了。有得必有失吧。
简单配置
使用 zap.NewDevelopment() 可以传入数个 Option 参数对默认配置做修改。Option 是一个接口,定义如下:
type Option interface {
apply(*Logger)
}
也就是说 Option 需要是包含一个函数的对象,这个函数接收一个 *Logger 没有返回值,内部可以对 Logger 做修改。zap 内置了很多 Option 实现,它们以函数的形式提供,例如:
func WithCaller(enabled bool) Option {
return optionFunc(func(log *Logger) {
log.addCaller = enabled
})
}
可以用它关闭 Development 配置中代码位置输出:
logger, _ := zap.NewDevelopment(zap.WithCaller(false))
logger.Info("Check order.", zap.Int("id", 123), zap.String("name", "Fruit"))
// 输出:2022-04-23T15:55:10.644+0800 INFO Check order. {"id": 123, "name": "Fruit"}
高级配置
zap 其实是对 zapcore 的包装,要想进行更灵活的设置,就得直接调用 zapcore 包的函数。
组装一个 zap.Logger 需要 zapcore.Core,而它又需要 Encoder, WriteSyncer 和 LevelEnabler。具体关系如下:
Logger
+-- Core
+-- Encoder
| +-- EncoderConfig
+-- WriteSyncer
+-- LevelEnabler
Encoder
Encoder 是最关键的一个,它控制着日志最终的输出格式。Encoder 本身控制日志的编码格式,例如 json 还是 plain-text。内部的 EncoderConfig 则控制每一个字段更具体的格式,例如时间字段的 key 是什么,格式是字符串还是 UNIX 时间戳。
可以想象,自己实现一个 Encoder 非常困难。幸运的是我们几乎没有必要这么做,zap 提供了两个内置 Encoder 可以满足大部分需要,分别可以调用 zapcore.NewConsoleEncoder(encoderConfig) 与 zapcore.NewJSONEncoder(encoderConfig) 来创建。
ConsoleEncoder 输出易于人类阅读的 plain-text 字符串。对于结构化的上下文数据将以 json 的格式附加在最后。预置的 DeveloplmentLogger 使用的就是它。
JSONEncoder 顾名思义,把所有字段都编码成 json 文本,非常适合用于归档或分析。
相比 Encoder,多数时候我们只要调节 EncoderConfig。zap 同样提供了两组默认的配置:zap.NewProductionEncoderConfig() 和 zap.NewProductionEncoderConfig(),它们的具体区别跟进源码可以轻松看出来。
EncoderConfig 的属性大致分为两类:
xxxKey。设置字段的键名,具体作用取决于 Encoder 的实现。例如 JSONEncoder 把 xxxKey 当作输出的 key,而 ConsoleEncoder 干脆忽略这个属性,一个例外是若 xxxKey 为空字符串则忽略这个字段不输出。
xxxEncoder。 这里的 Encoder 不是接口,只是基于函数签名的类型定义。例如 TimeEncoder 定义如下:
type TimeEncoder func(time.Time, PrimitiveArrayEncoder)
假如希望输出的时间格式是 [yyyyMMdd],可以自定义一个这样的 TimeEncoder 并使用:
func CustomTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(fmt.Sprintf("[%04d%02d%02d]", t.Year(), t.Month(), t.Day()))
}
func main() {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = CustomTimeEncoder // 设置自定义 TimeEncoder
encoder := zapcore.NewConsoleEncoder(encoderConfig)
core := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), zapcore.DebugLevel)
zap.New(core).Sugar().Infow("Check order.", "id", 123, "name", "Fruit")\
}
// 输出:[20220423] INFO Check order. {"id": 123, "name": "Fruit"}
还有几个其他的属性,看名字和源码里的注释就能理解。
WriteSyncer
WriteSyncer 控制输出的位置。zapcore.AddSync(io.Writer) 可以轻松把一个 io.Writer 的实例包装为 WriteSyncer。得益于 Go 优秀的接口+组合的设计模式,几乎所有期望的输出位置都能简单地接入 zap。
以最常见的输出到文件为例:
logfile, _ := os.Create("/path/to/a.log")
core := zapcore.NewCore(encoder, zapcore.AddSync(logfile), zapcore.DebugLevel)
logger := zap.New(core)
那既想输出到控制台又想输出到文件呢?
利用 MultiWriteSyncer!
logfile, _ := os.Create("/path/to/a.log")
// file + stdout
multiSyner := zapcore.NewMultiWriteSyncer(zapcore.AddSync(logfile), zapcore.AddSync(os.Stdout))
core := zapcore.NewCore(encoder, multiSyner, zapcore.DebugLevel)
logger := zap.New(core)
LevelEnabler
相对而言这个比较简单了。LevelEnabler 是一个接口,用来确定某个等级的日志是否需要输出。zap 内置的几种类型(zapcore.DebugLevel / zapcore.InfoLevel …)默认实现了这个接口,将打印等级 >= 它的日志。
通常 LevelEnabler 的实现只简单地判断需不需要打印,为此要定义一个结构体,再写函数实现未免太麻烦了。所以 zap 还提供了一个便捷的函数命名类型,定义如下:
type LevelEnablerFunc func(zapcore.Level) bool
这使我们可以通过一个匿名函数快捷地创建接口实现:
infoLevel := zap.LevelEnablerFunc(func(lv zapcore.Level) bool {
return lv >= zapcore.InfoLevel
})
// 效果等同于 zapcore.InfoLevel
套娃
我们已经知道,MultiWriteSyncer 可以把 WriteSyncer 套娃,从而实现多路输出,所有的输出都共享一套配置(格式/等级过滤等)。那如果要不同的等级输出到不同的地方,或不同的地方采用不同的格式怎么办? 一个粗暴的方案是定义两个 logger,然后整合成一个:
type Logger struct {
l1 zap.Logger
l2 zap.Logger
}
func (l *Logger) Infow(msg string, keysAndValues ...interface{}) {
l.l1.Infow(msg, keysAndValues ...)
l.l2.Infow(msg, keysAndValues ...)
}
恩… 思路对了,就是感觉好傻 😕
事实上,zap 已经原生支持了这个功能,叫做 Tee。一个 Tee 可以套娃多个 Core,返回一个新 Core。新的 Core 会把输入复制几份分别分发给下层的 Core 们。
使用如下:
debugCore := zapcore.NewCore(encoder, debugSyner, zapcore.DebugLevel)
errCore := zapcore.NewCore(encoder, errSyner, zapcore.ErrorLevel)
tee := zapcore.NewTee(debugCore, errCore)
logger := zap.New(tee)
参考
Zap 日志库实践
深入浅析golang zap 日志库使用