Go项目中的日志轮转
jeffreyGolang 项目中的日志库有很多,例如 go 自带的 log 或后续加入的 log/slog,开源的 zap、zerolog 等,他们提供的功能是高效的将输出记录到指定位置(也可以同时记录到多个位置,例如同时输出到控制台和文件),但是还缺少一个日志轮转功能,一旦程序运行久了,日志文件很大,影响写入速度不说,在一个大文件中找到需要的日志条目也很艰难。所以一般 Go 项目除了使用一个日志库,还需要一个日志切割库,比较出名的就是 lumberjack,搭配 zap 应该是众多生产项目的标配。由于最近有个小需求是和其他项目保持一致(每日轮转)然后运维那边方便复用相同的采集工具导入到 ES,所以研究了一下如何自己实现一个日志轮转的库。下面以 zap 为例。
zap 是如何记录日志的
按照官方 github 给的例子
, _ := zap.NewProduction()
loggerdefer logger.Sync()
.Info("failed to fetch URL",
logger// Structured context as strongly typed Field values.
.String("url", url),
zap.Int("attempt", 3),
zap.Duration("backoff", time.Second),
zap)
- 首先 New 一个 logger,没有额外传入 options,则取默认配置
func NewProductionConfig() Config {
return Config{
: NewAtomicLevelAt(InfoLevel),
Level: false,
Development: &SamplingConfig{
Sampling: 100,
Initial: 100,
Thereafter},
: "json",
Encoding: NewProductionEncoderConfig(),
EncoderConfig: []string{"stderr"},
OutputPaths: []string{"stderr"},
ErrorOutputPaths}
}
默认配置设置了 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 {
.Write(fields...)
ce}
}
在 Info 方法中,会根据级别和日志信息,获取到一个 ce(zapcore.CheckedEntry),由 ce.Write 写入日志。
func (ce *CheckedEntry) Write(fields ...Field) {
if ce == nil {
return
}
...
var err error
for i := range ce.cores {
= multierr.Append(err, ce.cores[i].Write(ce.Entry, fields))
err }
...
}
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{
: enab,
LevelEnabler: enc,
enc: ws,
out}
}
enc 提供了日志已什么样的格式记录(原始字符串还是帮你 json 序列化一下),enab 则是判断当前日志级别是否需要记录,例如 logger 设置的级别是 Error,则 Info 日志不会记录,就是一个开关。
ws 参数则是重点
type WriteSyncer interface {
.Writer
io() error
Sync}
它需要实现 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) {
.mu.Lock()
ldefer l.mu.Unlock()
:= int64(len(p))
writeLen // 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. 写入到文件
, err = l.file.Write(p)
n// 5. 记录当前文件写入的字节数,上面有用到
.size += int64(n)
lreturn n, err
}
可以看到,这是 lumberjack 根据文件当前大小来做日志切割的逻辑,所以只需要实现 Write(p []byte) (n int, err error) 方法,内部逻辑读取当前日志是否是初始化拿到的日期即可
// 判断同一天的工具方法
func isSameDay(t1, t2 int64) bool {
, m1, d1 := time.Unix(t1, 0).Date()
y1, m2, d2 := time.Unix(t2, 0).Date()
y2return 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
}
}
...
// 是同一天直接写入
, err = l.file.Write(p)
n...
}
lumberjack 没有实现 Sync 方法是如何接入到 zap 的
上面提到 lumberjack 的 logger 只实现了一个接口,没有 Sync 方法,能到 Zap 的 Core 使用吗?答案是可以的。
zap 的 github 有一则 FAQ 专门介绍了与日志轮转库 lumberjack 的整合
:= zapcore.AddSync(&lumberjack.Logger{
w : "/var/log/myapp/foo.log",
Filename: 500, // megabytes
MaxSize: 3,
MaxBackups: 28, // days
MaxAge})
:= zapcore.NewCore(
core .NewJSONEncoder(zap.NewProductionEncoderConfig()),
zapcore,
w.InfoLevel,
zap)
:= zap.New(core) logger
只要调用 zapcore.AddSync 方法即可将只实现了 ioWriter 方法的 logger 转换为 zapcore.WriteSyncer。
AddSync 将只有 io.Writer 没有 Sync 方法的 logger 添加一个空的 Sync 方法