Go 1.27 正式推出 encoding/json/v2:默认拒绝重复键和非法 UTF-8,反序列化最快提速 10 倍
原文信息:本文译自 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 切片或映射序列化成 JSONnull,对方反序列化时就可能出错。一项调查显示,大多数 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 生态。
感谢每一位贡献者!
译者注
- 升级前先自查这几处"默认变严":迁移到 v2 后最容易出问题的是三类数据——上游会发来大小写不一致的字段名(如
UserName与username)、带重复键的 JSON、以及依赖null判断"未设置"的 nil 切片/映射。建议先在测试里用 v2 跑一遍真实的历史报文,再决定哪些行为要用选项改回 v1 语义,而不是整体切换后再排查。 json.NewDecoder(r).Decode(v)可以换成json.UnmarshalRead(r, &v):后者会拒绝末尾多余数据,适合处理 HTTP 请求体这类"一次一个值"的输入;真正需要流式逐条读取(如 NDJSON 日志)时,再用jsontext.Decoder配合UnmarshalDecode。- 自定义序列化的性能热点:如果项目里有大量手写的
UnmarshalJSON(尤其是内部又调用json.Unmarshal的嵌套写法),这是 v2 收益最大的地方——补实现UnmarshalJSONFrom能避免重复解析。Kubernetes 生态(client-go、CRD 相关代码)里这类写法很常见,跑在华为云 CCE 等托管 Kubernetes 上的自研控制器、Operator 可以重点关注。 time.Duration要显式处理:v1 把它序列化成纳秒整数,v2 默认直接报错。配置文件、接口里有超时类字段的,升级前务必按迁移指南用结构体标签选项或调用方选项写明格式。
相关阅读
- Go 首次走紧急通道发安全版本:eMudhra 中间证书私钥泄露,Linux 上的 Go 程序必须重新编译:https://bbs.huaweicloud.com/blogs/fae4654f2bbd4698b1e4f777cb3217f4
- gopls 交互式重构上线:结构体标签、接口实现、移动声明都能“先问再改”:https://bbs.huaweicloud.com/blogs/412452f8a1434bb9a01771d1c81ba9bd
- 点赞
- 收藏
- 关注作者
评论(0)