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

Go slog LogValuer 递归引用为什么会被替换成错误值

来源:17golang原创

时间:2026-09-28 04:50:25 158浏览 收藏

LogValuer 递归引用时,slog 并不会把它悄悄替换成某个业务默认值。Value.Resolve() 会反复调用 LogValue;如果一直没有得到终值,超过内部调用上限后返回一个包含错误的 Value。因此排查重点是:返回链是否能收敛、是否把 Group 当成已经递归展开,以及自定义 Handler 有没有先 Resolve。

最稳妥的修复是让每个 LogValue 最终返回普通值或有限的 Group,并在 Handler 中通过 Resolve 后检查 Kind;不要在方法里直接返回自己或互相返回。

要点速览
  • Resolve 处理的是当前 Value 的 LogValuer 链,官方实现最多尝试 100 次。
  • 递归上限触发后得到的是 error Value;LogValue panic 也会被转成错误值。
  • Resolve 得到 Group 后不会替组内每个 Attr 再递归求值,Handler 需要明确自己的遍历策略。

先看清 Resolve 的递归边界

一个常见误区是把“递归引用”理解成 Go 运行时立刻栈溢出。slog 的入口通常是 Handler 调用 Resolve,它先判断当前值是不是 KindLogValuer,再调用一次 LogValue,如此循环。正常链路应当在某一层返回字符串、数字、时间或 Group;如果 A 返回 B,B 又返回 A,就没有稳定终点。

Go slog Value.Resolve 处理 LogValuer A、LogValuer B、终值和 error Value 的递归边界说明图

官方源码把调用次数上限定义为 100。达到上限后,返回值的底层内容是一个错误,错误文本会指出 LogValue called too many times 以及原始值类型。这个结果的含义是“求值没有收敛”,不是把原对象转换成了错误字符串。

三类写法要分开判断

第一类是有限包装:外层只返回一次 slog.String 或 slog.Group,这是正常用法。第二类是自引用,方法直接返回自己;第三类是互引用,A 返回 B、B 返回 A。后两类都应当修正数据模型或返回逻辑,而不是把 100 次当成可配置的业务重试次数。

type traceValue struct {
    ID string
}

func (v traceValue) LogValue() slog.Value {
    // 终止在普通字符串,避免返回 slog.Any("trace", v) 形成自引用。
    return slog.String("trace_id", v.ID)
}

type badValue struct{}

func (badValue) LogValue() slog.Value {
    // 错误示例:返回自身会让 Resolve 一直重复调用 LogValue。
    return slog.AnyValue(badValue{})
}

生产代码里还要注意接收者和值的类型。把一个指针包装进 slog.Any 后,它是否实现 LogValuer 取决于方法集;排查时可以打印 v.Kind(),不要只看 fmt.Sprint(v.Any()) 的外观。

Handler 里怎样处理替换结果

自定义 Handler 不应直接调用 attr.Value.LogValuer().LogValue() 并假设一次就结束。先 Resolve,才能获得官方定义的递归保护;随后按 Kind 处理普通值、Group 和错误值。Group 是一个边界:Resolve 返回 Group 后,组内 Attr 不会自动逐个递归 Resolve,是否遍历以及如何避免重复展开由 Handler 自己负责。

自定义 Handler 先 Resolve 再按 KindGroup、普通值和 error Value 分流的 Go slog 处理边界图
func resolvedText(a slog.Attr) (string, error) {
    v := a.Value.Resolve()
    // KindAny 可能承载 Resolve 产生的 error,先取出再决定如何记录。
    if v.Kind() == slog.KindAny {
        if err, ok := v.Any().(error); ok {
            return "", err
        }
    }
    if v.Kind() == slog.KindGroup {
        // Group 的成员是否继续 Resolve,要由遍历代码显式决定。
        return "group", nil
    }
    return v.String(), nil
}

如果这是日志链路,建议把错误值作为结构化字段记录,例如 resolve_error,同时保留原 Attr 的 key,便于定位是哪一个字段的 LogValue 没有收敛。不要在错误分支再次把同一个 Value 交给 Resolve,否则只会重复消耗排查时间。

用四组测试锁住边界

单元测试至少覆盖:一次返回字符串的正常值、自引用、互引用和 LogValue panic。测试不必依赖完整 JSON 日志;直接调用 slog.AnyValue(x).Resolve(),检查终值 Kind 或 Any() 中的 error,更容易把语义和 Handler 输出分开。

func TestResolveBoundary(t *testing.T) {
    cases := []struct {
        name string
        value any
        wantErr bool
    }{
        {"stable", traceValue{ID: "t-1"}, false},
        {"self-reference", badValue{}, true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got := slog.AnyValue(tc.value).Resolve()
            // 错误值只验证可识别的 error,不绑定整段运行时文本。
            _, isErr := got.Any().(error)
            if isErr != tc.wantErr {
                t.Fatalf("error=%v, want %v", isErr, tc.wantErr)
            }
        })
    }
}

实际项目再补一组互引用和 panic 测试,并把 Group 的成员策略单独测出来。这样升级 Go 或更换 Handler 时,失败会明确落在“求值没有收敛”还是“组内遍历重复”上。

相关问题与处理结论

把 100 次上限调大能解决问题吗?

不能。它只是防止无限循环,不能让自引用变成正确的业务值。应修改 LogValue 返回链,让它在有限次数内结束。

返回 Group 后为什么还有 LogValuer 没展开?

这是 Resolve 的边界设计:Group 本身已经解析完成,但组内属性不会由该次调用自动递归展开。需要展开时,在 Handler 的遍历逻辑中逐个处理并设置清晰的停止条件。

收尾检查

看到“错误值”时先确认它来自递归上限还是 panic,再检查 LogValue 是否返回自身、互相返回或把仍实现 LogValuer 的包装继续传下去。最后确认自定义 Handler 调用了 Resolve,并为普通值、Group 和错误值分别保留可检索的字段。

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