当前位置:首页 > 文章列表 > Golang > Go问答 > Go 解析 JSON 流时 Token 边界错乱怎么办

Go 解析 JSON 流时 Token 边界错乱怎么办

来源:17golang原创 2026-10-18 04:14:38 0浏览 收藏

在排查 Go 的流式 JSON 读取时,我遇到过一种很像“Token 越界”的现象:数组第一个元素读完后,下一次拿到的不是预期值;对象字段读取到一半时,Token() 又返回了 ] 或 }。这通常不是 encoding/json 把数据打乱了,而是把词法 token、容器分隔符和完整值混成了同一层。

处理这类问题的关键是固定职责:Token() 负责逐个读取 JSON token,json.Delim 负责标出数组或对象的起止边界,More() 只判断当前容器里是否还有元素,Decode() 则负责消费一个完整的值。只要围绕当前容器配对读取,所谓的“边界错乱”就会变成一个可以定位的读取位置问题。

先用 Token() 读取并确认容器起点,再在对应容器内部用 More() 判断是否还有值;数组元素或对象值交给 Decode(),循环结束后再读取闭合分隔符。不要用一次 Token() 代替一次完整的业务对象读取。

先还原 Token 看似错位的现场

JSON 流里同时存在基本值和结构分隔符。字符串、数字、布尔值和 null 是基本值;[、]、{、} 是 json.Delim。逗号和冒号不会作为独立 token 返回,所以把返回序列直接打印出来时,看到的内容和原始 JSON 文本并不是一一对应的字符序列。

例如下面的数据包含一个对象和一个数组:

{"name":"go","ports":[80,443]}

按 token 观察时,可能依次看到对象起点、字段名、字段值、数组字段名、数组起点、两个数字、数组终点和对象终点。此时 ] 不是“多出来的数据”,而是告诉你数组已经结束;如果代码把它当成数组元素处理,后面的判断自然会错位。

JSON 字节流经过 json.Decoder 后由 Token、Delim 和数组对象边界组成的静态结构图
图1:静态结构说明图,查看 Token、Delim 与数组/对象边界的关系。

根因通常是把 Token 当成了完整业务值

Token() 的返回类型是 json.Token,它描述的是当前位置的下一个 JSON token,而不是“下一个数组元素对应的 Go 结构体”。当数组元素本身是对象或数组时,一个元素会包含多个 token;如果每次只调用一次 Token() 就把结果强行转成业务字段,读取游标就会落在元素内部。

一个常见的误区是把 More() 放在任意读取循环里。它的语义是“当前正在解析的数组或对象里是否还有另一个元素”,不是“整个输入流是否还有 token”,也不是“上一次 Token 是否代表一个有效值”。离开容器后继续依赖 More(),就会把容器边界和流结束混在一起。

数组和对象的边界要先配对

修复时可以先建立一个小的边界纪律:读到 [ 后,只在这个数组内部调用 More();读到 { 后,只在这个对象内部调用 More();循环结束后再读取对应的 ] 或 }。闭合 token 能帮助你确认当前容器确实已经结束。

下面的辅助函数只负责判断 token 是否为指定的容器分隔符。判断失败时返回错误,调用方就不会在错误边界上继续消费输入。

package main

import (
	"encoding/json"
	"fmt"
)

// expectDelim 检查当前 token 是否是调用方期待的容器边界。
func expectDelim(token json.Token, want json.Delim) error {
	delim, ok := token.(json.Delim)
	if !ok || delim != want {
		return fmt.Errorf("期待分隔符 %q,实际得到 %v", want, token)
	}
	return nil
}

这个检查不会改变读取位置,但能把“读到了不该出现的结束符”变成明确错误。它尤其适合嵌套数组或对象,因为嵌套层级一旦配错,错误通常会在后面才暴露。

数组元素交给 Decode 一次消费

如果数组元素是完整对象,就不要手工逐个取字段 token。先读取数组起点,再让 More() 判断是否仍有元素,每轮使用 Decode() 把一个对象消费到结构体中,最后读取数组终点。

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"strings"
)

type Item struct {
	Name string `json:"name"`
	Port int    `json:"port"`
}

// readItems 按数组边界读取完整对象,避免把对象内部 token 当成数组元素。
func readItems(dec *json.Decoder) ([]Item, error) {
	start, err := dec.Token()
	if err != nil {
		return nil, err
	}
	if err := expectDelim(start, '['); err != nil {
		return nil, err
	}

	items := make([]Item, 0, 4)
	for dec.More() {
		var item Item
		// Decode 会消费当前数组中的一个完整 JSON 值。
		if err := dec.Decode(&item); err != nil {
			return nil, err
		}
		items = append(items, item)
	}

	end, err := dec.Token()
	if err != nil {
		return nil, err
	}
	if err := expectDelim(end, ']'); err != nil {
		return nil, err
	}
	return items, nil
}

func main() {
	input := `[ {"name":"http","port":80}, {"name":"https","port":443} ]`
	items, err := readItems(json.NewDecoder(strings.NewReader(input)))
	if err != nil && err != io.EOF {
		fmt.Println("读取失败:", err)
		return
	}
	fmt.Println("读取到元素数:", len(items))
}

这里的分工很清楚:Token() 只读取起止边界,More() 只回答“容器里还有没有元素”,Decode() 负责把当前元素转换成 Item。如果数组元素改成嵌套对象,外层循环仍然不需要手工追踪内部字段。

对象字段要区分字段名和字段值

对象的 token 序列与数组不同:对象内部通常交替出现字段名和字段值。需要动态处理字段时,可以先读字段名,再根据字段名决定如何消费紧随其后的值;如果值的结构复杂,仍应交给 Decode() 或专门的递归函数。

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

// readObject 展示对象边界内的字段名和值如何配对读取。
func readObject(dec *json.Decoder) error {
	start, err := dec.Token()
	if err != nil {
		return err
	}
	if err := expectDelim(start, '{'); err != nil {
		return err
	}

	for dec.More() {
		keyToken, err := dec.Token()
		if err != nil {
			return err
		}
		key, ok := keyToken.(string)
		if !ok {
			return fmt.Errorf("对象字段名不是字符串:%v", keyToken)
		}

		var value json.RawMessage
		// RawMessage 让当前字段值整体落地,避免只消费值的第一个 token。
		if err := dec.Decode(&value); err != nil {
			return fmt.Errorf("字段 %q 解码失败:%w", key, err)
		}
		fmt.Printf("字段 %s 的 JSON 值长度:%d\n", key, len(value))
	}

	end, err := dec.Token()
	if err != nil {
		return err
	}
	if err := expectDelim(end, '}'); err != nil {
		return err
	}
	return nil
}

var _ = io.EOF

对象循环里,Token() 读取的是字段名,字段值则由 Decode() 一次消费。这里使用 json.RawMessage 是为了保留值的完整 JSON 片段;实际项目也可以替换成具体结构体或 map[string]any。不能因为字段值恰好是字符串,就把所有字段都按字符串 token 读取。

Decoder、Token、More、容器元素、Decode 和结构化结果之间关系的静态结构图
图2:静态结构说明图,查看 More 与 Decode 如何围绕容器边界协作。

用 InputOffset 把错位定位到输入位置

当输入来自网络或文件时,仅打印 token 内容往往不够。Decoder.InputOffset() 返回当前解码器位置的字节偏移:它指向最近一次返回 token 的结尾与下一个 token 的开头。可以把偏移写入错误日志,帮助判断问题发生在字段名、嵌套容器还是闭合分隔符附近。

func decodeWithOffset(dec *json.Decoder, dst any) error {
	if err := dec.Decode(dst); err != nil {
		// 偏移用于定位输入边界,不代表业务字段的字符下标。
		return fmt.Errorf("JSON 解码失败,offset=%d:%w", dec.InputOffset(), err)
	}
	return nil
}

如果错误发生在 Token() 或 Decode() 之后,记录偏移和最近一次已识别的边界,比记录“第几个 token”更容易和原始输入对应。

这几种写法最容易再次触发边界问题

  • 用 for dec.More() 处理整个输入流,却没有先读入 [ 或 {。
  • 读到数组起点后,每轮只调用一次 Token(),把对象起点、字段名或结束符误当成元素。
  • 读完 More() 循环后不读取闭合分隔符,直接开始解析外层下一个值。
  • 把 More() 当作 EOF 判断;流结束应该处理 Token() 或 Decode() 返回的 io.EOF。
  • 嵌套容器只维护一个“当前深度”变量,却没有让每层函数负责自己的起点和终点。

一张表记住四个方法的边界

方法或类型它回答的问题不要把它当成
Token()下一个 JSON token 是什么?一个完整业务对象
json.Delim数组或对象从哪里开始、结束?普通业务值
More()当前数组或对象里还有元素吗?整个输入流还有数据吗?
Decode()如何消费下一个完整 JSON 值并映射到目标?只读取一个字段名

复盘结论

这类问题的影响通常不是 JSON 内容被修改,而是读取代码提前消费或错误解释了容器边界。排查时先打印并确认起止 Delim,再检查 More() 是否确实位于对应容器内部,最后确认每个业务元素是否由一次 Decode() 完整消费。

如果仍然出现错位,记录 InputOffset()、最近一次 token 类型和当前容器层级;这些信息足以把问题收敛到“字段名和值配对错误”“嵌套边界未闭合”或“把流结束误当成容器结束”中的一类。

相关问题

Token() 会返回逗号和冒号吗?

不会。逗号和冒号用于 JSON 语法分隔,不会作为独立 token 返回;数组和对象的起止符会以 json.Delim 返回。

More() 可以用来判断文件是否读完吗?

不可以。它只描述当前数组或对象的下一个元素是否存在。整个流是否结束应根据 Token() 或 Decode() 返回的 io.EOF 判断。

数组元素不是对象时还需要 Decode() 吗?

可以直接对数字、字符串等值使用 Decode(),也可以在确实需要逐 token 处理时使用 Token()。关键是先明确你处理的是完整值,还是故意处理词法 token。

资料出处:https://pkg.go.dev/encoding/json#Decoder.Token、https://pkg.go.dev/encoding/json#Decoder.More、https://pkg.go.dev/encoding/json#Decoder.Decode。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go JSON 类型错误怎样定位字段和输入偏移Go JSON 类型错误怎样定位字段和输入偏移
上一篇
Go JSON 类型错误怎样定位字段和输入偏移
Go 十六进制字符串包含空格时怎样处理
下一篇
Go 十六进制字符串包含空格时怎样处理
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之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模型性能。
    512次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    572次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    597次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    549次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    366次使用