Go 1.27 正式推出 encoding/json/v2:默认拒绝重复键和非法 UTF-8,反序列化最快提速 10 倍

举报
Eveline 发表于 2026/11/06 15:19:24 2026/11/06
【摘要】 Go 官方博客:encoding/json/v2 与 jsontext 在 Go 1.27 正式可用。v2 默认拒绝非法 UTF-8 与重复键、字段名大小写敏感、nil 切片输出空数组,Unmarshal 最快达 v1 的 10 倍;v1 已改由 v2 实现,可按调用点渐进迁移。本文全文翻译,附升级自查清单。

原文信息:本文译自 Go 官方博客文章《A new Go API for JSON》,作者 Michael Pratt、Joe Tsai、Daniel Martí、Johan Brandhorst-Satzkorn、Roger Peppe、Chris Hines、Damien Neil,发布于 2026 年 11 月 4 日。原文链接:https://go.dev/blog/jsonv2 。原文采用 CC BY 4.0 许可,代码采用 BSD 许可。本文为翻译,原文中的架构示意图请见原文链接;文末附译者注。

译者导语:Go 1.27 起,encoding/json/v2 和 encoding/json/jsontext 结束实验、正式进入标准库。v2 默认更严格(拒绝非法 UTF-8 和重复键、字段名大小写敏感、nil 切片输出 []),反序列化最快可达 v1 的 10 倍,而且 v1 本身已经改由 v2 实现,两者可以在同一个程序里混用。这篇官方文章讲清了为什么要另起一个大版本、新 API 长什么样、行为有哪些不同、怎样渐进迁移——准备升级 Go 1.27 的团队值得通读一遍。


引言

JSON(JavaScript Object Notation,见 RFC 8259)是一种简单的数据交换格式,也是互联网上最流行的数据格式,Go 程序大量读写它。早在 Go 1.0 之前,Go 标准库就已经支持 JSON。我们第一次撰文介绍 Go 的 JSON 支持,已经是 15 年多以前的事了!

软件包会随着用户需求不断演进,encoding/json 也不例外。去年,我们介绍过一套新的实验性 JSON API,目标是带来大家期待已久的改进和修复。

经过更多反馈和打磨,新的 encoding/json/v2 和 encoding/json/jsontext 包已在 Go 1.27 中稳定并正式可用!本文是当初介绍该实验那篇文章的更新版,会说明 encoding/json 存在哪些问题,并介绍这两个新包。

encoding/json 的问题

总体来看,encoding/json 经受住了时间的考验。把任意 Go 类型按某种默认形式序列化(marshal)为 JSON、再反序列化(unmarshal)回来,同时允许自定义表示方式——这种设计被证明非常灵活。不过,自它推出以来,用户陆续发现了不少缺点。

行为缺陷

encoding/json 有以下几类行为缺陷:

  • 对 JSON 语法的处理不够严谨:这些年 JSON 的标准化程度越来越高,以便不同程序之间能正确通信。总体趋势是解码器越来越严格,会拒绝有歧义的输入,降低两种实现对同一个 JSON 值给出不同(但都"成功")解释的可能性。
    • encoding/json 目前接受非法的 UTF-8,而最新的互联网标准(RFC 8259)要求必须是合法 UTF-8。默认行为应当在遇到非法 UTF-8 时报错,而不是悄悄造成数据损坏,给下游埋下隐患。
    • encoding/json 目前接受成员名重复的对象。RFC 8259 没有规定如何处理重复名称,实现可以任选一个值、合并、丢弃或报错。存在重复名称的 JSON 值,其含义并没有公认的解释。这一点可能在安全场景中被攻击者利用,而且已经被利用过(例如 CVE-2017-12635)。默认行为应当偏向安全,拒绝重复名称。
  • 泄露切片和映射的 nil 状态:JSON 经常用于和其他程序通信,而有些 JSON 实现不允许把 null 反序列化到预期为数组或对象的类型里。由于 encoding/json 会把 nil 切片或映射序列化成 JSON null,对方反序列化时就可能出错。一项调查显示,大多数 Go 用户希望 nil 切片和映射默认序列化为空数组或空对象。
  • 大小写不敏感的反序列化:反序列化时,JSON 对象的成员名按大小写不敏感的方式匹配到 Go 结构体字段名。这个默认行为出人意料,存在潜在安全隐患,也限制了性能。
  • 方法调用不一致:由于实现细节,encoding/json 对声明在指针接收者上的 MarshalJSON 方法调用不一致。这虽然被视为 bug,却没法修,因为太多应用依赖现有行为。

API 不足

encoding/json 的 API 有些地方容易踩坑,或者限制太多:

  • 很难正确地从 io.Reader 反序列化。用户常写 json.NewDecoder(r).Decode(v),但这样无法拒绝输入末尾多余的垃圾数据。
  • 选项可以设置在 Encoder 和 Decoder 类型上,却不能用于 Marshal 和 Unmarshal 函数。同样,实现了 Marshaler 和 Unmarshaler 接口的类型也用不上这些选项,选项没法沿调用栈往下传。比如调用自定义的 UnmarshalJSON 方法时,Decoder.DisallowUnknownFields 选项就失效了。
  • Compact、Indent 和 HTMLEscape 函数只能写入 bytes.Buffer,而不是 []byte 或 io.Writer 这类更灵活的目标,用起来不方便。

性能限制

抛开内部实现细节不谈,公开 API 本身就决定了它的一些性能上限:

  • MarshalJSON:MarshalJSON 接口方法强制实现方分配并返回一个 []byte。此外,按语义要求,encoding/json 还必须校验返回结果是合法 JSON,并重新格式化以匹配指定的缩进。
  • UnmarshalJSON:UnmarshalJSON 接口方法要求传入一个完整的 JSON 值(后面不能有多余数据)。这迫使 encoding/json 在调用 UnmarshalJSON 之前先完整解析一遍,才能确定这个值在哪里结束;之后 UnmarshalJSON 方法自己还得再解析一遍。
  • 缺少流式处理:虽然 Encoder 和 Decoder 基于 io.Writer 或 io.Reader 工作,但它们会把整个 JSON 值缓冲在内存里。用于逐个读取 token 的 Decoder.Token 方法分配开销很大,而且没有对应的写 token 的 API。

更糟的是,如果 MarshalJSON 或 UnmarshalJSON 方法内部又递归调用 Marshal 或 Unmarshal,性能就会退化成平方级。

为什么不直接修 encoding/json

为一个包推出不兼容的新大版本,是需要慎之又慎的决定。如果可能,我们应该尽量修复现有的包。

添加新功能相对容易,改变已有功能却很难。不幸的是,上面这些问题都是现有 API 的固有结果,在 Go 1 兼容性承诺的约束下几乎不可能修复。

理论上我们可以另起名字,比如 MarshalV2 或 UnmarshalV2,但这相当于在同一个包里再造一个平行的命名空间。于是就有了 encoding/json/v2(下文简称 v2):我们可以在独立的 v2 命名空间里做这些改动,与 encoding/json(下文简称 v1)区分开来。

地基:encoding/json/jsontext

在讨论 v2 API 之前,先介绍 encoding/json/jsontext 包,它为 Go 的 JSON 改进打下了基础。

Go 中的 JSON 序列化可以拆成两个主要部分:

  • 语法层:按 JSON 语法规则处理 JSON;
  • 语义层:定义 JSON 值与 Go 值之间的对应关系。

我们用"编码/解码"(encode/decode)指语法层功能,用"序列化/反序列化"(marshal/unmarshal)指语义层功能。我们希望把纯粹的编码功能和序列化功能清楚地区分开。

原文用一张示意图展示了这种分层(图见原文链接):紫色块表示类型,蓝色块表示函数或方法,箭头方向大致表示数据流向。图的下半部分由 jsontext 包实现,只关心语法;上半部分由 json/v2 包实现,负责给下半部分处理的语法数据赋予语义。

jsontext 的基本 API 如下:

package jsontext

type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error

type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)

type Kind byte
type Value []byte
func (Value) Kind() Kind
type Token struct { ... }
func (Token) Kind() Kind

jsontext 包提供在语法层面操作 JSON 的功能,名字来自 RFC 8259 第 2 节——那里把 JSON 数据的语法直接称为 JSON-text。由于只在语法层面工作,它不依赖 Go 反射。

Encoder 和 Decoder 支持编码、解码 JSON 值和 token。构造函数接受可变参数形式的选项,用来调整编码和解码的具体行为。与 v1 中的 Encoder、Decoder 不同,jsontext 中的新类型不再混淆语法和语义,并且真正以流式方式工作。

JSON 值是一个完整的数据单元,在 Go 中用一个具名的 []byte 类型(Value)表示。它与 v1 中的 RawMessage 完全相同,RawMessage 现在就是 jsontext.Value 的类型别名。从语法上讲,一个 JSON 值由一个或多个 JSON token 组成。JSON token 在 Go 中用不透明的 Token 类型表示,带有构造函数和访问方法。它类似 v1 中的 Token,但设计上可以零分配地表示任意 JSON token。

为了解决 MarshalJSON 和 UnmarshalJSON 接口方法的根本性能问题,我们需要一种高效的方式,把 JSON 当作 token 和值组成的流来编码和解码。v2 为此引入了 MarshalJSONTo 和 UnmarshalJSONFrom 两个接口方法,它们直接操作 Encoder 或 Decoder,让方法实现能以纯流式方式处理 JSON。这样,json 包就不必负责校验和格式化 MarshalJSON 返回的 JSON 值,也不必负责确定传给 UnmarshalJSON 的 JSON 值的边界——这些职责交给了 Encoder 和 Decoder。

encoding/json/v2 登场

在 jsontext 包的基础上,我们推出了 encoding/json/v2 包。它的设计目标是修复 v1 的问题,同时让 v1 用户感到熟悉。我们希望大多数 v1 用法直接迁移到 v2 后,行为基本不变。

本文主要介绍 v2 的高层 API。具体用法可以参考 v2 包文档中的示例、Go 官方的"Working with JSON"教程,或者 Anton Zhiyanov 介绍这一主题的博客文章。

v2 的基本 API 如下:

package json

func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

Marshal 和 Unmarshal 的签名与 v1 类似,但可以传入选项来配置行为。MarshalWrite 和 UnmarshalRead 直接操作 io.Writer 或 io.Reader,不必为了读写这类对象临时构造一个 Encoder 或 Decoder。MarshalEncode 和 UnmarshalDecode 操作的是 jsontext.Encoder 和 jsontext.Decoder,它们其实就是前面几个函数的底层实现。和 v1 不同,选项是每个序列化、反序列化函数的一等参数,这大大提升了 v2 的灵活性和可配置性。v2 提供了不少选项,本文不一一展开。

由类型决定的自定义

和 v1 一样,v2 允许类型通过实现特定接口来定义自己的 JSON 表示:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}
type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}
type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

Marshaler 和 Unmarshaler 接口与 v1 中的完全一样。新增的 MarshalerTo 和 UnmarshalerFrom 接口让类型可以借助 jsontext.Encoder 或 jsontext.Decoder 把自己表示为 JSON。由于可以通过 Encoder 或 Decoder 上的 Options 访问方法取到选项,选项就能沿调用栈一路传下去。

想了解如何实现一个保持对象成员顺序的自定义类型,可以看包文档里的 OrderedObject 示例。

由调用方决定的自定义

在 v2 中,Marshal 和 Unmarshal 的调用方还可以为任意类型指定自定义的 JSON 表示。调用方指定的函数优先于类型自身定义的方法,也优先于该类型的默认表示。

func WithMarshalers(*Marshalers) Options

type Marshalers struct { ... }
func MarshalFunc[T any](fn func(T) ([]byte, error)) *Marshalers
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T) error) *Marshalers

func WithUnmarshalers(*Unmarshalers) Options

type Unmarshalers struct { ... }
func UnmarshalFunc[T any](fn func([]byte, T) error) *Unmarshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers

MarshalFunc 和 MarshalToFunc 用来构造自定义序列化器,通过 WithMarshalers 传给 Marshal 调用,就能覆盖特定类型的序列化方式。同理,UnmarshalFunc 和 UnmarshalFromFunc 为 Unmarshal 提供同样的定制能力。

包文档中的 ProtoJSON 示例展示了如何借助这一特性,把所有 proto.Message 类型的序列化统一交给 protojson 包处理。

行为差异

虽然 v2 力求与 v1 行为基本一致,但为了解决 v1 的问题,有些行为还是变了,最主要的有:

  • 遇到非法 UTF-8 时,v2 会报错。
  • JSON 对象中存在重复名称时,v2 会报错。
  • v2 把 nil 的 Go 切片或映射分别序列化为空 JSON 数组或空 JSON 对象。
  • v2 把 JSON 对象反序列化到 Go 结构体时,成员名与字段名按大小写敏感的方式匹配。
  • v2 重新定义了 omitempty 标签选项:如果字段会被编码成"空"JSON 值(即 null、""、[] 和 {}),就省略它。
  • v2 序列化 time.Duration 时会报错,因为它目前还没有默认表示,但提供了选项让调用方自己决定。

对大多数行为变化,都有对应的结构体标签选项或调用方选项,可以让程序按 v1 或 v2 的语义运行,甚至采用调用方自定的行为。详见 encoding/json 包文档中的"Migrating to v2"一节。

性能优化

v2 的 Marshal 性能与 v1 大致持平,有时稍快,有时稍慢。v2 的 Unmarshal 则明显快于 v1,基准测试显示最高可达 10 倍。

想获得更大的性能提升,已有的 Marshaler 和 Unmarshaler 实现应当迁移为同时实现 MarshalerTo 和 UnmarshalerFrom,从而以纯流式方式处理 JSON。举个例子:Kubernetes 的某个服务在 UnmarshalJSON 方法里递归解析 OpenAPI 规范,严重拖累了性能(见 kubernetes/kube-openapi#315);改用 UnmarshalJSONFrom 之后,性能提升了几个数量级。

更多基准测试细节见 GitHub 上的 go-json-experiment/jsonbench 仓库。

除了这些结构性改进,对于 v2 实现比 v1 慢的场景,我们也在持续做性能优化。Go 1.28 还会有进一步的提升。

兼容性

Go 1 兼容性承诺是 Go 项目的一项核心保证:开发者构建的应用能经得起时间考验,在未来的 Go 版本上继续正常工作。

原来的 encoding/json 和新的 encoding/json/v2 当然都受 Go 1 兼容性承诺约束。使用 v1 的应用会一直得到支持,永远不必迁移到 v2。

不过,设计 v2 时,我们想做的不止是"继续支持 v1"。我们希望 v1 和 v2 彼此兼容,让使用不同版本的包能一起工作。这样整个生态可以平滑迁移,而不是因为依赖的选择,被迫只能用其中一个版本。

举例来说,假设你的包用 v2 序列化一个结构体,其中某个字段的类型定义在另一个包里,而那个包用 v1 的 MarshalJSON 方法定义了序列化行为。没问题:v2 支持 MarshalJSON 方法,序列化照常进行。

反过来,假设你的包用 v1 序列化同一个结构体,而另一个包为了性能用了 v2 的 MarshalJSONTo 方法。同样没问题:v1 的实现在底层也支持 v2 的 MarshalJSONTo 方法。

我们做到这一点的办法是:在新包的设计中,v1 在底层就是用 v2 实现的。前面提到,v2 支持多种选项(Options),每一处行为差异都由其中一个选项控制。因此,只要传入全套 v1 兼容选项,就能用 v2 实现 v1。

借助这种做法,向后兼容的新增特性——比如新方法(如 MarshalJSONTo)或新的结构体标签(如 embed)——都会自动对现有 v1 用户生效。

迁移

用 v2 选项来实现 v1 还有一个好处:可以逐个调用点渐进迁移。

encoding/json 的文档完整列出了 v1 与 v2 之间的所有行为差异,以及控制每项行为的选项。

许多应用可以直接迁移到 v2,没有任何可见的行为变化;依赖 v1 某些行为的应用,则可以只用选项改回自己依赖的那几项。

例如,可以选择全部 v2 行为,但允许重复名称:

json.Marshal(..., json.DefaultOptionsV2(), jsontext.AllowDuplicateNames(true))

或者反过来,选择全部 v1 行为,但禁止重复名称:

json.Marshal(..., jsonv1.DefaultOptionsV1(), jsontext.AllowDuplicateNames(false))

官方的 encoding/json/v2 迁移指南(Migration Guide)介绍得更详细,覆盖了把应用从 v1 迁移到 v2 的各种场景。

更多资料

关于如何使用新的 JSON 包,可以参考:

  • "Working with JSON"教程;
  • encoding/json/v2 和 encoding/json/jsontext 的包文档;
  • encoding/json 包文档中与 v2 的对比说明;
  • encoding/json/v2 迁移指南。

六年磨一剑

encoding/json 新大版本的规划持续了好几年。2020 年底,因为现有包里的问题始终修不了,encoding/json 的维护者之一 Daniel Martí 第一次写下他对一个假想中的 v2 包的设想。另一边,Joe Tsai 在完成 Go Protocol Buffers API 的工作后很失望:protojson 包不得不用一套自定义的 JSON 实现,因为 encoding/json 既无法满足 Protocol Buffer 规范要求的更严格 JSON 标准,也无法高效地以流式方式序列化 JSON。

两人都相信 JSON 在 Go 里可以有更好的未来,而且这既有益也可行。于是 Daniel 和 Joe 联手构思 v2,开始搭建原型(最初的代码是从 Go protobuf 模块里的 JSON 序列化逻辑打磨而来)。之后,又有几位成员(Roger Peppe、Chris Hines、Johan Brandhorst-Satzkorn、Damien Neil、Austin Clements 和 Michael Pratt)加入,参与设计评审、代码评审和回归测试。很多早期讨论都有公开的会议录像和会议纪要;后来的讨论记录在 encoding/json/v2 工作组的会议纪要里。

这项工作从一开始就是公开的,并且越来越多地让更广泛的 Go 社区参与进来:先是一场 GopherCon 演讲和 2023 年底发起的讨论,然后是 2025 年初提交的正式提案,最后在 Go 1.25 中把 encoding/json/v2 作为 Go 实验特性(GOEXPERIMENT)推出,供所有 Go 用户大规模试用。

v2 走到今天花了大约 6 年,吸收了许多贡献者的反馈,也从生产环境的实际使用中积累了宝贵经验。

值得一提的是,这项工作主要是由不在 Google 任职的人开发和推动的,这也说明 Go 项目是一项协作事业,背后有一个活跃的全球社区在致力于改进 Go 生态。

感谢每一位贡献者!


译者注

  1. 升级前先自查这几处"默认变严":迁移到 v2 后最容易出问题的是三类数据——上游会发来大小写不一致的字段名(如 UserName 与 username)、带重复键的 JSON、以及依赖 null 判断"未设置"的 nil 切片/映射。建议先在测试里用 v2 跑一遍真实的历史报文,再决定哪些行为要用选项改回 v1 语义,而不是整体切换后再排查。
  2. json.NewDecoder(r).Decode(v) 可以换成 json.UnmarshalRead(r, &v):后者会拒绝末尾多余数据,适合处理 HTTP 请求体这类"一次一个值"的输入;真正需要流式逐条读取(如 NDJSON 日志)时,再用 jsontext.Decoder 配合 UnmarshalDecode。
  3. 自定义序列化的性能热点:如果项目里有大量手写的 UnmarshalJSON(尤其是内部又调用 json.Unmarshal 的嵌套写法),这是 v2 收益最大的地方——补实现 UnmarshalJSONFrom 能避免重复解析。Kubernetes 生态(client-go、CRD 相关代码)里这类写法很常见,跑在华为云 CCE 等托管 Kubernetes 上的自研控制器、Operator 可以重点关注。
  4. time.Duration 要显式处理:v1 把它序列化成纳秒整数,v2 默认直接报错。配置文件、接口里有超时类字段的,升级前务必按迁移指南用结构体标签选项或调用方选项写明格式。

相关阅读

  • Go 首次走紧急通道发安全版本:eMudhra 中间证书私钥泄露,Linux 上的 Go 程序必须重新编译:https://bbs.huaweicloud.com/blogs/fae4654f2bbd4698b1e4f777cb3217f4
  • gopls 交互式重构上线:结构体标签、接口实现、移动声明都能“先问再改”:https://bbs.huaweicloud.com/blogs/412452f8a1434bb9a01771d1c81ba9bd
【版权声明】本文为华为云社区用户翻译文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容, 举报邮箱:cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。