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

很多团队会遇到类似的问题:服务一多,日志格式各写各的。有的打印 JSON 字符串,有的用 tab 拼接,日志平台解析规则写了一堆正则,最后还是对不上。实际上这并不只是格式不统一的问题,而是日志从一开始就没有一个结构化的承载模型。log/slog 想要解决的,就是这个承载模型的问题。
先分清四个名词:Logger、Handler、Record 与 Attr
要理解 slog,需要抓住四个核心对象。Logger 是日志门面,业务代码几乎只跟它打交道;Handler 负责把日志事件序列化并输出;Record 是一次日志事件的完整描述,包含时间、级别、消息与属性;Attr 是构成结构化字段的最小单元,本质上是一个 key-value 对。
很多人使用 slog 时一直在调 Logger 的方法,却很少想 Handler 做了什么。这种“门面与实现分离”的设计,恰恰是 slog 可以被深度定制的基础。
Handler 接口决定了你的日志能走多远
slog 默认提供了 JSONHandler 和 TextHandler。前者适合被日志平台消费,后者适合本地阅读。但如果你需要脱敏、字段追加、自动附加堆栈、按环境路由,就必须自己实现或包装 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 做真正的输出。WithAttrs 和 WithGroup 是给派生 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。
这里特别容易让人迷惑的是 WithAttrs 和 WithGroup。它们在 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 压力明显上升。 - 混淆
Group和WithGroup。 前者在单条日志内分组,后者改变派生 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/