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

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

来源:17golang原创

时间:2026-10-18 04:14:38 186浏览 收藏

在排查 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>