Go log/slog 结构化日志深度使用:Handler、Attr 与自定义日志级别

本文深入解析 Go log/slog 的 Handler、Attr 与自定义日志级别机制,对比 JSONHandler 与 TextHandler,结合自定义 Handler 示例和常见误区,帮助团队设计可扩展的结构化日志方案,适合正在从标准库 log 迁移到 slog 的 Go 服务。

从标准库 log 到 slog:问题不是格式,而是日志的可编程性

如果你把一个 Go 服务的日志从 log.Printf 换成 slog.Info,第一反应大概率是:输出看起来没什么区别。但如果只是这么浅尝辄止,你会错过 slog 真正有价值的部分——它把日志从“格式化输出”提升到了“可编程的数据流”。

Go log/slog 结构化日志深度使用:Handler、Attr 与自定义日志级别

很多团队会遇到类似的问题:服务一多,日志格式各写各的。有的打印 JSON 字符串,有的用 tab 拼接,日志平台解析规则写了一堆正则,最后还是对不上。实际上这并不只是格式不统一的问题,而是日志从一开始就没有一个结构化的承载模型。log/slog 想要解决的,就是这个承载模型的问题。

先分清四个名词:Logger、Handler、Record 与 Attr

要理解 slog,需要抓住四个核心对象。Logger 是日志门面,业务代码几乎只跟它打交道;Handler 负责把日志事件序列化并输出;Record 是一次日志事件的完整描述,包含时间、级别、消息与属性;Attr 是构成结构化字段的最小单元,本质上是一个 key-value 对。

很多人使用 slog 时一直在调 Logger 的方法,却很少想 Handler 做了什么。这种“门面与实现分离”的设计,恰恰是 slog 可以被深度定制的基础。

Handler 接口决定了你的日志能走多远

slog 默认提供了 JSONHandlerTextHandler。前者适合被日志平台消费,后者适合本地阅读。但如果你需要脱敏、字段追加、自动附加堆栈、按环境路由,就必须自己实现或包装 Handler。

Handler 接口很小,只有四个方法:

type Handler interface {
    Enabled(ctx context.Context, level slog.Level) bool
    Handle(ctx context.Context, r slog.Record) error
    WithAttrs(attrs []slog.Attr) slog.Handler
    WithGroup(name string) slog.Handler
}

简单解释一下每个方法的作用。

Enabled 决定某个级别是否应该被处理,这是级别过滤的第一道关卡。Handle 做真正的输出。WithAttrsWithGroup 是给派生 Logger 用的,它们决定了由 Logger.With 或 Logger.WithGroup 产生的属性如何向下传递。

这四个方法相互配合,意味着 Handler 不能只是简单实现一个 JSON 序列化器,还要正确维护“从 Logger 那里继承的上下文”,否则 logger.With("request_id", id) 这种调用就会失效。

下面是一个给 Warn 以上级别自动追加堆栈的自定义 Handler 示例:

type stackHandler struct {
    inner slog.Handler
}

func (h *stackHandler) Handle(ctx context.Context, r slog.Record) error {
    if r.Level >= slog.LevelWarn {
        r.AddAttrs(slog.String("stack", string(debug.Stack())))
    }
    return h.inner.Handle(ctx, r)
}

func (h *stackHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.inner.Enabled(ctx, level)
}

func (h *stackHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &stackHandler{inner: h.inner.WithAttrs(attrs)}
}

func (h *stackHandler) WithGroup(name string) slog.Handler {
    return &stackHandler{inner: h.inner.WithGroup(name)}
}

这样,错误日志就会自动带上调用堆栈,而不需要在每个业务函数里手动调用 debug.Stack()。如果之后想要做脱敏,也可以沿着同样的思路,在 Handle 中改掉某些字段后再交给内部 Handler。

这里特别容易让人迷惑的是 WithAttrsWithGroup。它们在 Handler 接口里返回新的 Handler,而不是修改原实例。也就是说,每次调用 logger.With(...) 都会生成一条新的 Handler 链。如果你误以为 With 是原地变更,很容易在并发场景下踩坑——多个 goroutine 共享同一个 Logger 时,各自 With 出来的属性不能互相污染。slog 用不可变的方式绕开了这个问题,代价是属性列表的拷贝开销需要自己权衡。

Attr:分组、继承与性能

Attr 看起来只是 slog.Attr{Key: "user_id", Value: slog.Int64Value(123)},但实际使用中有几个细节非常容易错。

第一个是分组。slog.Group 可以让多个字段在 JSON 输出时嵌套为一个对象,例如:

slog.Info("request done",
    slog.String("request_id", "abc123"),
    slog.Group("metrics",
        slog.Duration("duration", duration),
        slog.Int("read_bytes", rb),
    ),
)

输出会变成 {"request_id":"abc123","metrics":{"duration":"1ms","read_bytes":123}}。这种嵌套在做链路追踪和指标分析时非常有用。

第二个是 WithAttrs 的继承顺序。每次调用 logger.With(attrs...),新的 Handler 会在原有 Handler 上叠加 Attrs。要注意的是,WithGroup 也会改变这些 Attrs 的展示路径。

第三个是性能。slog 的 API 设计是鼓励“开箱即分配”的,频繁调用 logger.With 并传递大量属性会带来额外开销。在热点路径上,可以尽量把固定属性持续放在一个 Logger 实例上,动态属性放在单次调用中。

不过这些都属于优化层面的问题。真正影响可维护性的,往往是结构化格式的混乱。

自定义日志级别:突破默认的四档

slog 的默认级别是 Debug、Info、Warn、Error,数值分别为 -4、0、4、8。但这个粒度对复杂系统往往不够。比如你希望有一个比 Debug 更细的 TRACE 级别,用于本地排查细节;或者希望有一个 AUDIT 级别,用于审计日志,比 Error 更严格。

好消息是 slog 的自定义级别非常自然,因为 slog.Level 本质上就是 int 类型。你可以在常量里定义新级别:

const (
    LevelTrace slog.Level = slog.LevelDebug - 4
    LevelAudit slog.Level = slog.LevelInfo + 4
)

然后通过 HandlerOptions 启用:

opts := &slog.HandlerOptions{Level: LevelTrace}
logger := slog.New(opts.NewJSONHandler(os.Stdout))

不过这里有个容易忽略的点:自定义级别不仅是可以调用 logger.Log(context.Background(), LevelTrace, "msg"),还需要 Handler 的 Enabled 方法正确配合。默认的 Handler 会拿启动时配置的 Level 和当前级别比较,小于设定值的级别会被丢弃。如果你想要“某些级别只输出到特定地方”,只改 Logger 行为是做不彻底的,还是要回到 Handler。

如果希望日志级别能在运行时动态变化,可以定义一个实现 slog.Leveler 接口的类型,例如按环境变量取值:

type envLevel struct{}

func (envLevel) Level() slog.Level {
    if os.Getenv("LOG_LEVEL") == "debug" {
        return slog.LevelDebug
    }
    return slog.LevelInfo
}

把它丢给 HandlerOptions.Level 字段即可。这个能力在标准库 log 里是完全不存在的。

方案对比:你不必完全自研 Handler

在决定是否要自定义 Handler 之前,先看清楚默认方案能覆盖哪些场景。下面是一个粗略对比:

方案 输出格式 结构化支持 适用场景 注意事项
标准库 log 纯文本拼接 工具脚本、临时日志 无法被日志平台高效解析
JSONHandler JSON 单行 生产环境、日志平台采集 默认不会自动附加调用来源
TextHandler key=value 文本 本地开发、调试 字段路径查询不方便
自定义 Handler 自定义 完全可控 脱敏、堆栈、按环境路由、审计 需要自己维护 Handler 生命周期

这个表想说明的是:自定义 Handler 不是银弹,它应该用来解决默认方案做不到的事,而不是因为“不想用 JSON”就去重新造轮子。

绕不开的几个坑

如果你们正在往 slog 迁移,有五个常见坑值得提前知道。

  • 把普通 logger 当全局状态到处传。 结果上下文属性在调用链里丢失,最后日志里只剩一条孤零零的消息。
  • 过于依赖 With 在请求热点中频繁创建派生 Logger,会让 GC 压力明显上升。
  • 混淆 GroupWithGroup 前者在单条日志内分组,后者改变派生 Logger 在整个生命周期内附加属性的路径。
  • 自定义级别时只改数值,不检查 Enabled 逻辑。 结果日志确实调用了,但被 Handler 过滤掉,看起来像“丢失日志”。
  • 忘记给 Handler 配置 ReplaceAttr 实际上 HandlerOptions.ReplaceAttr 可以统一替换或删除某些属性,很多格式化需求根本不需要自定义 Handler。

其中最后一条经常被忽略。比如你不想看到日志里的 time 字段,或者想把 msg 改名,ReplaceAttr 足以完成,完全不必动 Handler 接口。只有需要修改 Record 本身时,才值得包装 Handler。

实践建议:从一条日志到一个规范

落地 slog 不一定要一次性推倒重来。比较稳妥的方式是从新模块开始,用 slog 的 JSONHandler 作为基线,通过 HandlerOptions 设置统一的服务名和环境字段。随后,把那些对日志格式有硬性要求的模块,渐进式切到自定义 Handler。

在接口层尽量做到“logger 即依赖”。在构造业务对象时通过依赖注入传入 Logger,而不是在包级变量里绑一个全局 logger。这样测试时用内存 Handler 收集日志,断言也很方便。

如果业务里日志场景复杂,可以建立一个日志字段规范,把 request_id、user_id、error_code 这类字段统一定义。这样在不同服务切换的时候,日志平台里的字段路径才稳定。

最后再回到标题里的三个名词。Handler 决定了日志的边界,Attr 决定了日志的信息结构,自定义日志级别决定了哪些信息值得记录。 把这三件事想清楚,slog 在你手里就不再只是新 API,而是整套日志架构的底座。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/782/

(0)
上一篇 4小时前
下一篇 4小时前

相关推荐