本节把 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.Println | task created 1 | 临时调试,永不进生产 |
log.Printf | 2026/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 的四个内置级别与常见用途对照:
| 级别 | 常量 | 用途 |
|---|---|---|
| DEBUG | slog.LevelDebug | 开发期细节,生产默认关闭 |
| INFO | slog.LevelInfo | 正常里程碑:启动、请求完成 |
| WARN | slog.LevelWarn | 可恢复异常:慢查询、重试 |
| ERROR | slog.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 看运行时 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。