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

结构化日志字段应该在调用处还是 Handler 中补齐

来源:17golang原创

时间:2026-10-07 14:14:00 406浏览 收藏

结构化日志字段最稳妥的分工是:事件事实写在调用处,稳定的请求或子系统字段用 Logger.With 在边界绑定,只有可由 context.Context 或进程环境统一推导的横切字段才交给 Handler 补齐。Handler 不应猜订单状态、重试次数或业务结果,因为它通常看不到这些语义。

官方文档:https://pkg.go.dev/log/slog

字段归属速记
  • 调用处:order_id、status、latency、error、rows 等当前事件事实。
  • Logger.With:request_id、component、tenant 等在一个作用域内稳定的字段。
  • Handler:从 context 提取的 trace_id、进程级 service/version、统一脱敏和键名规范化。

先按字段所有权划分三层

slog.Logger 会把时间、级别、消息和属性组成 Record,再交给关联的 Handler。这个接口边界很适合做统一输出,但不意味着所有字段都应该在 Handler 中生成。

字段类型最合适的位置原因
事件特有业务事实调用处只有当前代码知道准确语义和结果
一组日志共同字段Logger.With一次绑定,多次复用,作用域清晰
请求链路字段Handler 从 context 提取横切关注点可统一实现
键名重写、脱敏ReplaceAttr 或包装 Handler输出策略应集中维护
Go slog 调用处、Logger.With 与 Handler 的字段所有权静态关系图
图1:静态说明图展示事件字段、作用域字段和横切字段分别由调用处、Logger.With 与 Handler 负责,不是日志运行截图。

调用处记录当前事件才能知道的事实

订单处理函数知道订单号、结果、耗时和错误,因此这些字段应跟消息一起出现。LogAttrs 只接收 slog.Attr,比交替传入键和值更明确,也能减少部分分配。

func finishOrder(ctx context.Context, logger *slog.Logger, orderID string, started time.Time, err error) {
	attrs := []slog.Attr{
		// 订单号属于当前业务事件
		slog.String("order_id", orderID),
		// 耗时由调用处掌握,Handler 不应重新推断
		slog.Duration("latency", time.Since(started)),
	}

	if err != nil {
		attrs = append(attrs, slog.Any("error", err))
		logger.LogAttrs(ctx, slog.LevelError, "order failed", attrs...)
		return
	}

	logger.LogAttrs(ctx, slog.LevelInfo, "order completed", attrs...)
}

如果把 order_id 放进 Handler,Handler 只能依赖隐式 context 值或全局变量。这样字段来源难追踪,后台任务、测试和没有请求 context 的调用还会产生不同结果。

稳定作用域字段用 Logger.With 绑定

官方文档建议对多条日志共享的属性使用 Logger.With。它会返回一个带附加属性的新 Logger,后续每次调用都会包含这些字段。内置 Handler 还能在 With 时预先格式化共同属性。

func handleRequest(ctx context.Context, base *slog.Logger, requestID string) {
	requestLogger := base.With(
		// request_id 在整个请求作用域内保持不变
		slog.String("request_id", requestID),
		slog.String("component", "checkout"),
	)

	requestLogger.InfoContext(ctx, "request accepted")
	// 下游复用同一个请求级 Logger,避免每次重复字段
	processCheckout(ctx, requestLogger)
}

这种方式比“每条日志重复写 request_id”更不容易漏字段,也比“让 Handler 从任意 context key 猜字段”更清楚。子系统还可以配合 WithGroup 隔离常见键名。

Handler 只补可以统一推导的横切字段

官方文档明确提到,某些 Handler 会从调用处提供的 context 中加入信息,例如当前 trace span 标识。包装 Handler 时,Enabled、Handle、WithAttrs 和 WithGroup 都要正确转发。

type traceKey struct{}

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 {
	traceID, _ := ctx.Value(traceKey{}).(string)
	if traceID != "" {
		// Record 含共享内部状态,修改前必须 Clone
		record = record.Clone()
		record.AddAttrs(slog.String("trace_id", traceID))
	}
	return h.next.Handle(ctx, record)
}

func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	// 保留 Logger.With 添加的字段
	return ContextHandler{next: h.next.WithAttrs(attrs)}
}

func (h ContextHandler) WithGroup(name string) slog.Handler {
	// 保留 Logger.WithGroup 的分组语义
	return ContextHandler{next: h.next.WithGroup(name)}
}

Record 的普通副本可能共享隐藏状态。Go 文档要求:Handler 在修改 Record 前调用 Clone,或创建新 Record 并复制属性。这里先 Clone,再添加 trace_id。

Go slog ContextHandler 转发 Enabled、WithAttrs、WithGroup 并克隆 Record 后补充 trace_id 的静态结构图
图2:静态结构图展示 Logger、context、Record.Clone、AddAttrs 与下游 Handler 的接口关系,不表示执行时序。

没有 context 的调用要有明确降级规则

Handler 只有在日志调用传入 context 时才能读取其中的信息。代码持有 context 时,优先使用 InfoContext、ErrorContext 或 LogAttrs;普通 Info 不应被期待自动拥有请求字段。

func newLogger(w io.Writer) *slog.Logger {
	jsonHandler := slog.NewJSONHandler(w, &slog.HandlerOptions{
		// 生产环境从 INFO 起输出,可替换为 LevelVar 动态调整
		Level: slog.LevelInfo,
	})

	return slog.New(ContextHandler{next: jsonHandler}).With(
		// 进程级字段在构造 Logger 时一次绑定
		slog.String("service", "checkout-api"),
	)
}

建议约定:没有 trace_id 就省略该字段,不输出空字符串;后台任务用 job_id 或 run_id,不要伪造 request_id;调用处已经显式提供的业务字段不要再由 Handler 添加同名键。

兼容键名、分组和重复字段

大型系统最容易出问题的不是“缺一个字段”,而是同名字段含义不一致。可以制定一个很小的日志字段契约:

  • trace_id 只表示分布式追踪标识,由 Handler 从 context 提取。
  • request_id 由入口层创建并通过 Logger.With 绑定。
  • order_id、user_id 等业务标识由调用处写入。
  • HTTP、数据库或队列字段使用 WithGroup 分组,避免 status 含义冲突。

不要依赖“后写字段覆盖前写字段”的隐式规则。不同 Handler 对重复键的呈现方式可能不同;最简单的做法是让字段只有一个明确所有者。

性能和敏感信息放在输出边界统一处理

如果分析显示日志占用明显时间,常用字段应优先通过 Logger.With 绑定,热路径使用 LogAttrs。日志参数会在调用前求值,昂贵值可以实现 LogValuer,只在事件确实启用时计算。

密码、Token、身份证号等敏感内容不应进入日志。对已知键做统一脱敏,可在 HandlerOptions.ReplaceAttr 中返回替换值或空属性:

replaceSensitive := func(groups []string, attr slog.Attr) slog.Attr {
	switch attr.Key {
	case "password", "token":
		// 输出固定标记,不保留原始秘密
		return slog.String(attr.Key, "[REDACTED]")
	default:
		return attr
	}
}

ReplaceAttr 更适合重写、删除和脱敏已有属性;从 context 提取 trace_id 则适合包装 Handler。两者职责不同,不要把一个超大函数同时做业务推断、环境探测、字段改名和输出格式化。

最终判断清单

  • 字段是否只有当前业务代码知道?放调用处。
  • 字段是否在一个请求或子系统内保持稳定?用 Logger.With。
  • 字段是否能从 context 或运行环境统一推导?由 Handler 补齐。
  • 包装 Handler 是否完整转发四个接口方法,并在修改 Record 前 Clone?
  • 没有 context 时是否省略横切字段,而不是伪造空值?
  • 敏感键是否在统一输出边界脱敏?
  • 是否避免重复键和不明确的覆盖规则?

常见问题

request_id 应该由 Handler 从 context 自动取吗?

如果全项目都有统一 context 契约,可以由 Handler 取;若只有部分入口设置 request_id,入口层用 Logger.With 更直观。关键是只选一个所有者。

错误对象应该放在 Handler 中统一读取吗?

不应该。错误属于当前事件,调用处最清楚它对应哪个动作和结果,应使用 slog.Any("error", err) 显式记录。

为什么修改 Record 前必须 Clone?

Record 的普通副本可能共享内部属性状态。直接 AddAttrs 可能影响原 Record;Clone 会创建不共享状态的副本。

所有公共字段都用 Handler 补齐更省代码吗?

短期看似省代码,长期会隐藏字段来源并扩大耦合。稳定作用域字段用 With,Handler 只保留真正横切且可统一推导的字段。

一句话收口:调用处负责“发生了什么”,Logger.With 负责“这一组日志属于谁”,Handler 负责“输出时统一补充和治理什么”。按字段所有权分层,比把所有内容集中到一个万能 Handler 更容易维护。

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