From 575de9039879fd3c4ee2eb56162b7f1fa443a0a6 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 3 Apr 2026 10:11:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=E5=A2=9E=E5=BC=BA=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=94=AF=E6=8C=81=E8=AE=BF?= =?UTF-8?q?=E9=97=AE/=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97=E5=88=86?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Logger 结构体,分离访问日志和错误日志 - 支持 stdout/stderr/文件输出目标 - 实现 nginx 格式变量日志记录 - 添加日志文件关闭方法 - 修复 parseLevel 大小写处理 - 新增完整单元测试覆盖 Co-Authored-By: Claude Opus 4.6 --- internal/logging/logging.go | 96 +++++++++++++++++++++-- internal/logging/logging_test.go | 129 ++++++++++++++++++++++++++++++- 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index c9fdb63..499be14 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,16 +1,27 @@ package logging import ( + "io" "os" + "strings" "time" "github.com/rs/zerolog" "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" ) +// Logger 日志管理器,分离访问日志和错误日志。 +type Logger struct { + accessLog zerolog.Logger + errorLog zerolog.Logger + accessFile *os.File + errorFile *os.File +} + var log zerolog.Logger -// Init 初始化日志系统 +// Init 初始化日志系统(兼容旧接口)。 func Init(level string, pretty bool) { l := parseLevel(level) if pretty { @@ -20,7 +31,38 @@ func Init(level string, pretty bool) { } } -// LogAccess 记录访问日志 +// New 创建日志管理器,支持访问/错误日志分离。 +func New(cfg *config.LoggingConfig) *Logger { + if cfg == nil { + cfg = &config.LoggingConfig{} + } + + logger := &Logger{ + accessLog: zerolog.New(getOutput(cfg.Access.Path)).With().Timestamp().Logger(), + errorLog: zerolog.New(getOutput(cfg.Error.Path)).Level(parseLevel(cfg.Error.Level)).With().Timestamp().Logger(), + } + + return logger +} + +// getOutput 获取输出目标(stdout/stderr/文件)。 +func getOutput(path string) io.Writer { + path = strings.TrimSpace(path) + if path == "" || path == "stdout" { + return os.Stdout + } + if path == "stderr" { + return os.Stderr + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return os.Stdout + } + return f +} + +// LogAccess 记录访问日志。 func LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) { log.Info(). Str("method", string(ctx.Method())). @@ -32,14 +74,58 @@ func LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.D Msg("request") } -// Error 返回 Error 级别日志记录器 +// LogAccessWithLogger 使用 Logger 实例记录访问日志(nginx 格式变量)。 +func (l *Logger) LogAccess(ctx *fasthttp.RequestCtx, status int, size int64, duration time.Duration) { + l.accessLog.Info(). + Str("remote_addr", ctx.RemoteAddr().String()). + Str("request", string(ctx.Method())+" "+string(ctx.Path())). + Int("status", status). + Int64("body_bytes_sent", size). + Dur("request_time", duration). + Str("http_referrer", string(ctx.Request.Header.Peek("Referer"))). + Str("http_user_agent", string(ctx.Request.Header.Peek("User-Agent"))). + Msg("") +} + +// Debug 返回 Debug 级别日志记录器。 +func (l *Logger) Debug() *zerolog.Event { + return l.errorLog.Debug() +} + +// Info 返回 Info 级别日志记录器。 +func (l *Logger) Info() *zerolog.Event { + return l.errorLog.Info() +} + +// Warn 返回 Warn 级别日志记录器。 +func (l *Logger) Warn() *zerolog.Event { + return l.errorLog.Warn() +} + +// Error 返回 Error 级别日志记录器。 +func (l *Logger) Error() *zerolog.Event { + return l.errorLog.Error() +} + +// Close 关闭日志文件。 +func (l *Logger) Close() error { + if l.accessFile != nil { + l.accessFile.Close() + } + if l.errorFile != nil { + l.errorFile.Close() + } + return nil +} + +// Error 返回 Error 级别日志记录器(全局实例)。 func Error() *zerolog.Event { return log.Error() } -// parseLevel 解析日志级别 +// parseLevel 解析日志级别。 func parseLevel(level string) zerolog.Level { - switch level { + switch strings.ToLower(level) { case "debug": return zerolog.DebugLevel case "info": diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index cadc502..52bdd39 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -1,9 +1,17 @@ package logging import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/rs/zerolog" + "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" ) func TestParseLevel(t *testing.T) { @@ -43,9 +51,9 @@ func TestParseLevel(t *testing.T) { expected: zerolog.InfoLevel, }, { - name: "uppercase DEBUG is case sensitive", + name: "uppercase DEBUG now works", input: "DEBUG", - expected: zerolog.InfoLevel, + expected: zerolog.DebugLevel, }, } @@ -57,4 +65,121 @@ func TestParseLevel(t *testing.T) { } }) } +} + +func TestNewLogger(t *testing.T) { + tests := []struct { + name string + cfg *config.LoggingConfig + }{ + {"nil config", nil}, + {"empty paths", &config.LoggingConfig{}}, + {"with access format", &config.LoggingConfig{Access: config.AccessLogConfig{Format: "json"}}}, + {"with error level", &config.LoggingConfig{Error: config.ErrorLogConfig{Level: "debug"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := New(tt.cfg) + if logger == nil { + t.Error("Expected non-nil Logger") + } + }) + } +} + +func TestLoggerWithFile(t *testing.T) { + tmpDir := t.TempDir() + accessPath := filepath.Join(tmpDir, "access.log") + errorPath := filepath.Join(tmpDir, "error.log") + + cfg := &config.LoggingConfig{ + Access: config.AccessLogConfig{Path: accessPath, Format: "json"}, + Error: config.ErrorLogConfig{Path: errorPath, Level: "info"}, + } + + logger := New(cfg) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/test") + ctx.Request.Header.SetMethod("GET") + + logger.LogAccess(ctx, 200, 10, 100*time.Millisecond) + logger.Error().Str("test", "value").Msg("test error") + logger.Close() + + if _, err := os.Stat(accessPath); os.IsNotExist(err) { + t.Error("Expected access log file to be created") + } +} + +func TestGetOutput(t *testing.T) { + if getOutput("") != os.Stdout { + t.Error("Expected stdout for empty path") + } + if getOutput("stderr") != os.Stderr { + t.Error("Expected stderr for 'stderr' path") + } + + tmpFile := filepath.Join(t.TempDir(), "test.log") + out := getOutput(tmpFile) + if out == nil { + t.Error("Expected non-nil writer for file path") + } +} + +func TestLoggerNginxFormat(t *testing.T) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + logger := New(&config.LoggingConfig{Access: config.AccessLogConfig{Format: "json"}}) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/users?id=123") + ctx.Request.Header.SetMethod("POST") + ctx.Request.Header.Set("User-Agent", "test-agent") + ctx.Request.Header.Set("Referer", "http://example.com/") + + logger.LogAccess(ctx, 201, 512, 250*time.Millisecond) + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + + output := buf.String() + expectedFields := []string{"request", "status", "body_bytes_sent", "request_time", "remote_addr"} + + for _, field := range expectedFields { + if !strings.Contains(output, field) { + t.Errorf("Expected output to contain '%s'", field) + } + } +} + +func TestLoggerDebug(t *testing.T) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + logger := New(&config.LoggingConfig{Error: config.ErrorLogConfig{Level: "debug"}}) + + logger.Debug().Msg("debug message") + logger.Info().Msg("info message") + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + io.Copy(&buf, r) + + output := buf.String() + if !strings.Contains(output, "debug message") { + t.Error("Expected debug message to be logged") + } + if !strings.Contains(output, "info message") { + t.Error("Expected info message to be logged") + } } \ No newline at end of file