Compare commits

..

8 Commits

Author SHA1 Message Date
程广 aa69fd4979 ```
feat(logger): 支持float64类型的日志配置参数并添加测试

- 修改file.go中maxSize和maxCount的类型断言,从int64/int改为
  float64优先,并保留对原类型的兼容
- 添加file_test.go包含完整的文件滚动功能测试用例
- 测试覆盖大小滚动、最大文件数限制和JSON解析场景
```
2026-07-13 10:05:24 +08:00
kingecg 36b2fe9f34 ```
feat(logger): add maxCount configuration option for file appender

Add support for maxCount parameter in file appender configuration.
The maxCount option allows users to specify the maximum number of
backup files to keep when log rotation is enabled.

The new configuration option defaults to 1 if not specified in the
appender options and is passed to the FileAppender struct during
initialization.
```

Co-authored-by: Copilot <copilot@github.com>
2026-07-11 08:54:19 +08:00
kingecg cdea9d86ed ```
feat(file): add thread safety and improve log rolling functionality

- Add sync.Mutex to FileAppender for thread-safe operations
- Implement proper mutex locking in start() goroutine to prevent
  race conditions during file operations
- Return boolean from checkAndRoll() method to indicate if rolling occurred
- Add MaxCount field to limit number of archived log files
- Implement cleanup of old log files exceeding MaxCount limit
- Add proper error handling for file rename operations
- Track current file size using WriteString return values

refactor(format): update formatting functions with better type safety

- Change isFormatString parameter

Co-authored-by: Copilot <copilot@github.com>
2026-07-09 21:47:03 +08:00
kingecg 8c6cf6310f chore: update module path to git.kingecg.top 2025-06-26 01:05:39 +08:00
kingecg ecd906084a chore: update module path to git.kingecg.top 2025-06-25 22:53:39 +08:00
kingecg 0c28c10c1e chore: update module path to git.kingecg.top 2025-06-25 21:56:20 +08:00
kingecg 27fcdad135 fix nil pointer error 2025-06-06 23:30:27 +08:00
kingecg 1004333534 fix 2025-06-06 20:23:06 +08:00
6 changed files with 366 additions and 71 deletions

106
file.go
View File

@ -4,31 +4,37 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// FileAppender implements the LoggerAppender interface for file output.
// It supports log rotation based on file size and age.
// Example:
// appender := &FileAppender{
// formatter: NewSimpleFormatter(),
// filePath: "app.log",
// EnableRolling: true,
// MaxSize: 1024 * 1024 * 10, // 10MB
// }
// appender.Append(event)
// appender.Close()
//
// appender := &FileAppender{
// formatter: NewSimpleFormatter(),
// filePath: "app.log",
// EnableRolling: true,
// MaxSize: 1024 * 1024 * 10, // 10MB
// }
// appender.Append(event)
// appender.Close()
type FileAppender struct {
formatter LogFormatter
filePath string
lchan chan LogEvent
file *os.File
stopChan chan struct{}
mu sync.Mutex
// 新增滚动相关字段
EnableRolling bool
MaxSize int64 // 文件最大大小(字节)
MaxAge int64 // 文件最大时间(秒)
MaxCount int // 最大文件数量
currentFileSize int64 // 当前文件大小
createdAt int64 // 文件创建时间戳
}
@ -63,57 +69,98 @@ func (f *FileAppender) start() {
}
go func() {
defer f.file.Close()
for {
select {
case <-f.stopChan:
f.mu.Lock()
if f.file != nil {
f.file.Close()
}
f.mu.Unlock()
return
case logEvent := <-f.lchan:
f.mu.Lock()
// 检查日志滚动
if f.EnableRolling {
f.checkAndRoll(logEvent)
if f.checkAndRoll(logEvent) {
f.mu.Unlock()
continue
}
}
logMsg := f.formatter(logEvent)
f.file.WriteString(logMsg)
n, err := f.file.WriteString(logMsg)
if err == nil {
f.currentFileSize += int64(n)
}
f.mu.Unlock()
}
}
}()
}
// 新增日志滚动方法
func (f *FileAppender) checkAndRoll(logEvent LogEvent) {
// 新增日志滚动方法,返回是否进行了滚动
func (f *FileAppender) checkAndRoll(logEvent LogEvent) bool {
// 按大小滚动
if f.MaxSize > 0 && f.currentFileSize >= f.MaxSize {
f.rollFile(logEvent)
return
return true
}
// 按时间滚动
if f.MaxAge > 0 && time.Now().Unix()-f.createdAt >= f.MaxAge {
f.rollFile(logEvent)
return true
}
return false
}
// 日志文件滚动
func (f *FileAppender) rollFile(logEvent LogEvent) {
// 关闭当前文件
f.file.Close()
if f.file != nil {
f.file.Close()
}
// 重命名旧文件
timestamp := time.Now().Format("20060102150405")
newPath := f.filePath + "." + timestamp
os.Rename(f.filePath, newPath)
if err := os.Rename(f.filePath, newPath); err != nil {
// 重命名失败,直接创建新文件
f.file, _ = os.OpenFile(f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
return
}
// 创建新文件
f.file, _ = os.OpenFile(f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
var err error
f.file, err = os.OpenFile(f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
// 重置状态
f.currentFileSize = 0
f.createdAt = time.Now().Unix()
// 重新写入当前日志
logMsg := format(logEvent)
f.file.WriteString(logMsg)
logMsg := f.formatter(logEvent)
if _, err := f.file.WriteString(logMsg); err == nil {
f.currentFileSize += int64(len(logMsg))
}
// 清理超过MaxCount的旧文件
if f.MaxCount > 0 {
logDir := filepath.Dir(newPath)
logFileBase := filepath.Base(f.filePath)
files, _ := filepath.Glob(logDir + "/" + logFileBase + ".*")
if len(files) > f.MaxCount {
sort.Slice(files, func(i, j int) bool {
return files[i] < files[j]
})
for i := 0; i < len(files)-f.MaxCount; i++ {
os.Remove(files[i])
}
}
}
}
func (f *FileAppender) Append(logEvent LogEvent) {
@ -122,9 +169,7 @@ func (f *FileAppender) Append(logEvent LogEvent) {
}
func makeFileAppender(appenderConfig LogAppenderConfig) *LoggerAppender {
var logfile interface{}
var ok bool
logfile, ok = appenderConfig.Options["file"]
logfile, ok := appenderConfig.Options["file"]
if !ok {
logfile = "default.log"
}
@ -136,8 +181,8 @@ func makeFileAppender(appenderConfig LogAppenderConfig) *LoggerAppender {
}
maxSize := int64(0)
if size, ok := appenderConfig.Options["maxSize"].(int64); ok {
maxSize = size * 1024 * 1024 // 将兆转换为字节
if size, ok := appenderConfig.Options["maxSize"].(float64); ok {
maxSize = int64(size) * 1024 * 1024 // 将兆转换为字节
} else if sizeStr, ok := appenderConfig.Options["maxSize"].(string); ok {
// 解析带单位的大小配置支持KB, MB, GB
var re = regexp.MustCompile(`(?i)^(\d+)([kmg]b)?$`)
@ -167,16 +212,16 @@ func makeFileAppender(appenderConfig LogAppenderConfig) *LoggerAppender {
re := regexp.MustCompile(`^\d+[hd]?$`)
if re.MatchString(ageStr) {
// 提取数字部分和单位
numStr := ""
var numStr strings.Builder
unit := ""
for _, c := range ageStr {
if c >= '0' && c <= '9' {
numStr += string(c)
numStr.WriteRune(c)
} else {
unit = string(c)
}
}
num, _ := strconv.ParseInt(numStr, 10, 64)
num, _ := strconv.ParseInt(numStr.String(), 10, 64)
switch unit {
case "h":
maxAge = num * 3600 // 小时转秒
@ -187,6 +232,12 @@ func makeFileAppender(appenderConfig LogAppenderConfig) *LoggerAppender {
} else if ageInt, ok := appenderConfig.Options["maxAge"].(int64); ok {
maxAge = ageInt
}
maxCount := int(1)
if count, ok := appenderConfig.Options["maxCount"].(float64); ok {
maxCount = int(count)
} else if count, ok := appenderConfig.Options["maxCount"].(int); ok {
maxCount = count
}
var ret LoggerAppender = &FileAppender{
formatter: SelectFormatter(appenderConfig.Formatter),
@ -194,6 +245,7 @@ func makeFileAppender(appenderConfig LogAppenderConfig) *LoggerAppender {
EnableRolling: rollingEnabled,
MaxSize: maxSize,
MaxAge: maxAge,
MaxCount: maxCount,
}
ret.(*FileAppender).start()

226
file_test.go Normal file
View File

@ -0,0 +1,226 @@
package gologger
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestFileRollingBySize(t *testing.T) {
// 创建临时目录
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "test.log")
config := LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": float64(1), // 1MB触发滚动
},
}
appender := *makeFileAppender(config)
f := appender.(*FileAppender)
defer f.Close()
// 写入小于 MaxSize 的日志,不应触发滚动
smallMsg := LogEvent{
Ts: time.Now(),
Category: "test",
Level: Info,
Data: []interface{}{"small message"},
}
f.Append(smallMsg)
time.Sleep(100 * time.Millisecond)
// 检查文件存在
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatalf("log file should exist: %v", err)
}
// 写入大量日志,触发大小滚动
// 每次写入约100字节需要约10000次才能超过1MB
largeData := strings.Repeat("x", 100)
for i := 0; i < 10500; i++ {
msg := LogEvent{
Ts: time.Now(),
Category: "test",
Level: Info,
Data: []interface{}{largeData},
}
f.Append(msg)
}
time.Sleep(200 * time.Millisecond)
// 检查是否有滚动文件产生
files, _ := filepath.Glob(logFile + ".*")
if len(files) == 0 {
t.Fatalf("expected rolled file to exist, but none found")
}
t.Logf("Rolled files found: %v", files)
}
func TestFileRollingByMaxCount(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "test.log")
config := LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": float64(1), // 1MB
"maxCount": float64(3), // 最多保留3个旧文件
},
}
appender := *makeFileAppender(config)
f := appender.(*FileAppender)
defer f.Close()
// 多次触发滚动,产生多个旧文件
largeData := strings.Repeat("x", 1024)
for round := 0; round < 5; round++ {
for i := 0; i < 10500; i++ {
msg := LogEvent{
Ts: time.Now(),
Category: "test",
Level: Info,
Data: []interface{}{largeData},
}
f.Append(msg)
}
time.Sleep(100 * time.Millisecond)
}
// 等待清理完成
time.Sleep(300 * time.Millisecond)
// 检查旧文件数量不应超过 maxCount
files, _ := filepath.Glob(logFile + ".*")
if len(files) > 3 {
t.Errorf("expected at most %d rolled files, but got %d: %v", 3, len(files), files)
}
t.Logf("Rolled files count: %d, files: %v", len(files), files)
}
func TestFileRollingMaxCountFromJSON(t *testing.T) {
// 模拟 JSON 解析场景
jsonStr := `{
"enableRolling": true,
"file": "logs/test.log",
"maxSize": "50M",
"maxCount": 10
}`
var options map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &options); err != nil {
t.Fatalf("json unmarshal failed: %v", err)
}
// 验证 JSON 解析后的 maxCount 类型
count := options["maxCount"]
t.Logf("maxCount type: %T, value: %v", count, count)
// 测试类型断言
if c, ok := options["maxCount"].(float64); ok {
t.Logf("maxCount as float64: %v -> int: %d", c, int(c))
if int(c) != 10 {
t.Errorf("expected maxCount=10, got %d", int(c))
}
} else if _, ok := options["maxCount"].(int); ok {
t.Log("maxCount is int type")
} else {
t.Errorf("maxCount has unexpected type: %T", count)
}
}
func TestMakeFileAppenderConfig(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "app.log")
tests := []struct {
name string
config LogAppenderConfig
wantRolling bool
wantMaxSize int64
wantMaxCount int
}{
{
name: "string maxSize and maxCount",
config: LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": "10MB",
"maxCount": float64(5),
},
},
wantRolling: true,
wantMaxSize: 10 * 1024 * 1024,
wantMaxCount: 5,
},
{
name: "int maxSize and int maxCount",
config: LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": float64(20),
"maxCount": float64(3),
},
},
wantRolling: true,
wantMaxSize: 20 * 1024 * 1024,
wantMaxCount: 3,
},
{
name: "KB unit",
config: LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": "100KB",
"maxCount": float64(2),
},
},
wantRolling: true,
wantMaxSize: 100 * 1024,
wantMaxCount: 2,
},
{
name: "GB unit",
config: LogAppenderConfig{
Options: map[string]interface{}{
"file": logFile,
"enableRolling": true,
"maxSize": "1GB",
"maxCount": float64(1),
},
},
wantRolling: true,
wantMaxSize: 1 * 1024 * 1024 * 1024,
wantMaxCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
appender := *makeFileAppender(tt.config)
f := appender.(*FileAppender)
defer f.Close()
if f.EnableRolling != tt.wantRolling {
t.Errorf("EnableRolling = %v, want %v", f.EnableRolling, tt.wantRolling)
}
if f.MaxSize != tt.wantMaxSize {
t.Errorf("MaxSize = %v, want %v", f.MaxSize, tt.wantMaxSize)
}
if f.MaxCount != tt.wantMaxCount {
t.Errorf("MaxCount = %v, want %v", f.MaxCount, tt.wantMaxCount)
}
})
}
}

View File

@ -10,26 +10,30 @@ import (
// LogFormatter is a function type that formats a LogEvent into a string.
// Example:
// formatter := func(event LogEvent) string {
// return fmt.Sprintf("[%s] %s: %v", event.Ts.Format("2006-01-02"), event.Level, event.Data)
// }
//
// formatter := func(event LogEvent) string {
// return fmt.Sprintf("[%s] %s: %v", event.Ts.Format("2006-01-02"), event.Level, event.Data)
// }
type LogFormatter = func(LogEvent) string
const logTemplate = "[%s] %s : %s - %s\n"
// format is the default formatter that converts a LogEvent to a string using the default template.
// It handles both simple values and formatted strings.
// Template: "[timestamp] level : category - data"
func format(logEvent LogEvent) string {
data := logEvent.Ts.Format("2006-01-02 15:04:05")
msg := ""
firstMsg := logEvent.Data[0]
if isFormatString(firstMsg) {
msg = fmt.Sprintf(firstMsg.(string), logEvent.Data[1:]...)
} else {
msg = sprint(logEvent.Data)
var msg string
if len(logEvent.Data) > 0 {
firstMsg := logEvent.Data[0]
if isFormatString(firstMsg) {
msg = fmt.Sprintf(firstMsg.(string), logEvent.Data[1:]...)
} else {
msg = sprint(logEvent.Data)
}
}
ret := fmt.Sprintf(logTemplate, data, logEvent.Category, getLogLevelStr(logEvent.Level), msg)
return ret
return fmt.Sprintf(logTemplate, data, logEvent.Category, getLogLevelStr(logEvent.Level), msg)
}
func getLogLevelStr(level int) string {
@ -41,34 +45,35 @@ func getLogLevelStr(level int) string {
return "Unknown"
}
func isFormatString(f interface{}) bool {
func isFormatString(f any) bool {
s, ok := f.(string)
if !ok {
return false
}
// 尝试使用空接口来格式化字符串
m := fmt.Sprintf(s, []interface{}{}...)
return strings.Contains(m, "MISSING")
// 检查是否包含常见格式化动词
return strings.Contains(s, "%s") || strings.Contains(s, "%d") ||
strings.Contains(s, "%v") || strings.Contains(s, "%f") ||
strings.Contains(s, "%x") || strings.Contains(s, "%t") ||
strings.Contains(s, "%c") || strings.Contains(s, "%b") ||
strings.Contains(s, "%e") || strings.Contains(s, "%g")
}
func sprint(s []interface{}) string {
str := make([]any, len(s))
func sprint(s []any) string {
var b strings.Builder
for i, v := range s {
if i > 0 {
str[i] = fmt.Sprintf(" %v", v)
} else {
str[i] = fmt.Sprintf("%v", v)
b.WriteString(" ")
}
fmt.Fprintf(&b, "%v", v)
}
return fmt.Sprint(str...)
return b.String()
}
func jsonFormatter(logEvent LogEvent) string {
_, err := json.Marshal(logEvent.Data)
d, err := json.Marshal(logEvent)
if err != nil {
logEvent.Data = []interface{}{fmt.Sprintf("%v", logEvent.Data)}
return fmt.Sprintf(`{"error":"%v"}`, logEvent.Data)
}
d, _ := json.Marshal(logEvent)
return string(d)
}

2
go.mod
View File

@ -1,3 +1,3 @@
module git.pyer.club/kingecg/gologger
module git.kingecg.top/kingecg/gologger
go 1.19

48
main.go
View File

@ -4,6 +4,7 @@ package gologger
import (
"strings"
"sync"
"time"
)
@ -27,6 +28,7 @@ var logLevelMap map[string]int = map[string]int{
var loggerMap map[string]*Logger = map[string]*Logger{}
var appenderFactoryMap map[string]func(LogAppenderConfig) *LoggerAppender = map[string]func(LogAppenderConfig) *LoggerAppender{}
var appenders map[string]*LoggerAppender = map[string]*LoggerAppender{}
var mu sync.RWMutex
var loggerConfig LoggersConfig = LoggersConfig{
Appenders: map[string]LogAppenderConfig{
"console": {
@ -57,12 +59,14 @@ type LoggersConfig struct {
Appenders map[string]LogAppenderConfig `json:"appenders"`
Categories map[string]LogConfig `json:"categories"`
}
// Logger represents a logger instance for a specific category.
// It maintains the log level and appenders for that category.
// Example:
// logger := GetLogger("mycategory")
// logger.Info("This is an info message")
// logger.Error("This is an error message")
//
// logger := GetLogger("mycategory")
// logger.Info("This is an info message")
// logger.Error("This is an error message")
type Logger struct {
category string
level int
@ -122,19 +126,24 @@ func (l *Logger) Trace(msg ...interface{}) {
l.log(Trace, msg)
}
func GetLogger(name string) *Logger {
mu.RLock()
if logger, ok := loggerMap[name]; ok {
mu.RUnlock()
return logger
} else {
logConfig, ok := loggerConfig.Categories[name]
if ok {
return makeLogger(name, logConfig)
}
logConfig, ok = loggerConfig.Categories["default"]
l := makeLogger(name, logConfig)
l.category = name
loggerMap[name] = l
return l
}
mu.RUnlock()
logConfig, ok := loggerConfig.Categories[name]
if !ok {
logConfig = loggerConfig.Categories["default"]
}
l := makeLogger(name, logConfig)
l.category = name
mu.Lock()
loggerMap[name] = l
mu.Unlock()
return l
}
func makeLogger(name string, config LogConfig) *Logger {
@ -156,14 +165,16 @@ func makeLogger(name string, config LogConfig) *Logger {
func ReconfigLoggers(config LoggersConfig) {
validateConfig(config)
mu.Lock()
defer mu.Unlock()
loggerConfig = config
for name, appenderConfig := range loggerConfig.Appenders {
oldappender, ok := appenders[name]
if ok {
if oldappender, ok := appenders[name]; ok {
(*oldappender).Close()
}
appenderFactory, ok := appenderFactoryMap[appenderConfig.Type]
if ok {
if appenderFactory, ok := appenderFactoryMap[appenderConfig.Type]; ok {
appenders[name] = appenderFactory(appenderConfig)
} else {
appenders[name] = &consoleAppender
@ -172,7 +183,7 @@ func ReconfigLoggers(config LoggersConfig) {
for name, logger := range loggerMap {
logConfig, ok := loggerConfig.Categories[name]
if !ok {
logConfig, _ = loggerConfig.Categories["default"]
logConfig = loggerConfig.Categories["default"]
}
logger.level = logLevelMap[strings.ToLower(logConfig.Level)]
if len(logConfig.Appenders) == 0 {
@ -203,4 +214,5 @@ func RegistAppender(typeName string, appenderCreatCb func(LogAppenderConfig) *Lo
func init() {
loggerMap["default"] = defaultLogger
appenders["console"] = &consoleAppender
}

View File

@ -1,7 +1,7 @@
package main
import (
logger "git.pyer.club/kingecg/gologger"
logger "git.kingecg.top/kingecg/gologger"
)
func aqmain() {