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 才能安全地派生出多个互不干扰的上下文。

属性顺序也有意义。一次 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。

因此,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 |
| 自定义 Handler | WithAttrs 返回新实例 | 复制切片,避免底层数组别名 |
| 子系统同名键 | WithGroup 建立命名空间 | 确认 TextHandler 与 JSONHandler 的展示差异 |
相关问题
With 会深拷贝 slog.Any 里的对象吗?不会。它派生 Logger/Handler 状态;属性值内部引用的业务对象仍由调用方负责生命周期。
为什么自定义 Handler 里要复制 attrs 切片?因为派生 Handler 不能修改原 Handler,直接 append 可能复用底层数组,导致不同 Logger 之间互相覆盖属性。
With 和每次 Info 传属性有什么区别?With 适合重复出现的固定字段,Handler 有机会预格式化;Info 传入的属性属于当前 Record,更适合一次性的动态字段。
-
194 收藏
-
219 收藏
-
167 收藏
-
272 收藏
-
398 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习