三十的博客

GVM 脚手架探险记05:跟着 GVM 学习自定义日志轮转

发布时间
阅读量 加载中...

先说重点

GVM 的日志结构位于配置文件定义的目录下,按 YYYY-MM-DD 格式创建日期目录,并在其中存储不同级别的日志文件。

目录结构参考

log/
├── 2025-07-17/
│ └── info.log
└── 2025-07-18/
└── info.log

思考
假如说日志级别配置的很低,比如 debug 级别,什么日志都记,会不会可能把这个文件写满呢?

通过阅读 GVM 自定义日志轮转源码,不难发现其实底层记录日志就是通过 Go 的写文件操作,而轮转功能也是通过底层的文件系统操作实现的。

日志轮转的实现

go
// core/internal/cutter.go

// Cutter 实现 io.Writer 接口
// 用于日志切割, strings.Join([]string{director,layout, formats..., level+".log"}, os.PathSeparator)
type Cutter struct {
    // 日志级别(debug, info, warn, error, dpanic, panic, fatal)
	level        string
    // 时间格式 2006-01-02 15:04:05
	layout       string
    // 自定义参数([]string{Director,"2006-01-02", "business"(此参数可不写), level+".log"}
	formats      []string
    // 日志文件夹
	director     string
    //日志保留天数
	retentionDay int
    // 文件句柄
	file         *os.File
    // 读写锁
	mutex        *sync.RWMutex
}

type CutterOption func(*Cutter)

func NewCutter(director string, level string, retentionDay int, options ...CutterOption) *Cutter {
	rotate := &Cutter{
		level:        level,
		director:     director,
		retentionDay: retentionDay,
		mutex:        new(sync.RWMutex),
	}
	// 应用每个前置操作 类似 gin 的中间件底层逻辑
	for i := 0; i < len(options); i++ {
		options[i](rotate)
	}
	return rotate
}

定义了一个实现了 io.Writer 接口的结构体 Cutter ,用于日志轮转。并提供了工厂函数 NewCutter 用于创建 Cutter 实例。

是 Go 开发中最常用的设计模式之一,推荐使用。

另外这里用到了 函数选项模式 ,这是 Go 语言中一种常用的设计模式,用于在创建结构体实例时提供灵活的配置选项。

核心思想是:

ps:第一次见到这个模式的时候,惊为天人,真的是亮瞎了我的双眼。

函数选项模式 也不是特别难理解,就是用函数来设置结构体的字段,而不是直接传递多个参数。

GVM 这里提供了两个函数选项

go
// CutterWithLayout 时间格式
func CutterWithLayout(layout string) CutterOption {
	return func(c *Cutter) {
		c.layout = layout
	}
}

// CutterWithFormats 格式化参数
func CutterWithFormats(format ...string) CutterOption {
	return func(c *Cutter) {
		if len(format) > 0 {
			c.formats = format
		}
	}
}

当你在运行 GVM 项目,需要记录日志时,在底层会走这样的大致调用流程:

  1. 配置了我们定制的写同步器 syncer
go
syncer := entity.WriteSyncer()
entity.Core = zapcore.NewCore(global.GVA_CONFIG.Zap.Encoder(), syncer, levelEnabler)
  1. 获取 Zap 内核实例 ioCore
go
// 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,
	}
}

type ioCore struct {
	LevelEnabler
	enc Encoder
	out WriteSyncer
}
  1. 在记录日志时,会调用 ioCoreWriteSync 方法
go
func (c *ioCore) Write(ent Entry, fields []Field) error {
	buf, err := c.enc.EncodeEntry(ent, fields)
	if err != nil {
		return err
	}
	_, err = c.out.Write(buf.Bytes())
	buf.Free()
	if err != nil {
		return err
	}
	if ent.Level > ErrorLevel {
		// Since we may be crashing the program, sync the output.
		// Ignore Sync errors, pending a clean solution to issue #370.
		_ = c.Sync()
	}
	return nil
}

// Sync 刷新缓冲区
// core/internal/cutter.go

func (c *ioCore) Sync() error {
	return c.out.Sync()
}
  1. 实际上就是在调用构造时传入的 Cutter 实例身上的 WriteSync 方法。

刷新缓冲区

GVM 提供了一个线程安全的、兼容 WriteSyncer 接口的 Sync() 方法,确保关键日志数据持久化到磁盘。

go
func (c *Cutter) Sync() error {
	c.mutex.Lock()
	defer c.mutex.Unlock()

	if c.file != nil {
		return c.file.Sync()
	}
	return nil
}

为什么会需要刷新缓冲区?

  1. ​ 数据安全性 ​
  1. ​ 日志库的常见需求 ​

写入日志

完整代码

go
// core/internal/cutter.go

func (c *Cutter) Write(bytes []byte) (n int, err error) {
	c.mutex.Lock()
	defer func() {
		if c.file != nil {
			_ = c.file.Close()
			c.file = nil
		}
		c.mutex.Unlock()
	}()
	length := len(c.formats)
	values := make([]string, 0, 3+length)
	values = append(values, c.director)
	if c.layout != "" {
		values = append(values, time.Now().Format(c.layout))
	}
	for i := 0; i < length; i++ {
		values = append(values, c.formats[i])
	}
	values = append(values, c.level+".log")
	filename := filepath.Join(values...)
	// filename ex: log\2025-06-25\info.log
	director := filepath.Dir(filename)
	err = os.MkdirAll(director, os.ModePerm)
	if err != nil {
		return 0, err
	}
	err = removeNDaysFolders(c.director, c.retentionDay)
	if err != nil {
		return 0, err
	}
	c.file, err = os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
	if err != nil {
		return 0, err
	}
	return c.file.Write(bytes)
}

日志路径解析

go
length := len(c.formats)
values := make([]string, 0, 3+length)
values = append(values, c.director)
if c.layout != "" {
    values = append(values, time.Now().Format(c.layout))
}
for i := 0; i < length; i++ {
    values = append(values, c.formats[i])
}
values = append(values, c.level+".log")
filename := filepath.Join(values...)
  1. c.formats 是通过函数选项模式得到字符串切片
  2. 计算自定义文件名称的长度,最后加上3个容量[日志目录、时间格式、日志级别]得到一个路径长度切片 values
  3. 按照一定的次序,将元素依次添加到 values
  4. 调用 filepath.Join 拼接路径,得到的文件路径类似于:log\2025-06-25\info.log
思考
为什么长度都固定了,不采用 make([]string, 3) 而是采用 make([]string, 0, 3+length) 呢?

日志轮转解析

go
// core/internal/cutter.go

// 增加日志目录文件清理 小于等于零的值默认忽略不再处理
func removeNDaysFolders(dir string, days int) error {
	if days <= 0 {
		return nil
	}
	cutoff := time.Now().AddDate(0, 0, -days)
	// 递归遍历目录
	return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() && info.ModTime().Before(cutoff) && path != dir {
			err = os.RemoveAll(path)
			if err != nil {
				return err
			}
		}
		return nil
	})
}

利用了 filepath.Walk 递归遍历目录,删除过期的日志目录。

Zap 轮转配置

最后,让我们看下 Zap 中的日志轮转配置部分代码。

go
// core/internal/zap_core.go

// WriteSyncer 写同步器 指定日志写入的目标
// 入参 0 个或多个 string 类型的参数 在函数内部,formats 会被自动转换为 []string 类型(字符串切片)
func (z *ZapCore) WriteSyncer(formats ...string) zapcore.WriteSyncer {
	// 自定义的日志切分
	cutter := NewCutter(
		global.GVA_CONFIG.Zap.Director,     // 日志记录目录
		z.level.String(),                   // 日志级别
		global.GVA_CONFIG.Zap.RetentionDay, // 日志保留天数
		CutterWithLayout(time.DateOnly),    // 时间格式
		CutterWithFormats(formats...),      // 格式化参数
	)
	if global.GVA_CONFIG.Zap.LogInConsole {
		multiSyncer := zapcore.NewMultiWriteSyncer(os.Stdout, cutter)
		//  转换为 zapcore.WriteSyncer 类型
		return zapcore.AddSync(multiSyncer)
	}
	//  转换为 zapcore.WriteSyncer 类型
	return zapcore.AddSync(cutter)
}

其中 multiSyncer := zapcore.NewMultiWriteSyncer(os.Stdout, cutter) 是将 os.Stdoutcutter 两个 WriteSyncer 实例组合成一个 MultiWriteSyncer 实例,实现同时将日志写入控制台和文件的功能。

#Gvm #Golang