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

Go slog.With 为什么会复制属性

来源:17golang原创

时间:2026-11-14 16:55:50 284浏览 收藏

如果你在请求入口写了 requestLogger := logger.With("request_id", id),然后发现原来的 logger 没有这个字段,这不是属性丢失,而是 slog.With 的设计:它派生一个新 Logger,把属性交给 Handler 保存或预格式化,原 Logger 继续保持原来的上下文。

这里说的“复制属性”也不是简单地把一份全局 map 复制出来。Logger.With 复制的是 Logger 外壳,并调用 Handler.WithAttrs 生成新的 Handler 状态;自定义 Handler 如果错误地复用可变切片,就会把派生 Logger 和原 Logger 绑在一起。

一句话结论:With 负责派生 Logger,WithAttrs 负责派生 Handler;正确实现必须让原对象不变,同时把旧属性和新属性按顺序组合起来。

Logger.With 复制的是外壳,不是修改原对象

官方实现中的 Logger 只有一个 Handler 字段。调用 With 时,先浅复制 Logger 结构,再把 l.handler.WithAttrs(argsToAttrSlice(args)) 的返回值放入副本。参数会按普通日志参数规则转换为 slog.Attr。

package main

import (
	"log/slog"
	"os"
)

func main() {
	// 关闭时间字段,便于读者只观察 Logger 派生出来的属性。
	h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{} // 静态示例不关心时间字段。
			}
			return a
		},
	})
	base := slog.New(h)
	request := base.With("request_id", "r-17")

	base.Info("from base")
	request.Info("from request", "route", "/orders")
}

上面两个 Logger 共享底层输出目标,但它们的 Handler 状态不同。base 的日志没有 request_id,request 的每条日志都会带上它。把派生 Logger 赋回变量只是改变当前变量指向,不会反向修改已经存在的其他 Logger。

WithAttrs 为什么必须返回新的 Handler

Logger.With 不直接保存一份属性列表,而是把属性交给 Handler。Handler 接口明确要求 WithAttrs 返回一个包含旧属性和新属性的新 Handler,接收者本身不能被修改。这样同一个基础 Logger 才能安全地派生出多个互不干扰的上下文。

slog Logger With 与 Handler WithAttrs 派生关系的静态结构说明图
图1:结构说明图,展示 Logger、Handler、With、WithAttrs 与派生属性状态的静态关系;不是运行截图。

属性顺序也有意义。一次 base.With("service", "pay") 再调用 With("request_id", "r-17"),派生 Handler 需要保留前后顺序;随后日志调用传入的 Record 属性再接在 Logger 已有属性之后。Handler 可以按自己的格式输出,但不能把原 Logger 的状态改成“最后一次派生”的结果。

这就是下面两种写法的差别:

  • requestLogger := base.With(...):推荐,基础上下文可继续复用。
  • base = base.With(...):也合法,但当前变量从此代表派生 Logger;其他已经保存的 Logger 不受影响。

内置 Handler 为什么看起来像“复制了属性”

TextHandler 和 JSONHandler 会利用 WithAttrs 做一次性工作。官方源码中的公共 Handler 状态会被克隆,属性可以被预格式化,后续每条日志只需处理新 Record 的动态字段。这是性能优化,也是为什么属性并不一定以原始 []slog.Attr 的形态留到 Handle。

slog WithAttrs 预格式化与 Handle 动态记录属性关系说明图
图2:关系说明图,对比 WithAttrs 的固定属性、预格式化缓存与 Handle 接收的动态 Record;不是运行结果截图。

因此,With 适合放请求 ID、租户 ID、服务名这类会反复出现的固定属性。昂贵的动态值仍会在调用参数求值阶段产生;如果日志级别会过滤掉这条记录,参数表达式也不会因为 With 而自动延迟。

还要区分属性值本身的生命周期。slog.Attr 是值类型,但 slog.Any 里面仍可能装着指向可变对象的指针或切片。With 解决的是 Logger/Handler 状态派生,不会替你深拷贝业务对象;需要跨请求或异步使用时,仍应明确对象的所有权。

自定义 Handler 要避免切片别名

自定义 Handler 最容易出错的地方,是用 append 直接在接收者的切片上追加属性。只要底层数组还有容量,后续派生 Logger 就可能覆盖同一块内存,造成属性串线。实现时要复制 Handler 结构,并为保存属性的切片分配独立容量。

type attrsHandler struct {
	base slog.Handler
	attrs []slog.Attr
}

func (h *attrsHandler) WithAttrs(more []slog.Attr) slog.Handler {
	// 复制接收者的切片,避免派生 Handler 覆盖原 Handler 的底层数组。
	all := make([]slog.Attr, 0, len(h.attrs)+len(more))
	all = append(all, h.attrs...)
	all = append(all, more...)
	return &attrsHandler{base: h.base, attrs: all}
}

func (h *attrsHandler) WithGroup(name string) slog.Handler {
	// 组名由底层 Handler 负责;当前包装器只复制自己的属性状态。
	return &attrsHandler{base: h.base.WithGroup(name), attrs: append([]slog.Attr(nil), h.attrs...)}
}

func (h *attrsHandler) Enabled(ctx context.Context, level slog.Level) bool {
	// 过滤规则继续交给底层 Handler,避免包装器改变级别语义。
	return h.base.Enabled(ctx, level)
}

func (h *attrsHandler) Handle(ctx context.Context, r slog.Record) error {
	// 这里省略具体格式化;生产实现应把 h.attrs 与 r.Attrs 合并后交给输出层。
	return h.base.Handle(ctx, r)
}

这个片段只展示状态复制边界。真正的包装 Handler 还需要决定固定属性如何参与输出、是否调用 Record.Clone、如何处理 WithGroup,以及如何保持并发写入安全。最重要的规则只有一个:WithAttrs 不修改接收者,返回的新对象可以拥有自己的属性存储。

请求级日志应该怎样派生

在 HTTP 或消息消费入口,通常保留一个不带请求字段的基础 Logger,再为每个请求创建派生 Logger。若不同子系统可能使用相同键名,可以继续用 WithGroup 建立命名空间;它同样返回新的 Logger,并把分组交给 Handler 处理。

func handle(logger *slog.Logger, requestID string) {
	// 每次调用创建独立上下文,不把请求字段写回共享的基础 Logger。
	requestLogger := logger.With("request_id", requestID)
	parserLogger := requestLogger.WithGroup("parser")

	parserLogger.Info("decode payload", "size", 128)
}

可以用下面这张清单检查实现:

场景推荐做法注意点
共享服务字段基础 Logger.With固定属性只从基础上下文派生
请求字段入口处创建派生 Logger不要把请求状态写进全局 Logger
自定义 HandlerWithAttrs 返回新实例复制切片,避免底层数组别名
子系统同名键WithGroup 建立命名空间确认 TextHandler 与 JSONHandler 的展示差异

相关问题

With 会深拷贝 slog.Any 里的对象吗?不会。它派生 Logger/Handler 状态;属性值内部引用的业务对象仍由调用方负责生命周期。

为什么自定义 Handler 里要复制 attrs 切片?因为派生 Handler 不能修改原 Handler,直接 append 可能复用底层数组,导致不同 Logger 之间互相覆盖属性。

With 和每次 Info 传属性有什么区别?With 适合重复出现的固定字段,Handler 有机会预格式化;Info 传入的属性属于当前 Record,更适合一次性的动态字段。

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