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

jsontext按Token读取超大JSON

来源:17golang原创

时间:2026-11-15 03:08:10 246浏览 收藏

处理一个几 GB 的 JSON 文件时,最容易踩的坑不是 JSON 语法,而是把整份文档一次性反序列化成 Go 结构体。Go 1.27 提供的 encoding/json/jsontext 更接近 JSON 的语法层:用 Decoder.ReadToken 逐个读取结构符号、字段名、字符串、数字和字面量,业务代码只保留当前需要的状态。

官方地址:https://pkg.go.dev/encoding/json/jsontext

如果目标是扫描超大 JSON 中的少数字段,优先把输入接到 jsontext.NewDecoder,循环调用 ReadToken,把 io.EOF 当作正常结束;不要把每个 Token 追加到切片。需要跨下一次读取保留原始 Token 时,再调用 Clone。

先确定:你要 Token 还是完整 Value

jsontext 同时提供 ReadToken 和 ReadValue。前者读取一个词法 Token,例如 {、字段名、数字或 ];后者读取一个完整 JSON 值,完整对象和数组也属于 Value。两条路线都能流式推进,但内存边界和处理粒度不同。

需求优先选择原因
扫描字段名、统计结构、过滤少数成员ReadToken每次只处理当前 Token,状态可由业务代码控制
把一个完整对象交给下游组件ReadValue一次得到完整 JSON 值,调用方不必自己拼接边界
保存从解码器读出的 TokenToken.Clone原始 Token 的数据只保证在下一次 Decoder 调用前有效
jsontext 从 io.Reader 流式读取超大 JSON 并按 Token 输出的结构示意图
图1:jsontext 按 Token 读取超大 JSON 的静态结构示意图,不是运行截图。

从任意 io.Reader 创建流式 Decoder

文件、HTTP 响应体和网络连接都可以实现 io.Reader,所以扫描函数不必绑定具体输入来源。下面的函数只接收 Reader,并把每个 Token 的原始显示形式交给回调;它不会把整份 JSON 放进 []byte 或结构体。

package main

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

// scanTokens 逐个读取 JSON Token,调用方决定如何消费当前 Token。
func scanTokens(r io.Reader, visit func(jsontext.Token, int) error) error {
    dec := jsontext.NewDecoder(r)

    for {
        tok, err := dec.ReadToken()
        if err == io.EOF {
            // 输入流正常结束,不把 EOF 当成失败。
            return nil
        }
        if err != nil {
            // 语法错误或底层 Reader 错误都交给上层处理。
            return err
        }

        // StackDepth 表示当前处于多少层对象或数组嵌套。
        if err := visit(tok, dec.StackDepth()); err != nil {
            return err
        }
    }
}

func main() {
    err := scanTokens(strings.NewReader(`{"items":[{"id":1},{"id":2}]}`), func(tok jsontext.Token, depth int) error {
        // String 对字符串返回解码后的值,对其他 Token 返回原始表示。
        fmt.Printf("depth=%d token=%s\n", depth, tok.String())
        return nil
    })
    if err != nil {
        fmt.Println("scan failed:", err)
    }
}

上面示例的 main 需要额外导入 strings 才能作为完整演示;生产代码通常把文件或 HTTP Body 直接传给 scanTokens。示例故意只打印当前 Token,不把 Token 收集到切片,这才是大文档扫描时的关键。

循环处理时,EOF 和错误要分开

ReadToken 返回 io.EOF 代表没有更多 Token,是成功路径;其他错误则可能表示 JSON 语法不完整、输入截断或底层 Reader 失败。把所有错误都当作 EOF,会让损坏的文件看起来像是成功处理。

func countNumbers(r io.Reader) (int, error) {
    dec := jsontext.NewDecoder(r)
    count := 0

    for {
        tok, err := dec.ReadToken()
        if err == io.EOF {
            // 全部 Token 已读完,返回累计结果。
            return count, nil
        }
        if err != nil {
            // 不吞掉语法错误,避免把半份数据当成完整结果。
            return 0, fmt.Errorf("read JSON token: %w", err)
        }

        // Kind 可用于区分字符串、数字和对象/数组边界。
        if tok.Kind() == jsontext.KindNumber {
            count++
        }
    }
}

这里的计数条件只用于展示处理位置,实际项目应按当前 Go 版本的 jsontext.Kind 常量表达数字类型,不要依赖字符字面量猜测 Token 类型。更常见的做法是先根据字段名维护一个小状态机,再对目标值调用 tok.Int()、tok.Uint() 或 tok.Float(),并检查转换错误。

用字段状态和嵌套深度筛选目标数据

按 Token 读取并不意味着业务逻辑必须复杂。面对类似 {"items":[...]} 的文档,可以只保留“最近读到的字段名”“是否正在目标数组内”“当前深度”几个变量;读到目标值后立即处理,读到结束分隔符时清除状态。

func scanItemIDs(r io.Reader, handle func(string) error) error {
    dec := jsontext.NewDecoder(r)
    wantID := false

    for {
        tok, err := dec.ReadToken()
        if err == io.EOF {
            // 没有更多 Token,扫描成功结束。
            return nil
        }
        if err != nil {
            // 将 JSON 语法错误原样包装后返回。
            return fmt.Errorf("scan item ids: %w", err)
        }

        if tok.Kind() == jsontext.KindString {
            text := tok.String()
            if text == "id" {
                // 下一个 Token 是字段值,交给后续分支处理。
                wantID = true
                continue
            }
        }
        if wantID {
            // 这里示意把当前值交给业务层;真实代码应按 Kind 转换数值。
            if err := handle(tok.String()); err != nil {
                return err
            }
            wantID = false
        }
    }
}

生产代码还要把字段状态和对象层级绑定起来,避免嵌套对象里同名的 id 被误处理。StackDepth 能帮助你记录当前嵌套深度;如果需要精确判断当前路径,应维护一个与对象/数组边界同步的小型路径栈,而不是依赖“最近一次字段名”。

ReadToken 与 ReadValue 的边界

ReadToken 适合“看见一个就处理一个”,但它不会代表完整对象或数组;ReadValue 会返回一个完整的 JSON Value,适合把单个成员交给另一个解析器。选择时要看下游需要的最小单位,而不是简单追求 API 数量更少。

jsontext ReadToken 与 ReadValue 的选择边界和 Token Clone 生命周期示意图
图2:ReadToken 与 ReadValue 的静态选择示意图,底部生命周期提示对应官方 API 约定。

另一个容易被忽略的边界是 Token 的生命周期。ReadToken 返回的原始 Token 可能由 Decoder 的内部缓冲区支持;下一次 PeekKind、ReadToken、ReadValue 或 SkipValue 调用后,不能继续把它当作长期对象使用。若要放进队列、异步任务或跨循环保存,先复制:

func keepFirstToken(r io.Reader) (jsontext.Token, error) {
    dec := jsontext.NewDecoder(r)
    tok, err := dec.ReadToken()
    if err != nil {
        return jsontext.Token{}, err
    }

    // Clone 让 Token 脱离 Decoder 的内部缓冲区,便于跨下一次读取保存。
    saved := tok.Clone()
    return saved, nil
}

大文档场景下的内存和失败边界

  • 文档总大小不等于单次内存:按 Token 扫描通常不需要保留整份文档,但单个超长字符串或数字仍可能成为最大瞬时 Token。
  • 不要无界缓存:如果要把结果交给下游,用有界队列、批量提交或背压,不要把所有 Token 再复制一份。
  • Reader 错误不能吞:网络中断、文件截断和语法错误应和正常 EOF 分开记录。
  • 路径状态要有边界:同名字段可能出现在不同层级,使用对象/数组边界更新路径栈。
  • 版本要对齐:encoding/json/jsontext 是 Go 标准库的语法层 API,部署环境应与文章示例所用 Go 版本一致。

小结:把“整份解码”改成“当前 Token 决策”

jsontext 按 Token 读取超大 JSON 的核心不是一段特殊的循环,而是重新划分责任:Decoder 负责语法推进,业务代码只保留字段、路径和结果所需的最小状态;io.EOF 是正常结束,其他错误必须向上返回;需要长期保存的 Token 用 Clone 脱离内部缓冲区。这样才能在输入从几十 MB 增长到 GB 级时,仍然让内存上限由“当前 Token 和业务窗口”决定,而不是由整个文档决定。

常见问题

ReadToken 会把整个 JSON 文件读进内存吗?

不会把完整文档一次性解码成结构体,但 Decoder 仍会缓冲并解析当前 Token;超长字符串本身仍可能占用较多内存。

什么时候应该改用 ReadValue?

当下游需要一个完整对象或数组,而不是逐个处理结构符号时,ReadValue 更直接;它的内存边界应按单个 Value 的大小评估。

为什么不能直接把 Token 存起来以后用?

由 Decoder 读取的原始 Token 只保证在下一次 Decoder 调用前有效。跨循环、异步或队列保存时调用 Clone。

发现 io.EOF 后还要再读一次吗?

不需要。对一次完整输入,io.EOF 表示 Token 流已经正常结束;重复读取没有意义,应该返回累计结果。

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