Zerolog 实现要点及源码解析

489 阅读8分钟

前言

Zerolog 是一个高性能、零分配(allocation-free)的 Go 语言日志框架,旨在提供极致的日志性能,同时保持简单易用。它特别适用于需要大量日志输出且对性能要求较高的场景。

那么从日志框架本身出发,一个日志框架有哪些实现要点以及如何一个实现高性能的日志框架,本篇文章我们来看下 Zerolog 是如何实现以上要点的。

Zerolog 的实现要点

一个日志框架的实现要点包括:

  • 支持日志输出级别。
  • 日志格式化。
  • 多种日志输出方式,日志切割,日志清理。
  • 日志并发安全。
  • 可扩展性,日志钩子,日志追踪。
  • 错误处理,日志输出不影响主程序。

如何使用 Zerolog

1.Zerolog 提供了多种日志输出等级的方法,比如以下是分别输出四种不同等级的日志:

log.Debug().Msg("hello debug log")
log.Info().Msg("hello info log")
log.Warn().Msg("hello warn log")
log.Error().Msg("hello error log")

2.Zerolog 默认的输出格式为 json 格式,使用内置的 ConsoleWriter 可以修改日志的输出格式:

log.Info().Msg("hello log")
consoleWriter := zerolog.ConsoleWriter{
    Out:        os.Stdout,     // 输出方式
    TimeFormat: time.DateTime, // 时间格式
}
logger := zerolog.New(consoleWriter).With().Timestamp().Logger()
logger.Info().Str("foo", "bar").Msg("hello world")

// {"level":"info","foo":"bar","time":1734343473,"message":"hello log"}
// 2024-12-16 18:02:08 INF hello world foo=bar

3.Zerolog 默认输出到标准错误输出流,zerolog.New 函数接收一个 io.Writer 允许你改变日志的输出方式,比如输出到文件中:

output, err := os.Create("test.log")
if err != nil {
    panic(err)
}
logger := zerolog.New(output).With().Timestamp().Logger()
logger.Info().Str("foo", "bar").Msg("hello world")

Zerolog 没有实现内置的日志文件分割,日志清理功能,推荐可以使用 github.com/natefinch/lumberjack 包实现该功能。

4.Zerolog 采用链式 api 完成操作,每一次调用 api 后都会返回一个新的实例,所以不存在并发问题。

5.Zerolog 提供了 Hook 接口供开发者在日志输出中做扩展,也可用于添加日志追踪字段:

type TracingHook struct{}

func (h TracingHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
    ctx := e.GetCtx()
    spanId := h.getSpanIdFromContext(ctx)
    e.Str("span-id", spanId)
}

func (h TracingHook) getSpanIdFromContext(ctx context.Context) string {
    return "spanId"
}

func hook() {
    logger := zerolog.New(os.Stdout)
    logger = logger.Hook(TracingHook{})
    ctx := context.Background()
    logger.Info().Ctx(ctx).Msg("hook")
}

// {"level":"info","span-id":"spanId","message":"hook"}

6.当 Zerolog 的日志输出出错时,Zerolog 会默认在控制台中输出错误信息,不会影响到主流程的执行,你也可以定义全局的 zerolog.ErrorHandler 函数来自定义日志输出出错时的操作。

以上简单展示了 Zerolog 如何使用 api 来完成日志框架的一些基本功能,更多功能请参考官方文档:Zerolog

Zerolog 如何实现高性能

日志框架的性能十分重要,因为日志输出是一个高频操作,特别是在高并发的场景下,日志输出的性能可能对服务整体的性能有较大影响,Zerolog 是一个以高性能著称的日志框架,我们从源码的角度来分析它是如何实现高性能的。

首先我们需要知道有什么操作会影响性能,包括以下几点:

  • 格式化:每次日志输出都会频繁格式化,消耗 CPU。
  • 并发写入:多线程并发写入发生锁竞争。
  • 内存分配:日志输出时频繁创建临时对象,增加 GC,消耗 CPU。

要知道 Zerolog 如何处理以上几点,我们来分析一行日志从创建到输出的源码。

先来看几个日志输出的例子:

// 使用全局 Logger 输出
log.Debug().Msg("hello world")
log.Info().Msg("hello world")
log.Debug().Str("Scale", "833 cents").Float64("Interval", 833.09).Msg("Fibonacci is everywhere")
log.Debug().Str("Name", "Tom").Send()

// 创建一个新 logger 输出
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
logger.Info().Str("foo", "bar").Msg("hello world")

一行日志的输出最后需要调用 Msg(),Msgf() 或者 Send() 才会生效,三个方法内部都是调用 Event.msg() 完成日志写入。

先来看 log.go 文件,文件中定义了一个全局 Logger,如果你没有创建新 logger,那么使用全局 Logger 输出日志,当调用 log.Debug() 或者 log.Info() 时,就是调用 Logger 的方法:

// 默认 Logger
var Logger = zerolog.New(os.Stderr).With().Timestamp().Logger() // 默认输出到标准错误输出 os.Stderr,默认输出到控制台

func Debug() *zerolog.Event {
    return Logger.Debug()
}

func Info() *zerolog.Event {
    return Logger.Info()
}

或者你可以指定一个 io.Writer 创建一个新 logger:

func New(w io.Writer) Logger {
    if w == nil {
       w = io.Discard
    }
    lw, ok := w.(LevelWriter) // writer 需要实现 LevelWriter
    if !ok {
       lw = LevelWriterAdapter{w} // 如果不是,则默认包装为 LevelWriterAdapter,LevelWriterAdapter 内嵌了 io.Writer
    }
    return Logger{w: lw, level: TraceLevel} // 初始化 Logger 的 writer 与 日志等级
}

// LevelWriter
type LevelWriter interface {
    io.Writer
    WriteLevel(level Level, p []byte) (n int, err error)
}

// LevelWriterAdapter 内嵌了 io.Writer,并实现了 LeverWriter 的 WriteLevel 方法
type LevelWriterAdapter struct {
    io.Writer
}

func (lw LevelWriterAdapter) WriteLevel(l Level, p []byte) (n int, err error) {
    return lw.Write(p) // os.File 的 Writte() 方法,默认输出到标准错误输出
}

然后再来看 Logger 的定义以及 Logger 中的方法:

type Logger struct {
    w       LevelWriter // 日志 writer,必须不为空
    level   Level // logger 的日志等级
    sampler Sampler
    context []byte // context 字段内容
    hooks   []Hook
    stack   bool
    ctx     context.Context
}

func (l *Logger) Debug() *Event {
    return l.newEvent(DebugLevel, nil)
}

func (l *Logger) Info() *Event {
    return l.newEvent(InfoLevel, nil)
}

以 Debug() 方法和 Info() 方法为例,logger 调用 newEvent() 方法创建 Event,一个 Event 代表一行输出日志的对象。

来到 newEvent() 方法,首先会先判断日志是否满足输出等级,会根据 Logger 的输出等级与全局的设置的日志等级判断,如果不满足,则不创建,否则创建 Event 对象;其中的 newEvent() 函数位于 event.go 文件中,涉及到 Event 对象相关,我们稍后讲解。

func (l *Logger) newEvent(level Level, done func(string)) *Event {
    enabled := l.should(level)
    if !enabled {
       if done != nil { // 如果等级不够输出,执行预设的 done 函数后结束
          done("")
       }
       return nil
    }
    e := newEvent(l.w, level) // 正式创建 Event 对象
    e.done = done
    e.ch = l.hooks
    e.ctx = l.ctx
    if level != NoLevel && LevelFieldName != "" {
       e.Str(LevelFieldName, LevelFieldMarshalFunc(level)) // 输出 level 字段
    }
    if l.context != nil && len(l.context) > 1 {
       e.buf = enc.AppendObjectData(e.buf, l.context) // 添加上下文字段
    }
    if l.stack {
       e.Stack()
    }
    return e
}

func (l *Logger) should(lvl Level) bool {
    if l.w == nil { // 输出的 writer,标准输出或者文件输出等
       return false
    }
    if lvl < l.level || lvl < GlobalLevel() { // 待输出的日志等级是否大于 Logger 指定的日志等级与是否大于全局设置的日志等级
       return false
    }
    if l.sampler != nil && !samplingDisabled() {
       return l.sampler.Sample(lvl)
    }
    return true
}

在链式调用中,调用 Debug() 或者 Info() 我们会得到一个 Event 对象,Event 的定义位于 event.go 文件,接下来我们看看 Event 的定义及其方法。

其中 Msg() 方法就是我们在前文提到的会使日志输出生效的方法,其内部调用 msg() 方法完成日志输出:

type Event struct {
    buf       []byte // 日志内容
    w         LevelWriter // 日志 writer,必须不为空
    level     Level // 本次日志的等级
    done      func(msg string) // 写入日志内容后,会执行此函数
    stack     bool            // 开启错误栈跟踪
    ch        []Hook          // hooks from context
    skipFrame int             // The number of additional frames to skip when printing the caller.
    ctx       context.Context // Optional Go context for event
}

// 实际上就是调用 msg()
func (e *Event) Msg(msg string) {
    if e == nil {
       return
    }
    e.msg(msg)
}

func (e *Event) msg(msg string) {
    for _, hook := range e.ch { // 执行 hooks,hooh 在 Run 方法的实现中,可以添加日志内容(比如一些上下文字段)到内容日志中
       hook.Run(e, e.level, msg)
    }
    if msg != "" {
       e.buf = enc.AppendString(enc.AppendKey(e.buf, MessageFieldName), msg) // 添加键值对字符串
    }
    if e.done != nil {
       defer e.done(msg) // 写入 msg 后执行 done 函数
    }
    if err := e.write(); err != nil { // 执行 write 方法
       if ErrorHandler != nil { // 如果设置了全局的错误处理函数,执行
          ErrorHandler(err)
       } else { // 否则控制台打印
          fmt.Fprintf(os.Stderr, "zerolog: could not write event: %v\n", err)
       }
    }
}

func (e *Event) write() (err error) {
    if e == nil {
       return nil
    }
    if e.level != Disabled {
       e.buf = enc.AppendEndMarker(e.buf)
       e.buf = enc.AppendLineBreak(e.buf)
       if e.w != nil {
          _, err = e.w.WriteLevel(e.level, e.buf) // 执行日志写入
       }
    }
    putEvent(e) // 将对象放回到 eventPool 中
    return
}

在 Event 的 write() 方法中,调用了 putEvent() 函数,以及上文还未讲解的 newEvent() 函数,这个是 Zerolog 实现的 Event 对象的池化管理,借此来减少 Event 对象的频繁创建与销毁。

在 event.go 中,定义了一个 eventPool,它由 sync.Pool 实现,sync.Pool 是 Go 中一个用于存储临时对象的对象池,可以复用对象以减少内存分配和垃圾回收的开销且获取对象时保证线程安全。

var eventPool = &sync.Pool{
    New: func() interface{} {
       return &Event{
          buf: make([]byte, 0, 500), // 指定容量为 500 的 event
       }
    },
}

newEvent 函数与 putEvent 函数分别从 eventPool 获取以及存放 Event 对象以完成对象复用。

func newEvent(w LevelWriter, level Level) *Event {
    e := eventPool.Get().(*Event) // 从 eventPool 中取出对象
    e.buf = e.buf[:0] // 清空切片内容
    e.ch = nil
    e.buf = enc.AppendBeginMarker(e.buf) // 添加 json 开始标记 '{'
    e.w = w
    e.level = level
    e.stack = false
    e.skipFrame = 0
    return e
}

func putEvent(e *Event) {
    // sync.Pool 要求每个存储的条目具有大致相同的内存开销,所以对存储值做硬性限制
    const maxSize = 1 << 16 // 64KiB
    if cap(e.buf) > maxSize {
       return
    }
    eventPool.Put(e) // 添加到 eventPool 中
}

在分析完以上源码后,我们来总结 Zerolog 是如何实现高性能的:

1.格式化:日志 json 序列化是通过直接操作字节缓冲区实现的,不需要通过 json 库将字符串序列化。

var enc = json.Encoder{}
enc.AppendString(dst []byte, s string)
enc.AppendKey(dst []byte, key string)
enc.AppendBeginMarker(dst []byte)
enc.AppendEndMarker(dst []byte)

2.并发写入:在日志方法的链式调用中,比如设置日志等级,添加上下文字段等,每次调用都会产生一个新的实例,不需要加锁,避免了需要锁来保证并发安全,消除锁竞争。

3.内存分配:设置日志内容缓冲区(Event.buf),预先分配空间,日志内容直接写入缓冲区,避免创建中间对象;设置对象的缓冲区,eventPool,减少频繁创建临时对象产生的内存分配与 GC。