Go 基础体系 · 第 34/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 结构化日志 slog:从 Record 到 Handler 工程化
本文以 Go 1.26.4 为基准。log/slog 把日志建模为时间、级别、消息、源码位置和属性,再由 Handler 决定启用、补充上下文和编码。它负责稳定记录事件;字段设计、敏感治理、写入故障和采样仍由应用负责。
本文聚焦标准库 slog 的语义与实现边界。HTTP 请求生命周期、context 取消、tracing、metrics 和第三方高性能日志库属于相邻主题。日志可以携带 request/trace ID,却不能代替链路和指标;下面会完整解释 slog 自身,而不会靠外部链接补正文。
1. 一条 slog Record 包含什么
调用 logger.InfoContext(ctx, "article published", attrs...) 时,Logger 先判断该级别是否启用;启用后创建 slog.Record 并交给 Handler。Record 包含 Time、Level、Message、可选 PC 和属性。属性的顺序会保留,但业务不能把 JSON 字段顺序当接口契约。
logger.InfoContext(ctx, "article published",
"article_id", "a-42",
"duration", 18*time.Millisecond,
"attempt", 1,
)
字符串键值写法简洁,但参数数量为奇数或键不是字符串时会生成 !BADKEY 属性,通常说明调用错误。关键路径更适合 slog.String、slog.Int、slog.Duration 等强类型 Attr,静态分析也更容易检查。
消息描述稳定事件,例如 "article publish failed";变化值放字段,不把 ID 拼入消息。这样查询可以按消息和字段聚合,避免每个对象产生一个新模板。
2. Level 是有序数值,Enabled 应尽早过滤
内置级别数值依次为 Debug(-4)、Info(0)、Warn(4)、Error(8),级别越高越严重。Handler.Enabled(ctx, level) 在构造 Record 和求值昂贵属性前调用,是控制成本的第一道门。
var level slog.LevelVar
level.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &level})
logger := slog.New(handler)
level.Set(slog.LevelDebug) // 可由受控配置动态调整
HandlerOptions.Level 默认是 Info;它接收 slog.Leveler,LevelVar 可并发读取和更新。动态 Debug 应有期限和权限控制,否则高量细节会长期增加成本或泄漏数据。
级别约定要跨服务统一:Debug 是临时诊断,Info 是正常且有运营价值的事件,Warn 是已恢复或逼近边界的异常,Error 是当前操作失败。Error 不等于进程退出;真正无法继续的启动错误通常由 main 返回并设置退出码。
3. TextHandler 与 JSONHandler 的输出契约
slog.NewTextHandler 输出适合终端阅读的 key=value,值会按需要引用;NewJSONHandler 输出每条一行 JSON,适合日志采集器。二者默认加入 time、level、msg,启用 AddSource 后再加入 source。
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
})
JSON 值如何表示由 Attr 的 Value 决定:时间和 duration 有标准编码,错误通常变成其字符串,任意值可能经过 encoding/json。无法编码的值会以错误信息表示,而不是让日志调用返回 error。日志 API 不向调用者报告底层 Writer 错误,因此关键审计记录不能只依赖普通 slog 输出。
每条 JSON 应保持单行。消息中的换行会被 JSON 转义,但 TextHandler 输出也会处理引用。不要预先手工 JSON 编码对象再作为字符串字段,否则查询端只能看到嵌套字符串。
4. Attr 与 Value 避免无意义的 any
slog.Attr 是键和值,slog.Value 有 Bool、Int64、Uint64、Float64、String、Time、Duration、Group 和 Any 等种类。专用构造函数更明确,也通常避免反射:
logger.Info("request complete",
slog.String("method", "GET"),
slog.Int("status", 200),
slog.Duration("duration", elapsed),
slog.Time("deadline", deadline),
)
slog.Any 适合确实需要动态值的边界,但把整个 Request、用户对象或配置结构直接记录会带来不稳定 schema、大量分配、循环引用错误和敏感字段泄漏。日志字段应是经过选择的诊断事实。
零值 slog.Attr{} 的 key 为空且 Value 为空,内置 Handler 会忽略。空 key 配合 Group 值有展开语义,自定义 Handler 必须按 Handler 契约处理,不能简单把每个 Attr 当普通键值。
5. With 与 WithGroup 怎样建立稳定上下文
logger.With(attrs...) 返回派生 Logger,后续记录都带这些属性,适合服务名、组件和一次请求的稳定 ID。原 Logger 不变,Logger 可被多 goroutine 安全共享。
componentLog := logger.With(
slog.String("service", "article-api"),
slog.String("component", "publisher"),
)
componentLog.Info("worker started")
WithGroup("http") 把后续属性放进命名组,JSONHandler 会产生嵌套对象,TextHandler 常用点号键。组用于避免通用键冲突和表达局部结构:
logger.Info("request complete",
slog.Group("http",
slog.String("method", "GET"),
slog.Int("status", 200),
),
)
组结构是日志 schema 的一部分,不要在同一服务中时而用 status、时而用 http.status。
6. LogValuer 提供延迟且可治理的表示
类型实现 LogValue() slog.Value 后,Handler 解析 Any 值时会使用它。它适合让领域类型提供稳定、安全的日志表示:
type Secret string
func (Secret) LogValue() slog.Value {
return slog.StringValue("[REDACTED]")
}
type Money struct { Currency string; Cents int64 }
func (m Money) LogValue() slog.Value {
return slog.GroupValue(
slog.String("currency", m.Currency),
slog.Int64("cents", m.Cents),
)
}
求值发生在级别过滤之后,所以构造 LogValuer 可以延迟部分工作。但 LogValue 应快速、确定、无 I/O、无日志递归,接收者也不应在并发中被修改。slog 会限制 LogValuer 的递归解析,防止无限循环,但输出会变成错误值,不能把它当正常控制流。
7. ReplaceAttr 统一改名、删字段和脱敏
内置 Handler 的 ReplaceAttr 会对每个非 Group 属性调用,包括内置 time、level、source、msg。返回零 Attr 可删除字段;修改 key/value 可建立统一格式:
replace := func(groups []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
return slog.Attr{} // 测试输出中移除时间
case "password", "authorization", "cookie":
return slog.String(attr.Key, "[REDACTED]")
}
return attr
}
groups 是当前属性所在的已打开组路径,不包含该 Attr 自身是 Group 时的键。脱敏策略若只比较叶子 key,可能误删无关组的同名字段;成熟 schema 应结合完整路径判断。
ReplaceAttr 只能处理作为 Attr 进入 Handler 的值。秘密若已拼进 Message、error 字符串或一个自定义 JSON 字符串,无法可靠找回。URL query、请求 body、header、DSN 和 error 都应默认视为潜在敏感源。
8. Context 不会自动变成日志字段
InfoContext 把 context 传给 Handler,但内置 Handler 不会读取其中的 request ID 或 trace ID。自定义包装 Handler 可以取出少量约定值并附加到 Record。context 主要仍是取消和调用范围载体,不应塞入所有业务字段,更不应为了获取 logger 而让每个函数依赖 context。
应用组件通常显式持有 *slog.Logger;请求入口用 logger.With("request_id", id) 派生后传入服务对象或在边界记录。若现有调用广泛使用 InfoContext,Handler 统一提取 trace ID 也合理,但 key 必须是私有类型,值要校验长度与字符集。
context 已取消时,日志调用不会因此自动跳过;Handler.Enabled/Handle 可以观察它,但错误或收尾记录往往仍有价值。不要把日志写入生命周期和业务 context 取消强耦合。
9. 正确实现 Handler 的四个方法
自定义 Handler 必须实现 Enabled、Handle、WithAttrs、WithGroup。最安全的扩展方式通常是包装已有 Handler,并把编码、并发和组语义委托给它。
type contextHandler struct{ next slog.Handler }
func (h contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func (h contextHandler) Handle(ctx context.Context, record slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
record.AddAttrs(slog.String("request_id", id))
}
return h.next.Handle(ctx, record)
}
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return contextHandler{next: h.next.WithAttrs(attrs)}
}
func (h contextHandler) WithGroup(name string) slog.Handler {
return contextHandler{next: h.next.WithGroup(name)}
}
Handle 添加属性用 record.AddAttrs;长期保存 Record 必须 Clone。Handler 会被并发调用,内部状态要同步;保存 WithAttrs 的 slice 前必须复制。
10. Source、调用深度与性能成本
HandlerOptions.AddSource 利用 Record 的 PC 解析文件、行和函数,能帮助定位错误,但每条都解析会增加成本和日志量。通常只在开发、Warn/Error 或采样路径启用。包装 Logger 的 helper 可能让 source 指向 helper 而非调用点;需要精确 PC 时可用 runtime.Callers 和 Logger.LogAttrs 构造正确记录,但应为 helper 写测试。
性能顺序通常是:先在 Enabled 前避免昂贵字段,再使用 LogAttrs 加 Attr,避免 Any 反射和整对象记录,最后用 Benchmark/profile 验证。下面的模式避免关闭 Debug 时构造大摘要:
if logger.Enabled(ctx, slog.LevelDebug) {
logger.DebugContext(ctx, "cache snapshot",
slog.String("summary", expensiveSummary(cache)))
}
普通 DebugContext 已避免 Handler 编码,但函数参数在调用前求值,因此昂贵函数仍需显式 Enabled。不要为普通整数和字符串到处手写 Enabled,复杂度也有成本。
11. 错误应该在哪一层记录
底层函数通常包装并返回 error,上层拥有处理结论的边界记录一次:HTTP 入口知道状态码,消费者知道消息是否重试,后台任务调度器知道任务是否最终失败。每层都打印同一 error 会制造重复事件,无法按真实失败次数告警。
logger.ErrorContext(ctx, "article publish failed",
slog.String("article_id", id),
slog.String("operation", "publish"),
slog.Any("err", err),
slog.Bool("retryable", retryable),
)
字段名用 err 还是 error 应统一。默认 Handler 会把 error 记为字符串,不自动包含错误链、类型或堆栈;需要结构化分类时显式记录稳定错误码、重试性和操作。不要把完整 SQL、token 或用户正文藏进 %w 错误后再整条输出。
panic recovery 记录应包含可行动上下文和受控 stack,但记录后还必须返回正确失败响应或终止相应工作。日志不是错误处理。
12. 字段 schema、基数与敏感数据
稳定字段优于随意字段。为 service、component、operation、request_id、duration、status、error_code 定义命名和类型;同一个 key 不要有时是整数、有时是字符串。变更字段名会破坏查询、解析和告警,应像 API schema 一样评审。
高基数 ID 可以进入日志以便检索,但不应机械复制成 metrics 标签。日志存储同样有成本:每请求多条 Info、无界数组和完整 payload 会放大写入、网络、索引与保留费用。
密码、验证码、访问/刷新 token、Cookie、私钥、完整 DSN 永不记录。个人信息按必要性最小化并设保留期。所谓“只在 Debug”仍可能进入生产;脱敏要在默认路径生效,且用测试固定策略。
13. 写入失败、反压与采样
内置 Handler 把一条记录写到 io.Writer,Handle 虽返回 error,但 Logger 的便捷方法不把它交还调用者。磁盘满、管道关闭或采集器阻塞时,应用可能变慢或丢失日志,而业务调用方无直接反馈。应在部署层监控日志 agent、stdout 管道、磁盘和丢弃计数。
同步写入提供较直接的顺序与反压,却可能拉高请求尾延迟。异步 Handler 需要有界队列,并明确满时阻塞、丢 Debug/Info、抽样还是降级;进程退出还要 flush,但 crash 无法保证。不能用无界 channel 把日志压力转成 OOM。
采样适合重复高频事件,不适合审计和低频严重错误。采样决策应保留总数估计或丢弃指标,并尽量按事件类型/键公平处理。限流后的聚合日志如“过去一分钟丢弃 1200 条”比静默丢弃可诊断。
14. 测试与诊断 Handler
测试日志行为可把 JSONHandler 指向 bytes.Buffer,逐行解码成 map[string]any 后断言字段,不比较整行 JSON 的时间和顺序。对 ReplaceAttr,至少测试嵌套组、LogValuer、秘密 key、错误和禁用级别。
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{
ReplaceAttr: replace,
}))
logger.Info("login", slog.String("password", "secret"))
if strings.Contains(output.String(), "secret") {
t.Fatal("log contains plaintext secret")
}
线上看到日志缺失时,依次检查 Enabled 阈值、采样/队列、Writer 错误、采集器过滤、容器 stdout 和存储查询时间范围。字段缺失则检查 With 派生 logger 是否真正传到调用点、context key 类型、ReplaceAttr 是否删除,以及组路径是否与查询一致。
15. 可运行综合示例:动态级别、上下文与脱敏
下面程序组合 JSONHandler 与 contextHandler,动态过滤 Debug,通过 LogValuer 和 ReplaceAttr 两层脱敏。移除时间使输出可重复;实际生产通常保留时间。
package main
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
)
type requestIDKey struct{}
type secret string
func (secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
type contextHandler struct{ next slog.Handler }
func (h contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func (h contextHandler) Handle(ctx context.Context, record slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
record.AddAttrs(slog.String("request_id", id))
}
return h.next.Handle(ctx, record)
}
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return contextHandler{next: h.next.WithAttrs(attrs)}
}
func (h contextHandler) WithGroup(name string) slog.Handler {
return contextHandler{next: h.next.WithGroup(name)}
}
func main() {
var output bytes.Buffer
var level slog.LevelVar
level.Set(slog.LevelInfo)
replace := func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey { return slog.Attr{} }
if attr.Key == "authorization" { return slog.String(attr.Key, "[REDACTED]") }
return attr
}
base := slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: &level, ReplaceAttr: replace})
logger := slog.New(contextHandler{next: base}).With(slog.String("service", "article-api"))
ctx := context.WithValue(context.Background(), requestIDKey{}, "req-42")
logger.DebugContext(ctx, "not emitted", slog.String("detail", "noise"))
logger.InfoContext(ctx, "request complete",
slog.Group("http", slog.String("method", "GET"), slog.Int("status", 200)),
slog.Any("token", secret("plain-token")),
slog.String("authorization", "Bearer plain-token"),
)
level.Set(slog.LevelDebug)
logger.DebugContext(ctx, "debug enabled", slog.Int("attempt", 2))
if bytes.Contains(output.Bytes(), []byte("plain-token")) { panic("secret leaked") }
if _, err := output.WriteTo(os.Stdout); err != nil { panic(err) }
fmt.Printf("records=2\n")
}
运行后得到两条 JSON 记录和 records=2。第一条 Debug 在 Info 阈值下被 Enabled 拒绝,随后动态调整才输出;两种秘密都成为 [REDACTED],request ID 由 Handler 从 context 添加。
16. 工程检查清单
- 消息使用稳定事件名,变化信息用类型稳定的 Attr;避免奇数键值产生
!BADKEY; - 统一级别含义,用 LevelVar 受控动态调整,昂贵 Debug 字段先检查 Enabled;
- Logger 与 Handler 长期复用,通过 With 添加稳定字段,通过 Group 解决命名边界;
- 自定义领域类型用 LogValuer 给出快速、安全、确定的表示;
- ReplaceAttr 统一时间、级别和脱敏,但禁止秘密进入消息与 error 才是第一防线;
- context 只提取少量约定 ID,组件依赖显式 logger,不让 context 成为参数仓库;
- 自定义 Handler 正确转发四个方法,支持并发,并尊重 Record Clone 和组语义;
- 错误在决定处理结果的边界记录一次,附稳定错误码与重试性;
- 日志队列必须有界,监控写入阻塞、丢弃、采集器和存储成本;
- 用结构化解析测试 schema、级别、组和脱敏,不比较带时间的完整文本行。
理解 slog 的核心是一条处理流水线:Logger 先调用 Enabled,Record 保存事件和属性,LogValuer 延迟形成安全值,With/Group 建立 schema,Handler 补充上下文并编码,Writer 把记录交给外部系统。每一层都要有明确契约,结构化日志才会从“能输出 JSON”变成可长期治理的工程能力。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 测试体系:表驱动测试、子测试、Benchmark 与 Fuzz
- 下一篇:Go 配置管理:flag、环境变量、YAML 与默认值边界
- 延伸:Go context 完整指南:取消、超时、Deadline 与 Value
- 延伸:Go net/http 基础:Server、Handler、Middleware 与 Client 超时
- 延伸:Go 性能诊断基础:Benchmark、pprof、trace 与指标证据链
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论