《Go 语言编程入门》16.1 log/slog 结构化日志

给 TaskAPI 接入 log/slog 结构化日志:JSON 与 Text 两种 handler、LevelVar 动态调级、With 携带公共字段、LogValuer 脱敏、自定义 handler 从 context 注入 request_id,并用 ReplaceAttr 裁剪时间格式,最后把 main 与 httpapi 的日志出口统一起来。

本节把 TaskAPI 从「能跑」推进到「可观测」:给它换上 log/slog 结构化日志,让每条日志都带服务名、版本、请求 ID 等可检索字段,为第 17 章上容器、第 18 章上线做好准备。
适用版本:Go 1.27(实测 go1.27.0)。

16.1 log/slog 结构化日志

前 15 章里 TaskAPI 一直用 fmt.Println 和标准库 log 打日志。到了要部署的阶段,纯文本日志会立刻暴露问题:想按级别过滤、想按 request_id 串起一次请求、想被日志系统结构化采集,都做不到。Go 1.21 起标准库补上了 log/slog,本节把 TaskAPI 的日志出口换掉。

16.1.1 从文本到结构化

先把三种写法的差异摆在一起:

方式典型输出适合场景
fmt.Printlntask created 1临时调试,永不进生产
log.Printf2026/10/07 10:00:00 task created 1单机脚本、简单命令行
slog.Info{"level":"INFO","msg":"task created","id":1}服务端、可被机器解析

结构化日志的关键不是「更好看」,而是每条记录是键值对,日志系统能直接按字段建索引、做聚合。slog 把这个模型固化成了 Record + Attr,输出格式交给 Handler 决定。

16.1.2 两行接入 JSON handler

最小可用版本只要两行。slog.NewJSONHandler 把记录写成一行一个 JSON 对象:

h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
l := slog.New(h)
l.Info("server starting", "addr", ":8080", "version", "0.1.0")

os.Stdout 而不是 os.Stderr 是有意的:很多容器运行时把 stdout 当成日志流,stderr 留给真正的错误。实际跑一遍:

{"time":"2026-10-10T07:32:17.189901+08:00","level":"INFO","msg":"server starting","addr":":8080","version":"0.1.0"}

注意 slog 的调用形态:消息是第一个参数,后面成对出现的是 key/value。l.Info("msg", "k", v)。这跟 log.Printf("msg %v", v) 的格式化风格不同,初学最容易写错的就是漏掉一个 value,导致 !BADKEY 之类的输出。

标准库还提供了包级函数 slog.Info / slog.Error,它们走一个全局默认 logger。要替换默认 logger,用 slog.SetDefault:

slog.SetDefault(l)
slog.Info("using the default logger") // 走上面那个 JSON handler

包级函数省事,但依赖全局状态,测试里不好隔离。TaskAPI 里我们显式把 *slog.Logger 作为依赖注入(第 15 章的手工 DI),只有第三方库内部的日志才通过 SetDefault 兜底。

当 key/value 的 value 类型不确定时,可以显式构造 slog.Attr,用 slog.String / slog.Int64 / slog.Any 等辅助函数,避免依赖反射推断:

l.LogAttrs(ctx, slog.LevelInfo, "batch done",
	slog.Int("processed", 42),
	slog.String("status", "ok"),
)

LogAttrs 比变参的 Info 少一次分配,高频日志路径上值得换用。

16.1.3 级别控制与 LevelVar

HandlerOptions.Level 决定哪些记录会被真正写出。低于阈值的记录在进入 handler 前就被丢弃,几乎没有开销。固定级别传一个 slog.Level 即可:

&slog.HandlerOptions{Level: slog.LevelInfo}

但生产环境常常要运行时调级:排查问题时临时打开 DEBUG。这时用 slog.LevelVar,它实现了 slog.Leveler 接口,Set 是并发安全的:

lvl := new(slog.LevelVar)
lvl.Set(slog.LevelInfo)
base := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
// ... 运行中随时调整:
lvl.Set(slog.LevelDebug)

LevelVar 与 Level 的关系是「接口与值」:Leveler 只有一个 Level() Level 方法,LevelVar 和 Level 都实现了它,所以能塞进同一个字段。第 15 章我们读配置时把日志级别当成一个可热更的开关,正是靠 LevelVar 落地的。

slog 的四个内置级别与常见用途对照:

级别常量用途
DEBUGslog.LevelDebug开发期细节,生产默认关闭
INFOslog.LevelInfo正常里程碑:启动、请求完成
WARNslog.LevelWarn可恢复异常:慢查询、重试
ERRORslog.LevelError需要人介入:请求失败、依赖不可用

Level 是 int 的别名,slog.LevelInfo 等于 0、Warn 等于 4、Error 等于 8,Debug 等于 -4。所以也能用 lvl.Set(slog.Level(-8)) 自定义更细的级别,但大多数项目用四个内置级别就够了。

16.1.4 公共字段:With 与 WithGroup

TaskAPI 的每一条日志都应该带 service 和 version。用 With 把公共字段预先绑定,返回一个派生的 Logger:

l = l.With("service", "taskapi", "version", "0.1.0")
l.Info("server starting", "addr", ":8080")
l.Warn("slow query", "ms", 842)

With 返回新 logger,不影响原来的。运行结果里每条都自动带上了公共字段:

{"time":"...","level":"INFO","msg":"server starting","service":"taskapi","version":"0.1.0","addr":":8080"}
{"time":"...","level":"WARN","msg":"slow query","service":"taskapi","version":"0.1.0","ms":842}

当某组字段需要嵌套成子对象时,用 WithGroup。比如把所有 HTTP 相关字段收进 http 键:

l.WithGroup("http").Error("request failed", "status", 500, "path", "/tasks")

输出会变成 "http":{"status":500,"path":"/tasks"}。分组让日志在 JSON 里层次更清晰,也避免字段名撞车。

16.1.5 敏感字段脱敏:LogValuer

TaskAPI 迟早要记 token、密码这类敏感值。直接把明文写进日志是事故。slog 提供了 LogValuer 接口,只要类型实现了它,handler 在写出前会调用 LogValue() 做转换:

type Token string

func (Token) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }

调用时照常传 "token", Token("s3cr3t"),落盘却是:

{"time":"...","level":"INFO","msg":"server starting","service":"taskapi","version":"0.1.0","addr":":8080","token":"[REDACTED]"}

这种「类型自带脱敏」比在每个调用点手写判断可靠得多——只要用了 Token 类型,就不可能忘记脱敏。

16.1.6 用自定义 handler 注入 request_id

第 13 章起 TaskAPI 的每个请求都带 context,第 12 章又把请求级数据放进了 context。我们希望每条日志自动带上当前请求的 request_id,而不是每次手动传参。做法是包一层 handler:

type ctxHandler struct{ slog.Handler }

func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
	if id, ok := ctx.Value(reqIDKey).(string); ok {
		r.AddAttrs(slog.String("request_id", id))
	}
	return h.Handler.Handle(ctx, r)
}
func (h ctxHandler) WithAttrs(a []slog.Attr) slog.Handler { return ctxHandler{h.Handler.WithAttrs(a)} }
func (h ctxHandler) WithGroup(n string) slog.Handler      { return ctxHandler{h.Handler.WithGroup(n)} }

三个方法里,Handle 是核心,WithAttrs / WithGroup 必须返回同样包了一层的 handler,否则 With 之后注入就失效了。配套调用要用带 context 的 InfoContext:

ctx := context.WithValue(context.Background(), reqIDKey, "r-42")
l.InfoContext(ctx, "handled", "method", "GET", "path", "/tasks", "status", 200)

实测输出(这里还叠加了 ReplaceAttr 把时间裁成 15:04:05):

time=07:32:33 level=INFO msg=handled method=GET path=/tasks status=200 request_id=r-42
time=07:32:33 level=DEBUG msg="verbose on" request_id=r-42

这就是「中间件生成 request_id 塞进 context,日志层自动捞出来」的完整链路。

16.1.7 ReplaceAttr:裁剪与改写字段

HandlerOptions.ReplaceAttr 是最后一道改写入参。它在每个属性写盘前被调用,返回零值 Attr 就丢弃该字段。常见用法是统一时间格式、去掉冗长的 source 路径:

ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
	if a.Key == slog.TimeKey {
		a.Value = slog.StringValue(a.Value.Time().Format("15:04:05"))
	}
	return a
}

a.Key == slog.TimeKey 用的是常量而不是字面量 "time",避免拼错。HandlerOptions.AddSource 打开后会自动加 source 属性,定位到具体文件行号,但每条日志都要做一次 runtime.Callers,性能敏感路径上要权衡。

16.1.8 落到 TaskAPI:统一装配

把上面几块拼进 cmd/taskapi/main.go,构造一个带公共字段、动态级别、context 注入的 logger,再注入给 httpapi:

func newLogger() *slog.Logger {
	lvl := new(slog.LevelVar)
	lvl.Set(slog.LevelInfo)
	h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
	return slog.New(ctxHandler{h}).With("service", "taskapi", "version", version)
}

httpapi.Server 只依赖 *slog.Logger(第 5 章的接口思想),不关心底层是 JSON 还是 Text。第 18 章上线时,只要换 handler 就能把日志切到文本格式给本地调试。

注入后,httpapi 里落日志统一用带 context 的形态,request_id 自动带上:

func (s *Server) create(w http.ResponseWriter, r *http.Request) {
	// ... 解析与校验 ...
	created, err := s.store.Create(r.Context(), t)
	if err != nil {
		s.log.ErrorContext(r.Context(), "create failed", "err", err)
		writeError(w, http.StatusInternalServerError, "internal", err.Error())
		return
	}
	s.log.InfoContext(r.Context(), "task created", "id", created.ID)
	writeJSON(w, http.StatusCreated, created)
}

一条 create 请求在生产里会长这样,字段全部可被日志系统检索:

{"time":"2026-10-10T07:38:14.964Z","level":"INFO","msg":"task created","service":"taskapi","version":"0.1.0","request_id":"r-42","id":1}

16.1.9 常见坑

  • slog 与标准 log 可以打通:slog.NewLogLogger 把 slog.Handler 包成 *log.Logger,让第三方库的 log 输出也走结构化通道。
  • Attr 成对:参数个数为奇数时不会编译报错,而是输出 !BADKEY=...,靠 go vet 的 slog 检查项兜底。
  • 不要在高频路径上 With 造新 logger:With 会分配,热循环里应复用。

小结

  • slog 把日志建模成 Record + Attr,输出格式由 Handler 决定;JSON handler 适合生产,Text handler 适合本地。
  • 级别用 LevelVar 实现运行时热调;公共字段用 With 绑定;嵌套用 WithGroup。
  • 敏感字段用 LogValuer 在类型层脱敏;request_id 用自定义 handler 从 context 自动注入。
  • ReplaceAttr 是统一时间格式、裁剪字段的最后一道关口。

日志解决的是「事后能查」。但有些问题不是看日志能定位的——比如 CPU 突然打满、goroutine 泄漏。下一节我们给 TaskAPI 挂上 pprof 与 expvar,直接在运行时把内存、CPU、goroutine 的现场抓出来。

阅读导航:上一节:15.3 统一错误响应与输入校验 · 下一节:16.2 pprof 与 expvar 看运行时 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练