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 项目,需要记录日志时,在底层会走这样的大致调用流程:
- 配置了我们定制的写同步器
syncer
go
syncer := entity.WriteSyncer()
entity.Core = zapcore.NewCore(global.GVA_CONFIG.Zap.Encoder(), syncer, levelEnabler)
- 获取 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
}
- 在记录日志时,会调用
ioCore
的Write
和Sync
方法
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()
}
- 实际上就是在调用构造时传入的
Cutter
实例身上的Write
和Sync
方法。
刷新缓冲区
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
}
为什么会需要刷新缓冲区?
- 数据安全性
- 默认情况下,文件写入会先进入操作系统缓冲区,Sync() 确保数据真正写入磁盘。
- 防止程序崩溃时丢失未刷新的日志。
- 日志库的常见需求
- 像 zap、logrus 等日志库都会提供 Sync() ,允许用户控制日志的持久化时机。
写入日志
完整代码
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...)
c.formats
是通过函数选项模式得到字符串切片- 计算自定义文件名称的长度,最后加上3个容量[日志目录、时间格式、日志级别]得到一个路径长度切片
values
- 按照一定的次序,将元素依次添加到
values
中 - 调用
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.Stdout
和 cutter
两个 WriteSyncer
实例组合成一个 MultiWriteSyncer
实例,实现同时将日志写入控制台和文件的功能。