Go项目中的日志轮转

2024-12-02 ⏳2.3分钟(0.9千字)

Golang 项目中的日志库有很多,例如 go 自带的 log 或后续加入的 log/slog,开源的 zap、zerolog 等,他们提供的功能是高效的将输出记录到指定位置(也可以同时记录到多个位置,例如同时输出到控制台和文件),但是还缺少一个日志轮转功能,一旦程序运行久了,日志文件很大,影响写入速度不说,在一个大文件中找到需要的日志条目也很艰难。所以一般 Go 项目除了使用一个日志库,还需要一个日志切割库,比较出名的就是 lumberjack,搭配 zap 应该是众多生产项目的标配。由于最近有个小需求是和其他项目保持一致(每日轮转)然后运维那边方便复用相同的采集工具导入到 ES,所以研究了一下如何自己实现一个日志轮转的库。下面以 zap 为例。

zap 是如何记录日志的

按照官方 github 给的例子

logger, _ := zap.NewProduction()
defer logger.Sync()
logger.Info("failed to fetch URL",
  // Structured context as strongly typed Field values.
  zap.String("url", url),
  zap.Int("attempt", 3),
  zap.Duration("backoff", time.Second),
)
  1. 首先 New 一个 logger,没有额外传入 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"},
    }
}

默认配置设置了 logger 的输出器是 json 格式(这个也可以自己实现接口),日志级别是 Info,日志输出位置是 stderr 2. 有了默认设置的 log,就可以调用对应的 logger.Info 或者 Error 记录相应信息了,这是最简单的用法,还可以记录日志的字段、日志的时间格式、日志的输出位置等等,这里不作详细介绍。我们的注意力肯定要集中到写入那块,因为我们需要按天轮转(或按当前日志文件大小轮转),所以需要知道我们的日志字符串是如何写入的。

func (log *Logger) Info(msg string, fields ...Field) {
    if ce := log.check(InfoLevel, msg); ce != nil {
        ce.Write(fields...)
    }
}

在 Info 方法中,会根据级别和日志信息,获取到一个 ce(zapcore.CheckedEntry),由 ce.Write 写入日志。

func (ce *CheckedEntry) Write(fields ...Field) {
    if ce == nil {
        return
    }
    ...
    var err error
    for i := range ce.cores {
        err = multierr.Append(err, ce.cores[i].Write(ce.Entry, fields))
    }
    ...
}

ce.Write 则会让执行自己保存的每个 core 的 Write 方法写入多个不同位置,这个 core 有预设的,也提供了 New 方法构造

// NewCore creates a Core that writes logs to a WriteSyncer.
func NewCore(enc Encoder, ws WriteSyncer, enab LevelEnabler) Core {
    return &ioCore{
        LevelEnabler: enab,
        enc:          enc,
        out:          ws,
    }
}

enc 提供了日志已什么样的格式记录(原始字符串还是帮你 json 序列化一下),enab 则是判断当前日志级别是否需要记录,例如 logger 设置的级别是 Error,则 Info 日志不会记录,就是一个开关。

ws 参数则是重点

type WriteSyncer interface {
    io.Writer
    Sync() error
}

它需要实现 io.Writer, 和 Sync 方法,io.Writer 是控制写入的,Sync 则是 zap 独有的逻辑(打印 Error 以上的日志会调用 Sync 立即写入),所以日志轮转就需要我们实现这个 WriteSyncer,在我们实现的 Writer 接口上做文章

type Writer interface {
    Write(p []byte) (n int, err error)
}

lumberjack 是如何切割日志的

根据上面分析,直接找到核心方法 Write

func (l *Logger) Write(p []byte) (n int, err error) {
    l.mu.Lock()
    defer l.mu.Unlock()
    writeLen := int64(len(p))
    // 1. 单次写入的日志信息大于配置的轮转大小,报错返回
    // 这里是防止单条日志被切割到两个文件,例如我配置的10K一个轮转。单条日志是15K,没有这个逻辑的话,一条日志前半段在一个文件,后半段在另一个文件。
    if writeLen > l.max() {
        return 0, fmt.Errorf(
            "write length %d exceeds maximum file size %d", writeLen, l.max(),
        )
    }
    // 2. 写入的文件没有被初始化。打开已有的文件句柄或新建一个文件拿到句柄
    if l.file == nil {
        if err = l.openExistingOrNew(len(p)); err != nil {
            return 0, err
        }
    }
	// 3. 如果当前文件大小加上要写入的日志超过设置的轮转大小,则进行轮转(也就是新建一个日志文件,老文件备份)
    if l.size+writeLen > l.max() {
        if err := l.rotate(); err != nil {
            return 0, err
        }
    }
    // 4. 写入到文件
    n, err = l.file.Write(p)
    // 5. 记录当前文件写入的字节数,上面有用到
    l.size += int64(n)
    return n, err

}

可以看到,这是 lumberjack 根据文件当前大小来做日志切割的逻辑,所以只需要实现 Write(p []byte) (n int, err error) 方法,内部逻辑读取当前日志是否是初始化拿到的日期即可

// 判断同一天的工具方法
func isSameDay(t1, t2 int64) bool {
	y1, m1, d1 := time.Unix(t1, 0).Date()
	y2, m2, d2 := time.Unix(t2, 0).Date()
	return y1 == y2 && m1 == m2 && d1 == d2
}

func (l *Logger) Write(p []byte) (n int, err error) {
  ...
	// 不是同一天,轮转
	if !isSameDay(l.day, time.Now().Unix()) {
		if err := l.rotate(); err != nil {
            return 0, err
        }
	}
  ...
	// 是同一天直接写入
	n, err = l.file.Write(p)
  ...
}

lumberjack 没有实现 Sync 方法是如何接入到 zap 的

上面提到 lumberjack 的 logger 只实现了一个接口,没有 Sync 方法,能到 Zap 的 Core 使用吗?答案是可以的。
zap 的 github 有一则 FAQ 专门介绍了与日志轮转库 lumberjack 的整合

w := zapcore.AddSync(&lumberjack.Logger{
  Filename:   "/var/log/myapp/foo.log",
  MaxSize:    500, // megabytes
  MaxBackups: 3,
  MaxAge:     28, // days
})
core := zapcore.NewCore(
  zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
  w,
  zap.InfoLevel,
)
logger := zap.New(core)

只要调用 zapcore.AddSync 方法即可将只实现了 ioWriter 方法的 logger 转换为 zapcore.WriteSyncer。

AddSync 将只有 io.Writer 没有 Sync 方法的 logger 添加一个空的 Sync 方法