diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d3fcc..053f639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.5.10] - 2026-08-24 +- **默认配置扩展**: + - 新增 `SetDefaultConfig`,允许应用提供内置默认配置,并继续由配置文件和环境变量覆盖。 + - 最终有效配置未变化时复用现有 `DefaultLogger`,避免重复初始化写入资源。 +- **切分文件可靠性**: + - 修复默认 logger 初始化早于 writer service 时写入 nil 文件映射导致的 panic。 + - 文件 logger 初始化时自动创建父目录。 + - split 文件日志在异步 writer 启动前同步落盘,避免启动日志回退到终端。 +- **依赖更新**: 对齐 `cast v1.5.5`、`config v1.5.4`、`file v1.5.6`、`id v1.5.7`、`shell v1.5.5`、`encoding v1.5.6`、`rand v1.5.4`、`safe v1.5.3`。 + ## [1.5.9] - 2026-07-18 - **依赖校验修复**: 重新整理模块 checksum,并对齐 `x/crypto v0.52.0`、`x/sys v0.45.0`。 diff --git a/README.md b/README.md index f4b4db2..628e183 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ import "apigo.cc/go/log" // 默认 logger (通过 log.json 或环境变量配置) func main() { + // 应用内置预设;log.json/log.yml、env 配置和环境变量仍可覆盖 + log.SetDefaultConfig(log.Config{ + File: "logs/app.log", + SplitTag: ".2006-01-02", + }) + // 在微服务场景下动态设置应用名称 log.SetDefaultName("my-microservice") @@ -82,21 +88,26 @@ export LOG_FILE=console * `truncations`: 堆栈信息截断前缀(多个以逗号分隔,默认截断 `github.com/`, `golang.org/`, `/apigo.cc/`)。 * `sensitive`: 需要自动脱敏的字段名(多个以逗号分隔,不区分大小写),默认处理 `phone,password,secret,token,accessToken,authorization`。 +文件输出会在 logger 初始化时自动创建父目录。使用 `splitTag` 时,异步 writer service 启动前产生的日志会同步写入目标文件,启动后自动切换为异步写入。 + ## 🛠 API 指南 ### 核心功能 -1. **分级记录** +1. **应用默认配置** + * `SetDefaultConfig(Config)` —— 设置应用内置预设,然后加载配置文件与环境变量覆盖;最终有效配置未变化时复用当前默认 logger。 + +2. **分级记录** * `Debug`, `Info`, `Warning`, `Error` —— 标准日志方法,支持 `message` + 变长 `extra` 参数。 -2. **摩擦消除 (`As`)** +3. **摩擦消除 (`As`)** * `As(v, err)` —— 仿照 `cast.As`,忽略错误并返回零值,但会自动将错误记录到日志中。支持全局调用 (`log.As`) 或实例调用 (`logger.As`)。 * **优势**: 在类型转换或快速赋值场景下,无需繁琐的 `if err != nil` 判断,同时确保异常被记录。 -3. **通用记录 (`Log`)** +4. **通用记录 (`Log`)** * `Log(LogEntry)` —— 记录自定义结构的日志。 -4. **独立可视化工具 (`logv`)** +5. **独立可视化工具 (`logv`)** * **安装**: `go install apigo.cc/go/log/logv@latest` * **使用**: `tail -f app.log | logv` 或 `tail -f app.log | logv -json`。 diff --git a/TEST.md b/TEST.md index 723bf78..0473d51 100644 --- a/TEST.md +++ b/TEST.md @@ -1,5 +1,21 @@ # Test Results +## v1.5.10 验证 +- 验证 split 文件 logger 在包初始化阶段安全创建,不依赖文件级 `init` 顺序。 +- 验证文件 logger 自动创建父目录。 +- 验证异步 writer 启动前的 split 日志同步写入目标文件。 +- 验证代码默认配置保留未被配置文件覆盖的字段,并由配置文件覆盖已配置字段。 +- 验证最终有效配置不变时 `SetDefaultConfig` 复用现有 logger。 +- 全量单元测试、竞态测试和基准测试均通过。 + +### Benchmark(Apple M3 Max / arm64) + +```text +BenchmarkLoggerInfo-16 877782 1392 ns/op +BenchmarkLoggerAsyncConcurrent-16 4188966 284.4 ns/op +BenchmarkLogger_RequestLog_Realistic-16 973011 1124 ns/op 987 B/op 27 allocs/op +``` + ## v1.5.9 验证 - 独立模块解析、全量测试与基准测试均通过。 @@ -63,3 +79,5 @@ ok apigo.cc/go/log 3.301s - **深度脱敏能力**: `TestDeepDesensitization` 闭环验证了对复杂嵌套结构的脱敏逻辑。 - **可靠性边界**: `TestLoggerReliability` 模拟了极高压力下的日志丢弃与缓冲策略。 - **文件切分**: `TestSplitTag` 实测了基于时间滚动的文件切分能力。 +- **启动期文件写入**: `TestLoggerCore_SplitFileWritesBeforeWriterServiceStarts` 验证异步服务启动前日志直接写入 split 文件。 +- **默认配置覆盖**: `TestLoadDefaultConfig` 与 `TestSetDefaultConfigKeepsLoggerWhenEffectiveConfigIsUnchanged` 验证代码预设、文件覆盖和无变化复用行为。 diff --git a/core_test.go b/core_test.go index 5bf5894..0c13c56 100644 --- a/core_test.go +++ b/core_test.go @@ -1,8 +1,12 @@ package log import ( + "os" + "path/filepath" + "strings" "sync" "testing" + "time" ) func TestLoggerCore_Initialization(t *testing.T) { @@ -18,6 +22,119 @@ func TestLoggerCore_Initialization(t *testing.T) { } } +func TestLoggerCore_SplitFileInitialization(t *testing.T) { + logFile := filepath.Join(t.TempDir(), "logs", "server.log") + logger := NewLogger(Config{ + File: logFile, + SplitTag: "20060102", + }) + + if logger.file == nil { + t.Fatal("expected split file writer to be initialized") + } + if _, err := os.Stat(filepath.Dir(logFile)); err != nil { + t.Fatalf("expected parent directory to be created: %v", err) + } +} + +func TestLoggerCore_SplitFileWritesBeforeWriterServiceStarts(t *testing.T) { + if WriterService.Running.Load() { + t.Skip("writer service already running") + } + + logFile := filepath.Join(t.TempDir(), "logs", "server.log") + logger := NewLogger(Config{ + File: logFile, + SplitTag: "20060102", + }) + logger.Info("before writer service starts") + logger.file.Run() + + data, err := os.ReadFile(logFile + "." + time.Now().Format("20060102")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "before writer service starts") { + t.Fatalf("expected pre-start log to be written to split file, got %q", data) + } +} + +func TestLoadDefaultConfig(t *testing.T) { + oldConfig := defaultConfig + oldWorkingDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + defaultConfig = oldConfig + _ = os.Chdir(oldWorkingDir) + }) + + tempDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tempDir, "log.yml"), []byte("level: warning\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatal(err) + } + + defaultConfig = Config{ + Name: "built-in", + Level: "debug", + File: "logs/server.log", + } + conf := loadDefaultConfig() + + if conf.Name != "built-in" { + t.Fatalf("expected default name to be retained, got %q", conf.Name) + } + if conf.File != "logs/server.log" { + t.Fatalf("expected default file to be retained, got %q", conf.File) + } + if conf.Level != "warning" { + t.Fatalf("expected file config to override level, got %q", conf.Level) + } +} + +func TestSetDefaultConfig(t *testing.T) { + oldConfig := defaultConfig + oldLogger := DefaultLogger + t.Cleanup(func() { + defaultConfig = oldConfig + DefaultLogger = oldLogger + DefaultLogger.RedirectStdLog() + }) + + SetDefaultConfig(Config{Name: "application-default", Level: "debug"}) + + if DefaultLogger.config.Name != "application-default" { + t.Fatalf("expected rebuilt default logger name, got %q", DefaultLogger.config.Name) + } + if DefaultLogger.config.Level != "debug" { + t.Fatalf("expected rebuilt default logger level, got %q", DefaultLogger.config.Level) + } +} + +func TestSetDefaultConfigKeepsLoggerWhenEffectiveConfigIsUnchanged(t *testing.T) { + oldConfig := defaultConfig + oldLogger := DefaultLogger + t.Cleanup(func() { + defaultConfig = oldConfig + DefaultLogger = oldLogger + DefaultLogger.RedirectStdLog() + }) + + defaultConfig = Config{} + DefaultLogger = NewLogger(Config{Name: "configured", Level: "info"}) + currentLogger := DefaultLogger + + SetDefaultConfig(Config{Name: "configured"}) + + if DefaultLogger != currentLogger { + t.Fatal("expected unchanged effective config to keep the existing logger") + } +} + func TestLoggerCore_Concurrency(t *testing.T) { conf := Config{ Name: "concurrent-test", diff --git a/default_logger.go b/default_logger.go index 93cc0f0..d47eb39 100644 --- a/default_logger.go +++ b/default_logger.go @@ -4,18 +4,40 @@ import ( "apigo.cc/go/config" ) -var DefaultLogger *Logger +var ( + defaultConfig Config + DefaultLogger *Logger +) + +func loadDefaultConfig() Config { + conf := defaultConfig + _ = config.Load(&conf, "log") + return conf +} func init() { RegisterWriterMaker("es", newESWriter) RegisterWriterMaker("ess", newESWriter) - var conf Config - _ = config.Load(&conf, "log") + setDefaultLogger(loadDefaultConfig()) +} + +func setDefaultLogger(conf Config) { DefaultLogger = NewLogger(conf) DefaultLogger.RedirectStdLog() } +// SetDefaultConfig sets application defaults, then applies file and environment +// overrides before rebuilding DefaultLogger. +func SetDefaultConfig(conf Config) { + defaultConfig = conf + finalConfig := normalizeConfig(loadDefaultConfig()) + if DefaultLogger != nil && DefaultLogger.config == finalConfig { + return + } + setDefaultLogger(finalConfig) +} + // New 创建带有 traceId 的 Logger 副本 func New(traceId string) *Logger { return DefaultLogger.New(traceId) diff --git a/go.mod b/go.mod index 1874777..96e9457 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,19 @@ module apigo.cc/go/log go 1.25.0 require ( - apigo.cc/go/cast v1.5.3 - apigo.cc/go/config v1.5.3 - apigo.cc/go/file v1.5.5 - apigo.cc/go/id v1.5.4 - apigo.cc/go/shell v1.5.3 + apigo.cc/go/cast v1.5.5 + apigo.cc/go/config v1.5.4 + apigo.cc/go/file v1.5.6 + apigo.cc/go/id v1.5.7 + apigo.cc/go/shell v1.5.5 ) require apigo.cc/go/jsmod v1.5.3 // indirect require ( - apigo.cc/go/encoding v1.5.4 // indirect - apigo.cc/go/rand v1.5.3 // indirect - apigo.cc/go/safe v1.5.2 // indirect + apigo.cc/go/encoding v1.5.6 // indirect + apigo.cc/go/rand v1.5.4 // indirect + apigo.cc/go/safe v1.5.3 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/sys v0.45.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 6dd227f..fbddea6 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,21 @@ -apigo.cc/go/cast v1.5.3 h1:jk6VX0rGFhjKtfPhsaV6IKYpiGmORRk9qPTtuNS53tw= -apigo.cc/go/cast v1.5.3/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= -apigo.cc/go/config v1.5.3 h1:peq1FM2xO+vzPHJf8Dwg3DXm8PtFQMfTFKQj6fpoG7A= -apigo.cc/go/config v1.5.3/go.mod h1:ZiOAjWa1mQIzszaJZN+kO6YU4GXreng+NxkcK/TAkqQ= -apigo.cc/go/encoding v1.5.4 h1:Fk8TrveZATyy8SHukC4ZiqdTSp+QIfsRHtt55xmMK7w= -apigo.cc/go/encoding v1.5.4/go.mod h1:dShEsZ3gKqBINz7TSOYf4e7/fBCqCY9VzlenoGUQUFM= -apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs= -apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo= -apigo.cc/go/id v1.5.4 h1:D1Zx9gEZhOgdTgZ4SdmPImhpc9xGiOA33Y+j2MkstzQ= -apigo.cc/go/id v1.5.4/go.mod h1:hCTQq+KC1ALWe1FpPERf+W4B6FSulg9FAgOUJDDySiY= +apigo.cc/go/cast v1.5.5 h1:DMbfK3uPhPjRaXutj3StIZIkfjFIATSXfuAOeNOd4Fw= +apigo.cc/go/cast v1.5.5/go.mod h1:GMjjrYn93tWat1U409G7h1jR3ejfLLI7r0efBo9Sbd4= +apigo.cc/go/config v1.5.4 h1:1c/OarGwbz3+6ikurE+a6LJLjtzXvGvbxw3HV/Nk54M= +apigo.cc/go/config v1.5.4/go.mod h1:oN+D2F8ETIyqKp+Yu8R4PRQlUoqR44o35jAHwLrrAq0= +apigo.cc/go/encoding v1.5.6 h1:v02swVfbFGidD4QcX2ktuHHbCjdSbOB85fhzAXay+7M= +apigo.cc/go/encoding v1.5.6/go.mod h1:Big9q1Zwy4071dXtnrQ3SJDzfa/G7/A60KE/5+M//P8= +apigo.cc/go/file v1.5.6 h1:Y7w3Tyu4e16VuED7rF2pzba+dzGE+hjDnBlIPVHIfzA= +apigo.cc/go/file v1.5.6/go.mod h1:9sdW4ylSOA0HWc8Yt8qdnmMf6nn5SUEmjoPKyXpYXIQ= +apigo.cc/go/id v1.5.7 h1:Y5Sx6sQBCAdYMCQPTjODZyGMMd1+WRWCy2dWHVq11XQ= +apigo.cc/go/id v1.5.7/go.mod h1:fugudFBqfVNakfm91zZzuzU0P4PULzo6sylB8hRMqxA= apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU= apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw= -apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius= -apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= -apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg= -apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw= -apigo.cc/go/shell v1.5.3 h1:pI+u12sy6upoygq+1XXqUlvUboBfH4Q52jRpoJFv56A= -apigo.cc/go/shell v1.5.3/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= +apigo.cc/go/rand v1.5.4 h1:eessFBsKQuoOYdzrStldOGw9f4HqbeO87X95bI4jIBQ= +apigo.cc/go/rand v1.5.4/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4= +apigo.cc/go/safe v1.5.3 h1:9p/BmdlVWLbekpKByZIFC09Qn8Wdhik2eINiwunBxPs= +apigo.cc/go/safe v1.5.3/go.mod h1:Ay8kEPL76DeXH4ifsVTc/3/sfGHlWLQjAp4vi7GA9AI= +apigo.cc/go/shell v1.5.5 h1:sf1QZiL7IHkoOLdrYX1qT4JWIlYZIAAYGJJXorjscB4= +apigo.cc/go/shell v1.5.5/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/logger.go b/logger.go index 9c9804c..1a291f9 100644 --- a/logger.go +++ b/logger.go @@ -10,6 +10,7 @@ import ( "time" "apigo.cc/go/cast" + "apigo.cc/go/file" "apigo.cc/go/id" ) @@ -83,7 +84,7 @@ func RegisterWriterMaker(name string, f func(*Config) Writer) { writerMakers[name] = f } -func NewLogger(conf Config) *Logger { +func normalizeConfig(conf Config) Config { if conf.Level == "" { conf.Level = "info" } @@ -97,6 +98,11 @@ func NewLogger(conf Config) *Logger { if conf.Name == "" { conf.Name = getDefaultName() } + return conf +} + +func NewLogger(conf Config) *Logger { + conf = normalizeConfig(conf) logger := Logger{ truncations: cast.Split(conf.Truncations, ","), @@ -138,6 +144,7 @@ func NewLogger(conf Config) *Logger { } } } else { + file.EnsureParentDir(conf.File) if conf.SplitTag != "" { WriterService.FilesLock.RLock() logger.file = WriterService.Files[conf.File+conf.SplitTag] @@ -187,7 +194,14 @@ func (logger *Logger) writeBuf(entry LogEntry, buf []byte) { if logger.writer != nil { logger.writer.Log(entry, buf) } else if logger.file != nil { - fmt.Println(Viewable(string(buf))) + WriterService.FilesLock.RLock() + if WriterService.Running.Load() { + WriterService.FilesLock.RUnlock() + writeAsync(logPayload{entry: entry, buf: buf, file: logger.file}) + return + } + logger.file.Write(time.Now(), buf) + WriterService.FilesLock.RUnlock() } else if logger.goLogger == nil { fmt.Println(Viewable(string(buf))) } else { diff --git a/writer.go b/writer.go index fb63f91..d883326 100644 --- a/writer.go +++ b/writer.go @@ -37,9 +37,18 @@ type writerService struct { var ( // WriterService is the global instance of defaultService. - WriterService = &writerService{} + WriterService = newWriterService() ) +func newWriterService() *writerService { + service := &writerService{ + LogChannel: make(chan logPayload, 10000), + Files: make(map[string]*FileWriter), + } + service.Writers.Store([]Writer{}) + return service +} + // ConsoleWriter 控制台写入器 type ConsoleWriter struct { } @@ -52,10 +61,6 @@ func (w *ConsoleWriter) Run() { } func init() { - WriterService.LogChannel = make(chan logPayload, 10000) - WriterService.Writers.Store([]Writer{}) - WriterService.Files = make(map[string]*FileWriter) - RegisterWriterMaker("console", func(conf *Config) Writer { return &ConsoleWriter{} })