From b4933a6fd1fbc7f1024bcd6d42168a1ed5486643 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 13:53:02 +0800 Subject: [PATCH] feat(logger): slog-based logger with JSON and text handlers --- internal/infra/logger/logger.go | 63 ++++++++++++++++++++++++++++ internal/infra/logger/logger_test.go | 59 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 internal/infra/logger/logger.go create mode 100644 internal/infra/logger/logger_test.go diff --git a/internal/infra/logger/logger.go b/internal/infra/logger/logger.go new file mode 100644 index 0000000..e0da03c --- /dev/null +++ b/internal/infra/logger/logger.go @@ -0,0 +1,63 @@ +// Package logger 提供基于标准库 log/slog 的结构化日志器封装: +// 统一的 Format / Level 选项,以及按环境区分默认值的 FromEnv 便利函数。 +package logger + +import ( + "io" + "log/slog" + "os" +) + +// Format 表示日志输出格式。 +type Format string + +// 支持的输出格式。 +const ( + FormatJSON Format = "json" + FormatText Format = "text" +) + +// Level 是 slog.Level 的类型别名,方便外部直接使用本包常量而无需引入 log/slog。 +type Level = slog.Level + +// 常用日志级别(与 slog 同名常量等价)。 +const ( + LevelDebug = slog.LevelDebug + LevelInfo = slog.LevelInfo + LevelWarn = slog.LevelWarn + LevelError = slog.LevelError +) + +// Options 控制日志器的输出格式、最低级别与目标 Writer。 +// Writer 留空时默认写入 os.Stdout。 +type Options struct { + Format Format + Level Level + Writer io.Writer +} + +// New 按 Options 构造一个 *slog.Logger。Format 非 FormatText 时一律使用 JSON handler。 +func New(opt Options) *slog.Logger { + w := opt.Writer + if w == nil { + w = os.Stdout + } + hopts := &slog.HandlerOptions{Level: opt.Level, AddSource: false} + var h slog.Handler + switch opt.Format { + case FormatText: + h = slog.NewTextHandler(w, hopts) + default: + h = slog.NewJSONHandler(w, hopts) + } + return slog.New(h) +} + +// FromEnv 根据环境名返回合适默认: +// development → text + debug;其他 → json + info。 +func FromEnv(env string, w io.Writer) *slog.Logger { + if env == "development" { + return New(Options{Format: FormatText, Level: LevelDebug, Writer: w}) + } + return New(Options{Format: FormatJSON, Level: LevelInfo, Writer: w}) +} diff --git a/internal/infra/logger/logger_test.go b/internal/infra/logger/logger_test.go new file mode 100644 index 0000000..8b46553 --- /dev/null +++ b/internal/infra/logger/logger_test.go @@ -0,0 +1,59 @@ +package logger + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNew_JSONHandler_WritesStructured(t *testing.T) { + var buf bytes.Buffer + log := New(Options{Format: FormatJSON, Level: LevelInfo, Writer: &buf}) + log.Info("hello", "key", "value") + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + require.Equal(t, "hello", got["msg"]) + require.Equal(t, "value", got["key"]) + require.Equal(t, "INFO", got["level"]) +} + +func TestNew_TextHandler_HumanReadable(t *testing.T) { + var buf bytes.Buffer + log := New(Options{Format: FormatText, Level: LevelDebug, Writer: &buf}) + log.Debug("dev message", "x", 1) + require.Contains(t, buf.String(), "dev message") +} + +func TestNew_LevelFilter(t *testing.T) { + var buf bytes.Buffer + log := New(Options{Format: FormatJSON, Level: LevelWarn, Writer: &buf}) + log.Info("should be filtered") + require.Empty(t, strings.TrimSpace(buf.String())) +} + +func TestFromEnv_DevelopmentUsesText(t *testing.T) { + var buf bytes.Buffer + log := FromEnv("development", &buf) + log.Debug("dev", "k", "v") + out := buf.String() + require.Contains(t, out, "dev") + // text handler outputs key=value pairs (not JSON braces) + require.NotContains(t, out, `"msg":`) +} + +func TestFromEnv_NonDevelopmentUsesJSON(t *testing.T) { + var buf bytes.Buffer + log := FromEnv("production", &buf) + // Debug should be filtered (JSON default level is Info) + log.Debug("filtered") + require.Empty(t, strings.TrimSpace(buf.String())) + + log.Info("prod") + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + require.Equal(t, "prod", got["msg"]) +}