json.rawmessage 是解析未知嵌套 json 的最稳妥起点,它延迟解析、避免类型冲突与 panic,配合自定义 unmarshaljson、json.decoder 流式处理及 json.number 精度控制可安全应对递归结构。

解析嵌套层级未知的 JSON 时,json.RawMessage 是最稳妥的起点
Go 的 json.Unmarshal 默认要求结构体字段类型与 JSON 字段严格匹配,一旦遇到递归结构(比如无限嵌套的 "children" 数组),硬编码 struct 会立刻失效。直接用 map[string]interface{} 虽然能绕过类型检查,但后续取值繁琐、类型断言易 panic,且无法复用已有业务逻辑。
真正实用的做法是:把不确定是否递归的字段声明为 json.RawMessage,延迟解析。它本质是未解析的 JSON 字节切片,不触发解码,也不校验结构。
- 适用于字段可能为对象、数组、null,或嵌套自身(如树形节点)的场景
-
json.RawMessage必须是字节切片类型,不能是string或指针;赋值前需确保数据有效(比如非空、合法 JSON 片段) - 后续调用
json.Unmarshal解析该字段时,才真正触发类型校验——这时你已能根据上下文决定用哪个 struct 或继续用json.RawMessage
type Node struct {
ID int `json:"id"`
Name string `json:"name"`
Children json.RawMessage `json:"children"` // 不急着解析
}
用自引用 struct + UnmarshalJSON 实现安全递归解析
如果确定结构是“节点包含自身类型的子节点数组”,可以定义自引用 struct,并实现 UnmarshalJSON 方法。这是比全用 map[string]interface{} 更类型安全、更易维护的方式。
关键点在于:在自定义解码逻辑中,先判断 children 字段是否存在、是否为数组、是否为空,再逐个递归解析。避免无限循环或 panic。
立即学习“go语言免费学习笔记(深入)”;
- 必须检查
len(data)和json.Valid(data),否则空字段或非法 JSON 会导致Unmarshal失败并 panic - 递归调用
json.Unmarshal时,传入的是子节点的原始字节(从json.RawMessage提取),不是整个父对象 - 不要在
UnmarshalJSON中直接调用json.Unmarshal(data, &n)—— 这会触发无限递归;应先解到临时 map 或用json.Decoder流式处理
func (n *Node) UnmarshalJSON(data []byte) error {
type Alias Node // 防止无限递归
aux := &struct {
Children json.RawMessage `json:"children"`
*Alias
}{
Alias: (*Alias)(n),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(aux.Children) == 0 || bytes.Equal(aux.Children, []byte("null")) {
return nil
}
var children []Node
if err := json.Unmarshal(aux.Children, &children); err != nil {
return err
}
n.Children = children
return nil
}
json.Decoder 配合流式读取,避免大文件 OOM
当 JSON 文件体积较大(几十 MB 以上)、且递归层级深时,一次性 json.Unmarshal 整个文件会把全部内容加载进内存,极易触发 OOM。此时应改用 json.Decoder,边读边解析,按需构建节点。
特别适合处理扁平化但逻辑上递归的结构,比如日志事件嵌套上下文、配置项继承链等。
-
json.Decoder.Token()可以逐个读取 token(字符串、数字、{、[、]、} 等),手动控制解析流程 - 用栈(
[]*Node)维护当前路径上的父节点,遇到{就 push 新节点,遇到}就 pop 并挂载到父节点的Children中 - 错误处理必须覆盖所有 token 类型分支,否则容易卡在某个 token 上无限循环
dec := json.NewDecoder(file)
for dec.More() {
var node Node
if err := dec.Decode(&node); err != nil {
// 处理单个节点解析失败,不影响后续
continue
}
// 挂载到树中...
}
别忽略 json.Number 和空值对递归逻辑的影响
默认情况下,json.Unmarshal 把数字解析成 float64,但某些 API 返回的 ID 是整数且可能超 float64 精度(如 64 位时间戳、MongoDB ObjectId)。若递归结构里含这类字段,直接用 float64 接收会导致精度丢失或比较失败。
另一个常见坑是 null:JSON 中的 "children": null 和 "children": [] 在 Go 里行为完全不同。前者解到 []Node 会是 nil,后者是空 slice——但很多业务代码没区分这两者,导致遍历时 panic 或跳过合法分支。
- 启用
UseNumber()让 decoder 返回json.Number,后续按需转int64或string - 对可选的递归字段(如
Children),优先定义为指针类型(*[]Node)或使用自定义 unmarshal,显式处理null、缺失、空数组三种情况 - 测试用例必须覆盖
null、空数组、单层、多层嵌套、深层但某中间节点为null等边界
null 或类型错乱)、以及内存模型是否允许一次性加载。漏掉任意一个,线上就容易出 silent fail。










