登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go log/slog 结构化日志字段的分层设计

来源:17golang原创

时间:2026-10-15 09:26:55 450浏览 收藏

我在排查一条请求日志时遇到过一个很典型的现象:同一个服务里,有的记录带 request_id,有的把它拼进消息文本;业务字段有时叫 user_id,有时又藏在一段 JSON 里,最麻烦的是调试登录问题时差点把令牌写进日志。用 Go log/slog 时,问题通常不在“会不会打印”,而在字段应该在哪一层产生。

可维护的做法是把字段分成服务层、请求层和事件层:服务层用 Logger.With 固定,请求层用 WithGroup 建立命名空间并显式提取 context 属性,事件层只记录当前动作;最后在 Handler 统一脱敏。

官方资料:https://pkg.go.dev/log/slog

要点速览
  • Handler 是日志输出和字段策略的后端,Logger 负责收集记录。
  • context.Context 不会被 InfoContext 自动展开,想写入哪些字段必须显式提取。
  • With 适合固定字段,WithGroup 适合给请求或子系统建立命名空间。
  • ReplaceAttr 可以在 Handler 层集中处理 password、token 这类敏感字段。

先把字段按生命周期分成三层

先不要急着设计 key 名。把字段按“会变化多快”分层,后面的 API 选择会清楚很多:

层次典型字段产生位置适合的 slog 写法
服务层service、version、region进程启动后基本不变logger.With
请求层request_id、trace_id、route一次请求或一次任务WithGroup 加显式提取
事件层user_id、item_count、err某个具体动作LogAttrs 或单次日志参数

这样分层的好处是:服务字段不会在每行日志里重复拼接,请求字段不会污染全局 logger,业务字段也不会被错误地留在长生命周期对象上。

Go log/slog 将 service、request、event 字段分层后通过 With、WithGroup 汇入 Handler 的静态结构说明图
图1:log/slog 字段生命周期与 Handler 分层说明图,不是运行截图。

用 With 与 WithGroup 建立稳定命名空间

Logger.With 返回一个带附加属性的新 Logger,原来的 Logger 不会被改写;WithGroup 则为后续字段增加命名空间。下面这段代码把三层字段放在各自的位置:

package main

import (
	"context"
	"log/slog"
	"os"
)

func logOrder(ctx context.Context, base *slog.Logger, requestID string) {
	// 服务层字段在进程范围内稳定,创建一次即可复用。
	serviceLog := base.With(
		slog.String("service", "checkout"),
		slog.String("region", "cn-east-1"),
	)

	// 请求层字段放进 request 命名空间,避免与业务字段同名。
	requestLog := serviceLog.WithGroup("request").With(
		slog.String("request_id", requestID),
	)

	// 事件层只记录这一次动作;InfoContext 不会自动展开 ctx。
	requestLog.InfoContext(ctx, "order accepted",
		slog.String("order_id", "o-1038"),
		slog.Int("item_count", 3),
	)
}

func main() {
	// JSONHandler 负责把结构化字段写成每行一个 JSON 对象。
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logOrder(context.Background(), logger, "req-7f2a")
}

使用 JSON Handler 时,输出会保留分组层次,大致呈现为 service、region、request.request_id 和事件字段。这里的重点不是固定某个输出排版,而是让字段的来源可预测。若一个子系统也有 id,可以继续使用 WithGroup("parser"),避免同名 key 互相覆盖或让查询含义变得模糊。

明确 context 只负责传递现场信息

很多日志“偶尔缺 request_id”,根因是把 context 当成了自动字段容器。InfoContext、LogAttrs 的确会把 context 传给 Handler,但 Handler 是否读取、读取哪些值,取决于你的实现。标准 JSON Handler 不会把 context 中所有值扫出来。

因此,推荐在请求入口或日志适配层做一次明确转换:

type requestContextKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
	// 使用私有 key,避免和其他包的 context 值发生碰撞。
	return context.WithValue(ctx, requestContextKey{}, id)
}

func requestID(ctx context.Context) (string, bool) {
	// 只提取日志约定的字段,不把 context 当作 map 整体输出。
	id, ok := ctx.Value(requestContextKey{}).(string)
	return id, ok && id != ""
}

func infoWithContext(logger *slog.Logger, ctx context.Context, msg string) {
	attrs := make([]slog.Attr, 0, 1)
	if id, ok := requestID(ctx); ok {
		// 显式加入 request_id,字段来源在代码里一眼可见。
		attrs = append(attrs, slog.String("request_id", id))
	}
	logger.LogAttrs(ctx, slog.LevelInfo, msg, attrs...)
}

如果你的链路追踪库已经在 context 中保存了 trace ID,也应该通过一个小的适配函数显式取出并命名。这样做有两个好处:没有值时行为明确,字段名变更时只需要修改适配层;同时不会因为某个库把额外对象放进 context,就意外把内部数据写入日志。

在 Handler 层统一保护敏感字段

脱敏最好放在输出边界,而不是要求每个调用点都记住“密码必须改名”。HandlerOptions.ReplaceAttr 可以在属性即将输出前按 key 做统一替换;分组路径会通过 groups 参数传入,所以可以根据命名空间细化规则。

func redactingHandler(out io.Writer) slog.Handler {
	// 这些 key 即使出现在不同业务模块,也统一输出固定占位符。
	redact := func(groups []string, attr slog.Attr) slog.Attr {
		switch strings.ToLower(attr.Key) {
		case "password", "token", "authorization", "secret":
			// 不保留原值,避免日志采集、转储或错误回显再次暴露秘密。
			return slog.String(attr.Key, "[REDACTED]")
		default:
			return attr
		}
	}

	return slog.NewJSONHandler(out, &slog.HandlerOptions{
		// 统一在 Handler 边界执行,调用方仍可使用正常字段名。
		ReplaceAttr: redact,
	})
}

上面的示例需要导入 io 和 strings。真实项目里还要决定是否按 groups 限定规则,例如只对 auth 分组下的 token 脱敏。不要只依赖字段名:一个叫 credential 的结构体可能通过 LogValuer 展开出多个敏感属性,应该在类型自己的 LogValue 方法里返回已经整理过的安全值。

Go log/slog 显式提取 context 中的 request_id 与 trace_id,并在 ReplaceAttr 中将 password 和 token 变成 redacted 的静态关系说明图
图2:上下文字段提取与敏感字段脱敏关系说明图,不是运行截图。

用一条检查清单复查输出

字段分层完成后,我会用下面几项快速复查,而不是只看某一行日志“长得像不像 JSON”:

  • 来源:每个 request 或 trace 字段是否都有明确的提取入口,缺失时是否允许为空?
  • 命名:同一概念是否只保留一个 key,子系统是否用 WithGroup 隔离?
  • 生命周期:服务级 logger 是否不会携带请求级 ID,事件级大对象是否没有被放进 With?
  • 安全性:password、token、authorization、secret 以及自定义 LogValuer 是否都经过脱敏?
  • 并发性:自定义 Handler 是否返回新实例而不是修改共享 receiver,并且能安全处理并发调用?

最后一个问题尤其容易被忽略。官方 Handler 约定允许多个方法并发调用;如果自己包装 Handler,应保持原 Handler 的 WithAttrs、WithGroup 语义,返回带新状态的 Handler,再把记录交给下层处理。这样,字段结构才不会因为请求并发而互相串线。

常见问题

为什么调用 InfoContext 后日志里没有 context 的全部字段?

因为 context 只是传给 Handler 的现场参数,不是自动展开的属性集合。需要在适配层显式提取 request_id、trace_id 等允许写入的字段。

固定字段都应该放到 With 里吗?

只放真正稳定、体积小且不会随请求变化的字段。请求 ID、用户 ID 和大对象应放在请求或事件层,避免长生命周期 Logger 持有错误上下文。

ReplaceAttr 能替代所有脱敏逻辑吗?

不能。它适合按属性 key 做统一规则;复杂结构或自定义类型还需要在 LogValuer 中主动返回安全表示,并在日志边界做一次整体复查。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>