zap 是由 Uber 公司开源的一款 Go 日志库,就像它的命名一样,zap 以快著称。官方 GitHub 仓库中只用一句话来概括 zap:「在 Go 中进行快速、结构化、分级的日志记录」。这句话简单明了的概括了 zap 的核心特性,今天我们就来介绍下 zap 日志库的基本使用和高级特性,以及如何在实际应用程序中使用,来提高应用程序的可靠性。
特点
zap 具有如下特点:
- 快,非常快,这也是 zap 最显著的特点。速度快的原因是 zap 避免使用
interface{}
和反射,并且使用sync.Pool
减少堆内存分配。在 zap 面前 Logrus 的执行速度只有被吊打的份,你可以在官方 GitHub 仓库中看到 zap 与不同日志库的速度对比。 - 支持结构化日志记录。这是一个优秀的日志库必备功能。
- 支持七种日志级别:
Debug
、Info
、Warn
、Error
、DPanic
、Panic
、Fatal
,其中DPanic
是指在开发环境下(development
)记录日志后会进行panic
。 - 支持输出调用堆栈。
- 支持 Hooks 机制。
使用
基本使用
先安装
go get -u go.uber.org/zap
基本使用如下:
zap
库的使用与其他的日志库非常相似。先创建一个logger
,然后调用各个级别的方法记录日志(Debug/Info/Warn/Error/
)。zap
提供了几个快速创建logger
的方法,zap.NewExample()
、zap.NewDevelopment()
、zap.NewProduction()
,还有高度定制化的创建方法zap.New()
。创建前 3 个logger
时,zap
会使用一些预定义的设置,它们的使用场景也有所不同。Example
适合用在测试代码中,Development
在开发环境中使用,Production
用在生成环境。zap
底层 API 可以设置缓存,所以一般使用defer logger.Sync()
将缓存同步到文件中。刷新缓存,确保日志输出。由于
fmt.Printf
之类的方法大量使用interface{}
和反射,会有不少性能损失,并且增加了内存分配的频次。zap
为了提高性能、减少内存分配次数,没有使用反射,而且默认的Logger
只支持强类型的、结构化的日志。必须使用zap
提供的方法记录字段。zap
为 Go 语言中所有的基本类型和其他常见类型都提供了方法。这些方法的名称也比较好记忆,zap.Type
(Type
为bool/int/uint/float64/complex64/time.Time/time.Duration/error
等)就表示该类型的字段,zap.Typep
以p
结尾表示该类型指针的字段,zap.Types
以s
结尾表示该类型切片的字段。如:zap.Bool(key string, val bool) Field
:bool
字段zap.Boolp(key string, val *bool) Field
:bool
指针字段;zap.Bools(key string, val []bool) Field
:bool
切片字段。
当然也有一些特殊类型的字段:
zap.Any(key string, value interface{}) Field
:任意类型的字段;zap.Binary(key string, val []byte) Field
:二进制串的字段。
当然,每个字段都用方法包一层用起来比较繁琐。
zap
也提供了便捷的方法SugarLogger
,可以使用printf
格式符的方式。调用logger.Sugar()
即可创建SugaredLogger
。SugaredLogger
的使用比Logger
简单,只是性能比Logger
低 50% 左右,可以用在非热点函数中。调用SugarLogger
以f
结尾的方法与fmt.Printf
没什么区别,如例子中的Infof
。同时SugarLogger
还支持以w
结尾的方法,这种方式不需要先创建字段对象,直接将字段名和值依次放在参数中即可,如例子中的Infow
。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
package main import ( "fmt" "time" "go.uber.org/zap" ) func main() { // 生产环境 fmt.Println("------生产环境------") { logger, _ := zap.NewProduction() defer logger.Sync() // 刷新 buffer,保证日志最终会被输出 url := "https://arlettebrook.github.io/" logger.Info("production failed to fetch URL", zap.String("url", url), // 因为没有使用 interface{} 和反射机制,所以需要指定具体类型 zap.Int("attempt", 3), zap.Duration("backoff", time.Second), ) } // 开发环境 fmt.Println("------开发环境------") { logger, _ := zap.NewDevelopment() defer logger.Sync() url := "https://arlettebrook.github.io/" logger.Debug("development failed to fetch URL", zap.String("url", url), zap.Int("attempt", 3), zap.Duration("backoff", time.Second), ) } // 测试环境 fmt.Println("------测试环境------") { logger := zap.NewExample() defer logger.Sync() url := "https://arlettebrook.github.io/" logger.Info("failed to fetch URL", zap.String("url", url), zap.Int("attempt", 3), zap.Duration("backoff", time.Second), ) fmt.Println("------sugaredLogger------") sugar := logger.Sugar() sugar.Infow("failed to fetch URL", "url", url, "attempt", 3, "backoff", time.Second, ) sugar.Infof("Failed to fetch URL: %s", url) } }
zap 针对生产环境、开发环境以及测试环境提供了不同的函数来创建
Logger
对象。如果想在日志后面追加 key-value,则需要根据 value 的数据类型使用
zap.String
、zap.Int
等方法实现。这一点在使用上显然不如 Logrus 等其他日志库来的方便,但这也是 zap 速度快的原因之一,zap 内部尽量避免使用interface{}
和反射来提高代码执行效率。记录日志的
logger.Xxx
方法签名如下:1
func (log *Logger) Info(msg string, fields ...Field)
其中
fields
是zapcore.Field
类型,用来存储 key-value,并记录 value 类型,不管是zap.String
还是zap.Int
底层都是zapcore.Field
类型来记录的。zap 为每一种 Go 的内置类型都定义了对应的zap.Xxx
方法,甚至还实现zap.Any()
来支持interface{}
。执行以上代码,控制台得到如下输出:
1 2 3 4 5 6 7 8 9 10 11 12 13
------生产环境------ {"level":"info","ts":1714375211.8196504,"caller":"learn/main.go:18","msg":"production failed to fe tch URL","url":"https://arlettebrook.github.io/","attempt":3,"backoff":1} ------开发环境------ 2024-04-29T15:20:11.820+0800 DEBUG learn/main.go:32 development failed to fetch URL {" url": "https://arlettebrook.github.io/", "attempt": 3, "backoff": "1s"} ------测试环境------ {"level":"info","msg":"failed to fetch URL","url":"https://arlettebrook.github.io/","attempt":3,"b ackoff":"1s"} ------sugaredLogger------ {"level":"info","msg":"failed to fetch URL","url":"https://arlettebrook.github.io/","attempt":3,"b ackoff":"1s"} {"level":"info","msg":"Failed to fetch URL: https://arlettebrook.github.io/"}
可以发现,通过
zap.NewProduction()
创建的日志对象输出格式为 JSON,而通过zap.NewDevelopment()
创建的日志对象输出格式为 Text,日志后面追加的 key-value 会被转换成 JSON。并且,两者输出的字段内容也略有差异,如生产环境日志输出的时间格式为Unix epoch
利于程序解析,而开发环境日志输出的时间格式为ISO8601
更利于人类阅读。测试环境没有文件行号、堆栈跟踪信息以及时间,格式为JSON。对应的sugar都是是一样的。导致以上这些差异的原因是配置不同,我们来看下
zap.NewProduction
和zap.NewDevelopment
的代码实现:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
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"}, } } func NewDevelopment(options ...Option) (*Logger, error) { return NewDevelopmentConfig().Build(options...) } func NewDevelopmentConfig() Config { return Config{ Level: NewAtomicLevelAt(DebugLevel), Development: true, Encoding: "console", EncoderConfig: NewDevelopmentEncoderConfig(), OutputPaths: []string{"stderr"}, ErrorOutputPaths: []string{"stderr"}, } }
可以看到,两者在实现思路上是一样的,都是先创建一个配置对象
zap.Config
,然后再调用配置对象的Build
方法来构建Logger
。zap.Config
定义如下:1 2 3 4 5 6 7 8 9 10 11 12
type Config struct { Level AtomicLevel `json:"level" yaml:"level"` Development bool `json:"development" yaml:"development"` DisableCaller bool `json:"disableCaller" yaml:"disableCaller"` DisableStacktrace bool `json:"disableStacktrace" yaml:"disableStacktrace"` Sampling *SamplingConfig `json:"sampling" yaml:"sampling"` Encoding string `json:"encoding" yaml:"encoding"` EncoderConfig zapcore.EncoderConfig `json:"encoderConfig" yaml:"encoderConfig"` OutputPaths []string `json:"outputPaths" yaml:"outputPaths"` ErrorOutputPaths []string `json:"errorOutputPaths" yaml:"errorOutputPaths"` InitialFields map[string]interface{} `json:"initialFields" yaml:"initialFields"` }
每个配置项说明如下:
Level
: 日志级别。Development
: 是否为开发模式。DisableCaller
: 禁用调用信息,值为true
时,日志中将不再显示记录日志时所在的函数调用文件名和行号。DisableStacktrace
: 禁用堆栈跟踪捕获。Sampling
: 采样策略配置,单位为每秒,作用是限制日志在每秒内的输出数量,以此来防止全局的 CPU 和 I/O 负载过高。Encoding
: 指定日志编码器,目前支持json
和console
。EncoderConfig
: 编码配置,决定了日志字段格式。OutputPaths
: 配置日志输出位置,URLs 或文件路径,可配置多个。ErrorOutputPaths
: zap 包内部出现错误的日志输出位置,URLs 或文件路径,可配置多个,默认os.Stderr
。InitialFields
: 初始化字段配置,该配置的字段会以结构化的形式打印在每条日志输出中。
我们再来对比下
NewProductionEncoderConfig()
和NewDevelopmentEncoderConfig()
这两个配置的不同:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
func NewProductionEncoderConfig() zapcore.EncoderConfig { return zapcore.EncoderConfig{ TimeKey: "ts", LevelKey: "level", NameKey: "logger", CallerKey: "caller", FunctionKey: zapcore.OmitKey, MessageKey: "msg", StacktraceKey: "stacktrace", LineEnding: zapcore.DefaultLineEnding, EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.EpochTimeEncoder, EncodeDuration: zapcore.SecondsDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, } } func NewDevelopmentEncoderConfig() zapcore.EncoderConfig { return zapcore.EncoderConfig{ // Keys can be anything except the empty string. TimeKey: "T", LevelKey: "L", NameKey: "N", CallerKey: "C", FunctionKey: zapcore.OmitKey, MessageKey: "M", StacktraceKey: "S", LineEnding: zapcore.DefaultLineEnding, EncodeLevel: zapcore.CapitalLevelEncoder, EncodeTime: zapcore.ISO8601TimeEncoder, EncodeDuration: zapcore.StringDurationEncoder, EncodeCaller: zapcore.ShortCallerEncoder, } }
对比来看,两者有很多不同的配置,比如生产环境下
EncodeTime
值为zapcore.EpochTimeEncoder
,开发环境下EncodeTime
值为zapcore.ISO8601TimeEncoder
。这就是生产环境日志输出的时间格式为Unix epoch
而开发环境日志输出的时间格式为ISO8601
的原因。zapcore.EncoderConfig
其他几个常用的配置项说明如下:MessageKey
: 日志信息的键名,默认msg
。LevelKey
: 日志级别的键名,默认level
。TimeKey
: 日志时间的键名。EncodeLevel
: 日志级别的格式,默认为小写,如info
。
除了提供
zap.NewProduction()
和zap.NewDevelopment()
两个构造函数外,zap 还提供了zap.NewExample()
来创建一个Logger
对象,这个方法主要用于测试,这里就不多介绍了。
记录层级关系
前面我们记录的日志都是一层结构,没有嵌套的层级。我们可以使用zap.Namespace(key string) Field
构建一个命名空间,后续的Field
都记录在此命名空间中:
|
|
输出:
|
|
上面我们演示了两种Namespace
的用法,一种是直接作为字段传入Debug/Info
等方法,一种是调用With()
创建一个新的Logger
,新的Logger
记录日志时总是带上预设的字段。With()
方法实际上是创建了一个新的Logger
:
|
|
预设日志字段
如果每条日志都要记录一些共用的字段,那么使用zap.Fields(fs ...Field)
创建的选项。例如在服务器日志中记录可能都需要记录serverId
和serverName
:
|
|
输出:
|
|
与logger.with()
差不多
给语法加点糖
zap 虽然速度足够快,但是多数情况下,我们并不需要极致的性能,而是想让代码写起来更爽一些。zap 为我们提供了解决方案 —— SugaredLogger
。
|
|
通过 logger.Sugar()
方法可以将一个 Logger
对象转换成一个 SugaredLogger
对象。
SugaredLogger
提供了更人性化的接口,日志中追加 key-value 时不在需要 zap.String("url", url)
这种显式指明类型的写法,只需要保证 key 为 string
类型,value 则可以为任意类型,能够减少我们编写的代码量。
此外,为了满足不同需求,SugaredLogger
提供了四种方式输出日志:sugar.Xxx
、sugar.Xxxw
、sugar.Xxxf
、sugar.Xxxln
。
执行以上代码,控制台得到如下输出:
|
|
我们知道,这种方便的写法是有一定代价的,所以开发中是否需要使用 SugaredLogger
来记录日志,需要根据程序的特点来决定。SugaredLogger
与 Logger
的性能对比同样可以在官方 GitHub 仓库中看到。
定制 Logger
通过查看 zap.NewProduction()
和 zap.NewDevelopment()
两个构造函数源码,我们知道可以使用 zap.Config
对象的 Build
方法创建 Logger
对象。那么我们很容易能够想到,如果要定制 Logger
,只需要创建一个定制的 zap.Config
即可。
|
|
以上代码通过 newCustomLogger
函数创建了一个自定义的 Logger
,同样通过先定义一个 zap.Config
然后再调用其 Build
方法来实现。
配置日志分别输出到标准输出和 test.log
文件,执行以上代码,控制台和 test.log
都会得到如下输出:
|
|
另外,我们还通过 logger.WithOptions()
为 Logger
对象增加了一个选项 zap.AddCallerSkip(100)
,这个选项的作用是指定在通过调用栈获得行号时跳过的调用深度,因为我们的函数调用栈并不是 100 层,所以会触发 zap 内部错误,zap 会将错误日志输出到 ErrorOutputPaths
配置指定的位置中,即 error.log
。
error.log
得到的错误日志如下:
|
|
选项
logger.WithOptions()
支持的选项如下:
WrapCore(f func(zapcore.Core) zapcore.Core)
: 使用一个新的zapcore.Core
替换掉Logger
内部原有的的zapcore.Core
属性。Hooks(hooks ...func(zapcore.Entry) error)
: 注册钩子函数,用来在日志打印时同时调用注册的钩子函数。Fields(fs ...Field)
: 添加公共字段。ErrorOutput(w zapcore.WriteSyncer)
: 指定日志组件内部出现异常时的输出位置。Development()
: 将日志记录器设为开发模式,这将使DPanic
级别日志记录错误后执行panic()
。AddCaller()
: 与WithCaller(true)
等价。WithCaller(enabled bool)
: 指定是否在日志输出内容中增加调用信息,即文件名和行号。AddCallerSkip(skip int)
: 指定在通过调用栈获取文件名和行号时跳过的调用深度。AddStacktrace(lvl zapcore.LevelEnabler)
: 用来指定某个日志级别及以上级别输出调用堆栈。IncreaseLevel(lvl zapcore.LevelEnabler)
: 提高日志级别,如果传入的lvl
比现有级别低,则不会改变日志级别。WithFatalHook(hook zapcore.CheckWriteHook)
: 当出现Fatal
级别日志时调用的钩子函数。WithClock(clock zapcore.Clock)
: 指定日志记录器用来确定当前时间的zapcore.Clock
对象,默认为time.Now
的系统时钟。
NewExample()/NewDevelopment()/NewProduction()
这 3 个函数可以传入若干类型为zap.Option
的选项,从而定制Logger
的行为。又一次见到了选项模式!!
zap
提供了丰富的选项供我们选择。
输出文件名和行号
调用
zap.AddCaller()
返回的选项设置输出文件名和行号。但是有一个前提,必须设置配置对象Config
中的CallerKey
字段。也因此NewExample()
不能输出这个信息(它的Config
没有设置CallerKey
)。AddCaller()
与zap.WithCaller(true)
等价。一般不用有时我们稍微封装了一下记录日志的方法,但是我们希望输出的文件名和行号是调用封装函数的位置。这时可以使用
zap.AddCallerSkip(skip int)
向上跳 1 层。可能会用到。输出调用堆栈
有时候在某个函数处理中遇到了异常情况,因为这个函数可能在很多地方被调用。如果我们能输出此次调用的堆栈,那么分析起来就会很方便。我们可以使用
zap.AddStackTrace(lvl zapcore.LevelEnabler)
达成这个目的。该函数指定lvl
和之上的级别都需要输出调用堆栈:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
package main import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" ) func f1() { f2("hello world") } func f2(msg string, fields ...zap.Field) { zap.L().Warn(msg, fields...) // zap.L()获取全局logger } func main() { logger, _ := zap.NewDevelopment(zap.AddStacktrace(zapcore.WarnLevel)) defer logger.Sync() zap.ReplaceGlobals(logger) // 替换全局logger f1() }
将
zapcore.WarnLevel
传入AddStacktrace()
,之后Warn()/Error()
等级别的日志会输出堆栈,Debug()/Info()
这些级别不会。运行结果:1 2 3 4 5 6 7 8 9
2024-04-30T10:32:49.798+0800 WARN learn/main.go:13 hello world main.f2 F:/GoProject/learn/main.go:13 main.f1 F:/GoProject/learn/main.go:9 main.main F:/GoProject/learn/main.go:22 runtime.main D:/Go/src/runtime/proc.go:271
很清楚地看到调用路径。
创建自定义的配置对象,除了在代码中指定配置参数,也可以将这些配置项写入到 JSON 文件中,然后通过 json.Unmarshal
的方式将配置绑定到 zap.Config
,可以参考官方示例。
|
|
上面创建一个输出到标准输出stdout
和文件server.log
的Logger
。观察输出:
|
|
全局Logger
为了方便使用,zap
提供了两个全局的Logger
,一个是*zap.Logger
,可调用zap.L()
获得;另一个是*zap.SugaredLogger
,可调用zap.S()
获得。需要注意的是,全局的Logger
默认并不会记录日志!它是一个无实际效果的Logger
。看源码:
|
|
我们可以使用ReplaceGlobals(logger *Logger) func()
将logger
设置为全局的Logger
,该函数返回一个无参函数,用于恢复全局Logger
设置:
|
|
输出:
|
|
可以看到在调用ReplaceGlobals
之前记录的日志并没有输出。
与标准日志库搭配使用
如果项目一开始使用的是标准日志库log
,后面想转为zap
。这时不必修改每一个文件。我们可以调用zap.NewStdLog(l *Logger) *log.Logger
返回一个标准的log.Logger
,内部实际上写入的还是我们之前创建的zap.Logger
:
|
|
输出:
|
|
很方便不是吗?我们还可以使用NewStdLogAt(l *logger, level zapcore.Level) (*log.Logger, error)
让标准接口以level
级别写入内部的*zap.Logger
。
如果我们只是想在一段代码内使用标准日志库log
,其它地方还是使用zap.Logger
。可以调用RedirectStdLog(l *Logger) func()
。它会返回一个无参函数恢复设置:
|
|
看前后输出变化:
|
|
当然RedirectStdLog
也有一个对应的RedirectStdLogAt
以特定的级别调用内部的*zap.Logger
方法。