这里将记录其中之一—— 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"}

不同于 SugaredLoggerLogger 只能传递静态类型数据,字符串格式化自然也不行了。有得必有失吧。

简单配置

使用 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, WriteSyncerLevelEnabler。具体关系如下:

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 的属性大致分为两类:

  1. xxxKey。设置字段的键名,具体作用取决于 Encoder 的实现。例如 JSONEncoder 把 xxxKey 当作输出的 key,而 ConsoleEncoder 干脆忽略这个属性,一个例外是若 xxxKey 为空字符串则忽略这个字段不输出。
  2. 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)

参考

Last modification:April 23, 2022