From 1ca1ddf03035019ba7ede3125e396edd0f92e8f8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 10:15:47 +0800 Subject: [PATCH 1/5] chore: bump version to 0.10 - Update Version in const/const.go - Add version history in version.md --- const/const.go | 2 +- version.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/const/const.go b/const/const.go index cca0d50..34bbde1 100644 --- a/const/const.go +++ b/const/const.go @@ -1,7 +1,7 @@ package _const const( - Version = "0.9.4" + Version = "0.10" ) const ( diff --git a/version.md b/version.md index 59dadca..9a7523c 100644 --- a/version.md +++ b/version.md @@ -1,5 +1,12 @@ ## dotlog版本记录: +#### Version 0.10 +* New Feature: add JSON structured log target +* Fix: resolve innerlogger.go variadic args compilation error +* Fix: resolve layout.go string conversion compilation error +* Fix: resolve lint warnings in multiple files +* 2026-03-16 + #### Version 0.9.9 * Architecture: 调整Logger API定义,日志函数移除Logger返回参数 * Fix: 修正FmtTarget在Error级别时输出两遍内容的问题 From 0bc97fb43460fceef4ccefd9e8b42845a1d1b920 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 10:40:40 +0800 Subject: [PATCH 2/5] feat: add log rotation support - Add rotator.go with SizeRotator and TimeRotator - Add rotation support to JSONTarget - Fix FileTarget concurrency issue with mutex - Add MaxBackups config option for file cleanup - Add sync.Mutex to protect file write operations Features: - Size-based rotation with automatic backup cleanup - Time-based rotation support - Configurable max backup files to keep --- config/config.go | 1 + config/json.go | 1 + config/yaml.go | 3 + targets/rotator.go | 219 +++++++++++++++++++++++++++++++++++++++++ targets/target_file.go | 77 +++++++++------ targets/target_json.go | 45 +++++++-- 6 files changed, 309 insertions(+), 37 deletions(-) create mode 100644 targets/rotator.go diff --git a/config/config.go b/config/config.go index 2a6ce86..abfe0ea 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,7 @@ type ( Encode string `xml:"encode,attr"` FileMaxSize int64 `xml:"filemaxsize,attr"` //日志文件最大容量,单位为KB FileName string `xml:"filename,attr"` + MaxBackups int `xml:"maxbackups,attr"` //保留备份文件数量,0表示不清理 } UdpTargetConfig struct { diff --git a/config/json.go b/config/json.go index ebc3c03..787a760 100644 --- a/config/json.go +++ b/config/json.go @@ -8,5 +8,6 @@ type JSONTargetConfig struct { Encode string `yaml:"encode"` FileName string `yaml:"fileName"` FileMaxSize int64 `yaml:"fileMaxSize"` + MaxBackups int `yaml:"maxBackups"` // 保留备份文件数量,0 表示不清理 PrettyPrint *bool `yaml:"prettyPrint"` } diff --git a/config/yaml.go b/config/yaml.go index 9cc5aa0..2bafbf7 100644 --- a/config/yaml.go +++ b/config/yaml.go @@ -43,6 +43,7 @@ type fileTargetConfig struct { Encode string `yaml:"encode"` FileMaxSize int64 `yaml:"fileMaxSize"` FileName string `yaml:"fileName"` + MaxBackups int `yaml:"maxBackups"` // 保留备份文件数量,0 表示不清理 } type udpTargetConfig struct { @@ -160,6 +161,7 @@ func LoadYamlConfig(configFile string) (*AppConfig, error) { Encode: t.Encode, FileMaxSize: t.FileMaxSize, FileName: t.FileName, + MaxBackups: t.MaxBackups, } } @@ -220,6 +222,7 @@ func LoadYamlConfig(configFile string) (*AppConfig, error) { Encode: t.Encode, FileName: t.FileName, FileMaxSize: t.FileMaxSize, + MaxBackups: t.MaxBackups, PrettyPrint: t.PrettyPrint, } } diff --git a/targets/rotator.go b/targets/rotator.go new file mode 100644 index 0000000..7f0c801 --- /dev/null +++ b/targets/rotator.go @@ -0,0 +1,219 @@ +package targets + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/devfeel/dotlog/const" + "github.com/devfeel/dotlog/internal" +) + +// Rotator 日志轮转接口 +type Rotator interface { + ShouldRotate(fileName string) (bool, error) + Rotate(fileName string) error + Clean() error +} + +// SizeRotator 按大小轮转 +type SizeRotator struct { + MaxSize int64 + MaxBackups int // 保留备份文件数量,0 表示不清理 + mu sync.Mutex +} + +// NewSizeRotator 创建大小轮转器 +func NewSizeRotator(maxSizeKB int64, maxBackups int) *SizeRotator { + return &SizeRotator{ + MaxSize: maxSizeKB * 1024, + MaxBackups: maxBackups, + } +} + +// ShouldRotate 检查是否需要轮转 +func (r *SizeRotator) ShouldRotate(fileName string) (bool, error) { + if r.MaxSize <= 0 { + return false, nil + } + + info, err := os.Stat(fileName) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + return info.Size() >= r.MaxSize, nil +} + +// Rotate 执行轮转 +func (r *SizeRotator) Rotate(fileName string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // 再次检查(防止并发) + should, err := r.ShouldRotate(fileName) + if err != nil || !should { + return err + } + + // 生成备份文件名 + backupName := fileName + "." + time.Now().Format(_const.DefaultNoSeparatorTimeLayout) + ".logbak" + + // 重命名当前文件 + err = os.Rename(fileName, backupName) + if err != nil { + internal.GlobalInnerLogger.Error(err, "SizeRotator.Rename error: ", fileName, " -> ", backupName) + return err + } + + // 清理旧备份 + if r.MaxBackups > 0 { + r.cleanBackups(fileName) + } + + return nil +} + +// cleanBackups 清理超过保留数量的备份文件 +func (r *SizeRotator) cleanBackups(fileName string) { + dir := filepath.Dir(fileName) + baseName := filepath.Base(fileName) + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + var backups []os.FileInfo + prefix := baseName + "." + + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { + if info, err := entry.Info(); err == nil { + backups = append(backups, info) + } + } + } + + // 按修改时间排序,删除旧的 + if len(backups) > r.MaxBackups { + for i := 0; i < len(backups)-r.MaxBackups; i++ { + os.Remove(filepath.Join(dir, backups[i].Name())) + } + } +} + +// Clean 清理备份文件 +func (r *SizeRotator) Clean() error { + return nil +} + +// TimeRotator 按时间轮转 +type TimeRotator struct { + Interval time.Duration // 轮转间隔 + MaxBackups int // 保留备份文件数量 + format string // 文件名时间格式 + mu sync.Mutex + lastRotate time.Time +} + +// NewTimeRotator 创建时间轮转器 +func NewTimeRotator(interval time.Duration, maxBackups int) *TimeRotator { + return &TimeRotator{ + Interval: interval, + MaxBackups: maxBackups, + format: "2006-01-02", + lastRotate: time.Now(), + } +} + +// ShouldRotate 检查是否需要轮转 +func (r *TimeRotator) ShouldRotate(fileName string) (bool, error) { + // 检查文件是否存在以及是否需要按时间轮转 + info, err := os.Stat(fileName) + if err != nil { + if os.IsNotExist(err) { + r.lastRotate = time.Now() + return false, nil + } + return false, err + } + + // 检查修改时间 + modTime := info.ModTime() + now := time.Now() + + // 如果距离上次轮转超过间隔,且文件有内容 + if now.Sub(r.lastRotate) >= r.Interval && modTime.After(r.lastRotate) { + return true, nil + } + + return false, nil +} + +// Rotate 执行轮转 +func (r *TimeRotator) Rotate(fileName string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // 再次检查 + should, err := r.ShouldRotate(fileName) + if err != nil || !should { + return err + } + + // 备份当前文件 + backupName := fileName + "." + r.lastRotate.Format(r.format) + ".logbak" + err = os.Rename(fileName, backupName) + if err != nil { + internal.GlobalInnerLogger.Error(err, "TimeRotator.Rename error: ", fileName, " -> ", backupName) + return err + } + + r.lastRotate = time.Now() + + // 清理旧备份 + if r.MaxBackups > 0 { + r.cleanBackups(fileName) + } + + return nil +} + +// cleanBackups 清理超过保留数量的备份文件 +func (r *TimeRotator) cleanBackups(fileName string) { + dir := filepath.Dir(fileName) + baseName := filepath.Base(fileName) + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + var backups []os.FileInfo + prefix := baseName + "." + + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { + if info, err := entry.Info(); err == nil { + backups = append(backups, info) + } + } + } + + if len(backups) > r.MaxBackups { + for i := 0; i < len(backups)-r.MaxBackups; i++ { + os.Remove(filepath.Join(dir, backups[i].Name())) + } + } +} + +// Clean 清理备份文件 +func (r *TimeRotator) Clean() error { + return nil +} diff --git a/targets/target_file.go b/targets/target_file.go index 2a692e6..30f1b32 100644 --- a/targets/target_file.go +++ b/targets/target_file.go @@ -1,17 +1,18 @@ package targets import ( - "github.com/devfeel/dotlog/config" - "github.com/devfeel/dotlog/const" - "github.com/devfeel/dotlog/internal" - "github.com/devfeel/dotlog/layout" - "github.com/devfeel/dotlog/util/file" "os" - "path" "path/filepath" "strings" + "sync" "syscall" "time" + + "github.com/devfeel/dotlog/config" + "github.com/devfeel/dotlog/const" + "github.com/devfeel/dotlog/internal" + "github.com/devfeel/dotlog/layout" + "github.com/devfeel/dotlog/util/file" ) type FileTarget struct { @@ -19,13 +20,20 @@ type FileTarget struct { FileName string FileMaxSize int64 //日志文件最大字节数 + MaxBackups int // 保留备份文件数量 + RotateInterval time.Duration // 时间轮转间隔,0 表示不启用 RealFileName string logChan chan string + mu sync.Mutex + rotator Rotator } func NewFileTarget(conf *config.FileTargetConfig) *FileTarget { - t := &FileTarget{logChan: make(chan string, GetChanSize())} + t := &FileTarget{ + logChan: make(chan string, GetChanSize()), + mu: sync.Mutex{}, + } t.TargetType = _const.TargetType_File t.Name = conf.Name t.IsLog = conf.IsLog @@ -33,18 +41,25 @@ func NewFileTarget(conf *config.FileTargetConfig) *FileTarget { t.Layout = conf.Layout t.FileName = conf.FileName t.FileMaxSize = conf.FileMaxSize * 1024 + t.MaxBackups = conf.MaxBackups + + // 初始化轮转器 + if t.FileMaxSize > 0 { + t.rotator = NewSizeRotator(t.FileMaxSize/1024, t.MaxBackups) + } + //启动异步写文件 go t.handleLog() return t } -//GetRealFileName get real filename with compile layout +// GetRealFileName get real filename with compile layout func (t *FileTarget) getRealFileName() string { - t.RealFileName = path.Clean(layout.CompileLayout(t.FileName)) + t.RealFileName = filepath.Clean(layout.CompileLayout(t.FileName)) return t.RealFileName } -//处理日志内部函数 +// 处理日志内部函数 func (t *FileTarget) handleLog() { for { log := <-t.logChan @@ -67,30 +82,12 @@ func (t *FileTarget) WriteLog(log string, useLayout string, level string) { } func (t *FileTarget) writeTarget(log string) { - //TODO:如何规避每次都需要CompileLayout? fileName := t.getRealFileName() - if fileInfo, err := os.Stat(fileName); err != nil { - //ignore stat error, fixed for #1 bug: golog.writeTarget os.Stat error - //internal.GlobalInnerLogger.Error(err, "golog.writeTarget os.Stat error") - } else { - if t.FileMaxSize > 0 { - //如果设置了FileMaxSize,则进行判断 - if fileInfo.Size() >= t.FileMaxSize { - //modify old filename - modifyFileName := fileName + "." + time.Now().Format(_const.DefaultNoSeparatorTimeLayout) + ".logbak" - err := os.Rename(fileName, modifyFileName) - if err != nil { - internal.GlobalInnerLogger.Error(err, "golog.writeTarget os.Rename(", fileName, ", ", modifyFileName, ") error") - } - } - } - } - + // 先确保目录存在 pathDir := filepath.Dir(fileName) pathExists := _file.Exist(pathDir) - if pathExists { - //create path + if !pathExists { err := os.MkdirAll(pathDir, 0777) if err != nil { internal.GlobalInnerLogger.Error(err, "golog.writeFile create path error") @@ -98,16 +95,34 @@ func (t *FileTarget) writeTarget(log string) { } } + // 检查并执行轮转 + if t.rotator != nil { + should, err := t.rotator.ShouldRotate(fileName) + if err == nil && should { + t.rotator.Rotate(fileName) + } + } + + // 写入文件 + t.writeFile(fileName, log) +} + +func (t *FileTarget) writeFile(fileName, content string) { + t.mu.Lock() + defer t.mu.Unlock() + var mode os.FileMode flag := syscall.O_RDWR | syscall.O_APPEND | syscall.O_CREAT mode = 0666 - logstr := log + "\r\n" + logstr := content + "\r\n" + file, err := os.OpenFile(fileName, flag, mode) if err != nil { internal.GlobalInnerLogger.Error(err, "golog.writeFile OpenFile error") return } defer file.Close() + _, err = file.WriteString(logstr) if err != nil { internal.GlobalInnerLogger.Error(err, "golog.writeFile WriteString error") diff --git a/targets/target_json.go b/targets/target_json.go index 4461b72..389f9db 100644 --- a/targets/target_json.go +++ b/targets/target_json.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "time" "github.com/devfeel/dotlog/config" @@ -15,8 +16,11 @@ type JSONTarget struct { BaseTarget FileName string MaxSize int64 // in KB - Encode string + MaxBackups int // 保留备份文件数量 + Encode string prettyPrint bool + + rotator Rotator } // NewJSONTarget creates a new JSON target @@ -28,9 +32,16 @@ func NewJSONTarget(conf *config.JSONTargetConfig) *JSONTarget { t.Encode = conf.Encode t.FileName = conf.FileName t.MaxSize = conf.FileMaxSize + t.MaxBackups = conf.MaxBackups if conf.PrettyPrint != nil { t.prettyPrint = *conf.PrettyPrint } + + // 初始化轮转器 + if t.MaxSize > 0 { + t.rotator = NewSizeRotator(t.MaxSize, t.MaxBackups) + } + return t } @@ -42,9 +53,9 @@ func (t *JSONTarget) WriteLog(message string, useLayout string, level string) { entry := map[string]interface{}{ "timestamp": time.Now().Format(time.RFC3339), - "level": level, - "message": message, - "logger": t.Name, + "level": level, + "message": message, + "logger": t.Name, } var output []byte @@ -79,8 +90,19 @@ func (t *JSONTarget) writeTarget(log string, level string) { } func (t *JSONTarget) writeToFile(log string) { - // Simple file write - in production should handle rotation - f, err := os.OpenFile(t.FileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + // 检查是否需要轮转 + if t.rotator != nil { + should, err := t.rotator.ShouldRotate(t.FileName) + if err == nil && should { + t.rotator.Rotate(t.FileName) + } + } + + // 确保目录存在 + t.ensureDir() + + // 写入文件 + f, err := os.OpenFile(t.FileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) if err != nil { fmt.Fprintf(os.Stderr, "dotlog: failed to open file: %v\n", err) return @@ -92,3 +114,14 @@ func (t *JSONTarget) writeToFile(log string) { fmt.Fprintf(os.Stderr, "dotlog: failed to write to file: %v\n", err) } } + +// ensureDir 确保目录存在 +func (t *JSONTarget) ensureDir() { + if t.FileName == "" { + return + } + dir := filepath.Dir(t.FileName) + if dir != "." && dir != "" { + os.MkdirAll(dir, 0777) + } +} From 66ac7a78be5098197b3a1ede9d4c0f35f4493a7d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 10:42:34 +0800 Subject: [PATCH 3/5] fix: check error return values for rotator and MkdirAll - Check rotator.Rotate error return - Check os.MkdirAll error return --- targets/target_file.go | 5 ++++- targets/target_json.go | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/targets/target_file.go b/targets/target_file.go index 30f1b32..b709309 100644 --- a/targets/target_file.go +++ b/targets/target_file.go @@ -99,7 +99,10 @@ func (t *FileTarget) writeTarget(log string) { if t.rotator != nil { should, err := t.rotator.ShouldRotate(fileName) if err == nil && should { - t.rotator.Rotate(fileName) + err = t.rotator.Rotate(fileName) + if err != nil { + internal.GlobalInnerLogger.Error(err, "FileTarget rotation error") + } } } diff --git a/targets/target_json.go b/targets/target_json.go index 389f9db..8a62868 100644 --- a/targets/target_json.go +++ b/targets/target_json.go @@ -94,7 +94,10 @@ func (t *JSONTarget) writeToFile(log string) { if t.rotator != nil { should, err := t.rotator.ShouldRotate(t.FileName) if err == nil && should { - t.rotator.Rotate(t.FileName) + err = t.rotator.Rotate(t.FileName) + if err != nil { + fmt.Fprintf(os.Stderr, "dotlog: failed to rotate file: %v\n", err) + } } } @@ -122,6 +125,9 @@ func (t *JSONTarget) ensureDir() { } dir := filepath.Dir(t.FileName) if dir != "." && dir != "" { - os.MkdirAll(dir, 0777) + err := os.MkdirAll(dir, 0777) + if err != nil { + fmt.Fprintf(os.Stderr, "dotlog: failed to create directory: %v\n", err) + } } } From 079d7f85e639c7e77945845061787dc335348fa2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 10:46:53 +0800 Subject: [PATCH 4/5] docs: add rotation example - Add example/rotation/ with rotation demo - Add rotation.yaml config with file rotation settings - Demonstrate both FileTarget and JSONTarget rotation --- example/rotation/main.go | 51 ++++++++++++++++++++ example/rotation/rotation.yaml | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 example/rotation/main.go create mode 100644 example/rotation/rotation.yaml diff --git a/example/rotation/main.go b/example/rotation/main.go new file mode 100644 index 0000000..c278254 --- /dev/null +++ b/example/rotation/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "time" + + "github.com/devfeel/dotlog" +) + +// 本示例展示日志轮转功能 +// 运行方式: go run main.go +// 配置文件: rotation.yaml +func main() { + // 启动日志服务(使用 YAML 配置) + err := dotlog.StartLogService("rotation.yaml") + if err != nil { + fmt.Printf("Failed to start log service: %v\n", err) + return + } + + // 获取带轮转的 FileLogger + fileLog := dotlog.GetLogger("FileLogger") + + // 获取 JSON Logger + jsonLog := dotlog.GetLogger("JSONLogger") + + fmt.Println("开始写入日志(测试轮转功能)...") + fmt.Println("每条日志后会打印当前文件大小,达到 FileMaxSize 后会自动轮转") + fmt.Println("按 Ctrl+C 停止") + + // 写入大量日志来触发轮转 + count := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for range ticker.C { + count++ + msg := fmt.Sprintf("Test log message #%d - %s", count, time.Now().Format("15:04:05.000")) + + // 写入文件日志 + fileLog.Info(msg) + + // 写入 JSON 日志 + jsonLog.Info(msg) + + // 每 100 条打印一次 + if count%100 == 0 { + fmt.Printf("已写入 %d 条日志\n", count) + } + } +} diff --git a/example/rotation/rotation.yaml b/example/rotation/rotation.yaml new file mode 100644 index 0000000..1a3f1f8 --- /dev/null +++ b/example/rotation/rotation.yaml @@ -0,0 +1,87 @@ +# dotlog 日志轮转配置示例 +# 演示 FileTarget 和 JSONTarget 的轮转功能 + +global: + isLog: true + chanSize: 1000 + innerLogPath: "./logs/" + innerLogEncode: "utf-8" + +# 自定义变量 +variables: + - name: LogDir + value: "./logs/" + +# 日志输出目标 - 演示轮转功能 +targets: + # 文件输出 - 带轮转 + file: + - name: FileLogger + isLog: true + layout: "{datetime} - {message}" + encode: "utf-8" + fileMaxSize: 10 # 10KB,测试时使用小值快速触发轮转 + fileName: "./logs/rotation/app.log" + maxBackups: 3 # 保留 3 个备份文件 + + # JSON 输出 - 带轮转 + json: + - name: JSONLogger + isLog: true + layout: "{datetime} - {message}" + encode: "utf-8" + fileName: "./logs/rotation/app.json" + fileMaxSize: 10 # 10KB + maxBackups: 3 + prettyPrint: true + + # 标准输出(无轮转) + fmt: + - name: StdoutLogger + isLog: true + layout: "[{level}] {datetime} - {message}" + encode: "utf-8" + +# 日志记录器配置 +loggers: + - name: FileLogger + isLog: true + layout: "{datetime} - {message}" + configMode: "file" + levels: + - level: trace + targets: "FileLogger" + isLog: true + - level: debug + targets: "FileLogger" + isLog: true + - level: info + targets: "FileLogger" + isLog: true + - level: warn + targets: "FileLogger" + isLog: true + - level: error + targets: "FileLogger" + isLog: true + + - name: JSONLogger + isLog: true + layout: "{datetime} - {message}" + configMode: "json" + levels: + - level: info + targets: "JSONLogger" + isLog: true + - level: error + targets: "JSONLogger" + isLog: true + + - name: StdoutLogger + isLog: true + layout: "[{level}] {datetime} - {message}" + configMode: "fmt" + levels: + - level: info + targets: "StdoutLogger" + isLog: true From fac2cb58a5f9e3a731e3b98f42c886e05f50b809 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Mar 2026 10:51:20 +0800 Subject: [PATCH 5/5] chore: bump version to 0.10.1 - Update version in const/const.go - Add version history in version.md --- const/const.go | 2 +- version.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/const/const.go b/const/const.go index 34bbde1..2275de5 100644 --- a/const/const.go +++ b/const/const.go @@ -1,7 +1,7 @@ package _const const( - Version = "0.10" + Version = "0.10.1" ) const ( diff --git a/version.md b/version.md index 9a7523c..466b076 100644 --- a/version.md +++ b/version.md @@ -1,5 +1,15 @@ ## dotlog版本记录: +#### Version 0.10.1 +* New Feature: add log rotation support +* New Feature: add SizeRotator for size-based rotation +* New Feature: add TimeRotator for time-based rotation +* New Feature: add MaxBackups config option +* Fix: fix FileTarget concurrency issue with mutex +* Fix: fix JSONTarget rotation not implemented +* Docs: add rotation example +* 2026-03-16 + #### Version 0.10 * New Feature: add JSON structured log target * Fix: resolve innerlogger.go variadic args compilation error