当前位置:首页 > 文章列表 > Golang > Go教程 > Go encoding/json Decoder Token 流式读取嵌套结构

Go encoding/json Decoder Token 流式读取嵌套结构

来源:17golang原创 2026-09-29 04:45:50 0浏览 收藏

用 encoding/json.Decoder.Token 读取嵌套 JSON 时,我更倾向于让 Token 只负责“走到正确的容器边界”,再让 Decode 负责当前那一个结构体。这样既不需要把整份文档解码成 map[string]any,也不必手工处理每个标量:顶层对象逐字段读取,目标数组逐元素解码,处理完一条就交给回调。

稳定的组合是:先用 Token() 消费 {、字段名和 [,在容器内部用 More() 判断是否还有成员,用 Decode(&item) 物化当前元素,最后显式消费匹配的 ] 与 }。未知字段如果是对象或数组,必须完整跳过整个值。

官方文档:https://pkg.go.dev/encoding/json

使用要点
  • Token 返回标量或 json.Delim,并保证对象、数组分隔符正确嵌套匹配。
  • More 只用于已经进入的数组或对象,不能拿它判断整个输入流是否结束。
  • 大数组可以逐元素 Decode;单个元素仍会被完整解码,所以内存边界是“一个元素”,不是零分配。
  • 数字需要保留原始十进制文本时先调用 UseNumber,错误位置可结合 InputOffset 记录。

先确定只保留哪些数据

我在设计这类解析器时,第一步不是马上写 for dec.More(),而是先画出数据生命周期。假设顶层对象包含小型 meta、可能很大的 items 数组,以及未来可能增加的 debug、extensions 等字段。目标是把 meta 留在内存里,把 items 一条条交给存储或计算函数,其他字段跳过。

这个边界很重要:如果先 Decode 到包含 []Item 的总结构体,数组还是会整体驻留内存;如果所有内容都按 Token 拆到标量级,代码又会充满类型断言和层级状态。实际工程里,容器层用 Token、业务对象用 Decode,通常是可读性和内存占用之间更舒服的折中。

数据部分读取方式生命周期
顶层对象Token + More只维护当前字段位置
metaDecode 到小结构体文档处理期间保留
items数组边界用 Token,元素用 Decode每次只保留当前元素
未知字段递归 skipValue消费后立即丢弃

Token 负责边界,Decode 负责当前元素

Token() 会返回下一个 JSON token。对象和数组的四个分隔符以 json.Delim 返回;对象键和字符串值是 string,布尔值是 bool,null 是 nil。逗号和冒号不会作为 token 返回。进入 { 或 [ 后,More() 才表示当前容器内是否还有下一个成员。

我会先写一个很小的边界函数,所有容器入口和出口都经过它。这样代码遇到结构不符时,错误不会退化成难懂的类型断言失败。

func expectDelim(dec *json.Decoder, want json.Delim) error {
	// 只接受指定的容器边界,并带上当前字节偏移。
	tok, err := dec.Token()
	if err != nil {
		return fmt.Errorf("读取分隔符失败(offset=%d): %w", dec.InputOffset(), err)
	}
	delim, ok := tok.(json.Delim)
	if !ok || delim != want {
		return fmt.Errorf("期望分隔符 %q,实际为 %v(offset=%d)", want, tok, dec.InputOffset())
	}
	return nil
}
Go io.Reader、Decoder、Token、json.Delim、More、Decode 与 Item 回调的静态结构关系图
图1:Token 与局部 Decode 的静态结构说明图;前者识别容器层级,后者只物化当前数组元素。

完整解析器:逐字段进入,逐元素交付

下面的解析器要求顶层必须是对象,items 必须是数组,并拒绝重复的 items 字段。每个数组元素解码后立即调用 handle;回调可以写数据库、累计统计或发送到下一阶段。回调失败时立即停止继续读取,让上层决定重试或记录失败。

type Meta struct {
	TraceID string `json:"trace_id"`
	Source  string `json:"source"`
}

type Item struct {
	ID      json.Number       `json:"id"`
	Tags    []string          `json:"tags"`
	Payload map[string]string `json:"payload"`
}

func parseDocument(r io.Reader, handle func(Item) error) (Meta, error) {
	dec := json.NewDecoder(r)
	dec.UseNumber() // 动态数字和 Token 数字保留为 json.Number。

	var meta Meta
	seenItems := false

	// 顶层必须从对象开始。
	if err := expectDelim(dec, '{'); err != nil {
		return meta, err
	}

	for dec.More() {
		// 对象中的下一个 Token 应当是字段名。
		keyToken, err := dec.Token()
		if err != nil {
			return meta, fmt.Errorf("读取字段名失败(offset=%d): %w", dec.InputOffset(), err)
		}
		key, ok := keyToken.(string)
		if !ok {
			return meta, fmt.Errorf("对象键不是字符串(offset=%d)", dec.InputOffset())
		}

		switch key {
		case "meta":
			// meta 很小,直接解码成结构体更清楚。
			if err := dec.Decode(&meta); err != nil {
				return meta, fmt.Errorf("解码 meta 失败(offset=%d): %w", dec.InputOffset(), err)
			}

		case "items":
			if seenItems {
				return meta, fmt.Errorf("items 字段重复(offset=%d)", dec.InputOffset())
			}
			seenItems = true

			// 进入数组后,每次只解码当前元素。
			if err := expectDelim(dec, '['); err != nil {
				return meta, err
			}
			for dec.More() {
				var item Item
				if err := dec.Decode(&item); err != nil {
					return meta, fmt.Errorf("解码 item 失败(offset=%d): %w", dec.InputOffset(), err)
				}
				if err := handle(item); err != nil {
					return meta, fmt.Errorf("处理 item %s 失败: %w", item.ID, err)
				}
			}
			if err := expectDelim(dec, ']'); err != nil {
				return meta, err
			}

		default:
			// 未知字段可能是标量、对象或数组,必须跳过完整值。
			if err := skipValue(dec); err != nil {
				return meta, fmt.Errorf("跳过字段 %q 失败(offset=%d): %w", key, dec.InputOffset(), err)
			}
		}
	}

	if err := expectDelim(dec, '}'); err != nil {
		return meta, err
	}
	if !seenItems {
		return meta, errors.New("缺少 items 字段")
	}

	// 顶层对象后只允许空白,发现第二个值时拒绝尾随数据。
	if tok, err := dec.Token(); err != io.EOF {
		if err != nil {
			return meta, fmt.Errorf("检查文档结尾失败(offset=%d): %w", dec.InputOffset(), err)
		}
		return meta, fmt.Errorf("对象后存在尾随 token %v(offset=%d)", tok, dec.InputOffset())
	}
	return meta, nil
}

这里最让我觉得实用的点,是 Decode 可以和 Token 在同一个 Decoder 上交替使用。只要调用发生在合法的值位置,Decode(&item) 会从当前数组元素开始消费一个完整 JSON 值;下一轮 More() 会自然落到下一个元素或数组结尾。

未知字段要完整跳过,不能只读一个 Token

第一次写这类代码时,很容易在 default 分支里只调用一次 dec.Token()。如果未知值是字符串,这看起来有效;如果未知值是对象,一次调用只会读走开头的 {,后面的字段会被误当成顶层字段,解析位置随即错乱。

skipValue 需要先消费值的第一个 token。遇到标量时已经完成;遇到对象或数组时则递归消费其中的每个值,最后再读取对应闭合分隔符。

func skipValue(dec *json.Decoder) error {
	// 先读取未知值的第一个 Token,标量到这里就已消费完。
	tok, err := dec.Token()
	if err != nil {
		return err
	}
	delim, isContainer := tok.(json.Delim)
	if !isContainer {
		return nil
	}

	switch delim {
	case '{':
		for dec.More() {
			// 对象成员先消费键,再递归跳过对应的值。
			key, err := dec.Token()
			if err != nil {
				return err
			}
			if _, ok := key.(string); !ok {
				return fmt.Errorf("未知对象的键不是字符串: %v", key)
			}
			if err := skipValue(dec); err != nil {
				return err
			}
		}
		return expectDelim(dec, '}')

	case '[':
		for dec.More() {
			// 数组成员可能继续嵌套对象或数组。
			if err := skipValue(dec); err != nil {
				return err
			}
		}
		return expectDelim(dec, ']')

	default:
		return fmt.Errorf("值位置出现意外闭合分隔符 %q", delim)
	}
}
Go skipValue、未知对象、未知数组、json.Delim、InputOffset 与包装错误的静态结构图
图2:未知值跳过与错误定位静态结构图;复合值需要消费到匹配的闭合分隔符,偏移用于补充错误上下文。

数字、偏移和 Reader 缓冲是三个容易忽略的边界

数字精度:Token 返回的数字默认按 float64 处理。调用 UseNumber 后,解码到接口值的数字会保留为 json.Number,之后可以根据业务选择 Int64、Float64 或保留字符串。结构体字段如果有明确整数类型,也可以直接声明为 int64,让 Decode 执行类型检查。

错误位置:InputOffset 表示最近返回 token 结束处与下一个 token 开始处的字节偏移。它适合包装到错误中帮助定位,但不是行列号;需要行号时,可以在输入层额外维护换行索引,或保存错误附近的受限片段。

预读行为:NewDecoder 有自己的缓冲,可能从底层 io.Reader 读取超过当前 JSON 值的字节。如果 JSON 后面还拼接了自定义协议内容,不能假设底层 Reader 正好停在对象末尾;官方提供的 Buffered 可访问 Decoder 已读入但尚未被 Token 或 Decode 消费的数据,并且该 reader 只在下一次解码调用前有效。

什么时候我会选 Token,什么时候不会

如果输入只是普通配置文件,整体尺寸可控,直接 Decode 到明确结构体通常更简单,也更容易配合 DisallowUnknownFields 做严格字段检查。Token 更适合“大容器里只关注一部分字段”“巨大数组逐条处理”“协议需要在不同字段采用不同策略”这三类场景。

Token 也不是自动的性能捷径。它减少的是整份数据同时驻留的需求,但每次 Token、类型断言、错误包装和单元素 Decode 仍有成本。若单个 item 本身就是几十兆字节,逐 item Decode 依然会占用相应内存;这时需要继续把 item 内部拆成 Token 层级,或者从数据生产端改成 NDJSON、分块记录等更适合流处理的格式。

场景建议原因
小型固定结构直接 Decode代码最短,结构约束清楚
大数组逐条入库Token + More + 单元素 Decode内存边界约为一个元素
只读取少数字段Token 导航并跳过其余值避免构造无用对象
未知字段必须报错结构体 Decode + DisallowUnknownFields严格模式比手工跳过更合适
连续独立 JSON 记录循环 Decode 或 NDJSON不必手工遍历每个 token

常见问题

More 可以判断输入流是否结束吗?

不可以。More 只回答当前对象或数组里是否还有元素。顶层结束应通过消费闭合分隔符,并按协议检查后续是 io.EOF、下一个独立 JSON 值,还是其他数据。

Token 会自动把对象键和值配对吗?

不会。对象内部的调用顺序是字段名 token、字段值 token,如此重复。代码需要知道当前处于对象键位置还是值位置,最简单的办法就是在 for dec.More() 中先读键,再立即消费一个完整值。

可以在数组循环里直接调用 Decode 吗?

可以。先用 Token 消费数组开头的 [,然后在 More() 循环内对当前元素调用 Decode,循环结束后再消费 ]。

为什么未知字段不能简单调用一次 Token?

一次 Token 只能完整消费一个标量,遇到对象或数组只会读到开头分隔符。必须递归读取内部成员直到匹配的闭合分隔符,否则后续解析层级会错位。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Lanerc动漫网页版GPU占用高怎么办?硬件加速与浏览器排查说明Lanerc动漫网页版GPU占用高怎么办?硬件加速与浏览器排查说明
上一篇
Lanerc动漫网页版GPU占用高怎么办?硬件加速与浏览器排查说明
GitHub Desktop 按提交恢复单个文件的操作
下一篇
GitHub Desktop 按提交恢复单个文件的操作
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    258次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    302次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    282次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    259次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    68次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码