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

Go slog JSON 日志为什么混入不可序列化字段

来源:17golang原创

时间:2026-10-23 03:34:29 470浏览 收藏

我第一次看到这类日志时,以为是采集器把 JSON 改坏了:某个字段明明传入了对象,最终却变成了 !ERROR:json: unsupported type: chan int。真正的原因通常在应用进程内:slog.JSONHandler 会把 Any 值交给 encoding/json,而 channel、函数、复数等值不能直接编码。编码失败时,Handler 不返回错误,而是把错误文本写回当前字段。

看到 !ERROR:json,先不要查日志平台。先检查这个字段及其嵌套值是否包含 JSON 不支持的运行时对象;修复方式是记录稳定摘要,而不是把整个对象原样塞进 slog.Any。

本文只讨论 Go 标准库 log/slog 的 JSON 输出边界,不讨论采集器、索引模板或日志平台的二次加工。

为什么日志字段会变成 !ERROR:json

JSONHandler 的普通属性会按 encoding/json.Encoder 编码。顶层 Attr 如果直接放入一个 error,Handler 会调用它的 Error 方法;但嵌套在 map、slice 或结构体里的值,仍由 JSON 编码器整体处理。

slog Any、JSONHandler、encoding/json 和 !ERROR 字符串之间的编码边界说明图
图1:slog JSON 编码边界说明图,展示不可序列化值如何落入 !ERROR 字符串。这是静态说明图,不是截图或运行证据。

例如,下面把一个 channel 藏进 map。问题不在 map 本身,而在 map 的值树里出现了 chan int:

package main

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

func main() {
    // 用 JSONHandler 观察 Any 值的编码边界;channel 不能被 encoding/json 编码。
    var buf bytes.Buffer
    logger := slog.New(slog.NewJSONHandler(&buf, nil))
    logger.Info("request finished", "payload", map[string]any{
        "request_id": "req-17",
        "stream":     make(chan int),
    })

    // 这里把说明结果写到标准输出;日志内容仍是一条 JSON 记录。
    _, _ = os.Stdout.Write(buf.Bytes())
}
{"level":"INFO","msg":"request finished","payload":"!ERROR:json: unsupported type: chan int"}

这也解释了两个容易误判的现象:第一,整条日志通常还在,只有当前字段被替换;第二,调用方感知不到一个普通的编码返回错误,因为 JSONHandler.Handle 会把编码失败格式化为字符串后继续写出。

先区分三种值,定位会快很多

排查时我会把字段拆成三类,而不是笼统地说“JSON 有问题”。

值的来源典型写法处理结果
可直接编码的值slog.String、数字、布尔、普通结构体按 JSON 值输出
顶层 error Attrslog.Any("err", err)调用 Error(),输出字符串
嵌套不可编码值map、slice、结构体里含 channel 或 func当前字段变为 !ERROR:json: ...

因此,看到这类字符串时,先回到产生该字段的 slog.Any 或 Logger.With 调用,沿着 map、slice 和结构体继续找,不要只盯着字段名。

把运行时对象改成稳定摘要

最小修正不是把值强行转成字符串,而是定义日志需要的字段。连接、channel、函数、锁或请求对象往往带有运行时状态,直接序列化既可能失败,也可能泄露不该进入日志的内容。

运行时对象经过 LogValuer 变成稳定摘要后交给 JSONHandler 的结构图
图2:可序列化日志边界结构图,展示 LogValuer 如何只输出稳定摘要字段。这是静态说明图,不是截图或运行证据。

如果一个类型经常被记录,可以让它实现 slog.LogValuer。下面只暴露状态和容量,不把内部 channel 写进日志:

package main

import "log/slog"

type workerPool struct {
    name string
    jobs chan int
}

// LogValue 把运行时对象收敛为稳定字段,避免把 channel 传给 JSONHandler。
func (p workerPool) LogValue() slog.Value {
    // 只输出读者排障真正需要的摘要,不泄露内部任务内容。
    return slog.GroupValue(
        slog.String("name", p.name),
        slog.Int("capacity", cap(p.jobs)),
    )
}

func logPool(logger *slog.Logger, pool workerPool) {
    // LogValuer 会在 Handler 处理该属性时解析为可编码的 group。
    logger.Info("worker pool ready", slog.Any("pool", pool))
}

如果对象只使用一次,也可以在调用点显式构造 slog.Group。关键是把“日志模型”和“运行时对象”分开:

// 只记录稳定摘要;不要把 pool.jobs 原样放入 slog.Any。
logger.Info("worker pool ready",
    slog.Group("pool",
        slog.String("name", pool.name),
        slog.Int("capacity", cap(pool.jobs)),
    ),
)

修复后怎么判断已经处理对了

复查时我只看日志字段的契约,不追求把对象“完整打印出来”。可以按下面三点检查:

  1. 同一种对象在不同调用点输出相同的字段名和类型,不能一处是对象、一处是长字符串。
  2. 字段树里不再包含 channel、func、不可控的自定义 Marshaler 等运行时值;必要信息已经转换为字符串、数字或 group。
  3. 敏感字段在 LogValue 或显式 group 中被主动省略,不能因为“方便排查”把整个请求对象带进日志。

如果仍出现 !ERROR:json,继续沿着被替换的字段向下拆分。尤其要注意:一个顶层结构体即使看起来普通,只要某个导出字段或自定义 MarshalJSON 返回错误,整个该字段仍可能触发 JSONHandler 的兜底文本。

常见问题

为什么直接记录 error 通常没有这个问题?

因为 JSONHandler 对作为 Attr 值的 error 有专门处理,会调用 Error() 输出字符串。但 error 如果嵌在 map、slice 或结构体内部,就回到 encoding/json 的普通规则,不能把这个特例扩大到整个对象树。

能不能用 fmt.Sprint 统一解决?

可以临时止血,但会丢掉结构化字段,也可能把内部状态和敏感内容一股脑写进日志。长期方案优先使用显式 slog.Group 或 LogValuer,让字段集合成为稳定契约。

为什么不直接让 Handler 返回错误?

这是内置 JSONHandler 的设计:单条记录的某个值编码失败时,Handler 用 !ERROR: 字符串保留这条日志。应用如果需要严格失败策略,应在进入 slog 前自行构造可编码的日志 DTO,而不是依赖 Handler 作为数据校验器。

总结一下:!ERROR:json 不是日志平台凭空混入的字段,而是 slog.JSONHandler 对编码失败的可见兜底。找到产生 slog.Any 的对象,拆掉不可序列化运行时值,再用稳定摘要或 LogValuer 输出,问题就会从“日志偶尔变形”变成可维护的字段契约。

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