Go 标准库 JSON 包迎来重大升级:encoding/json/v2 实验版来了

2 阅读7分钟

原文:A new experimental Go API for JSON

作者:Joe Tsai、Daniel Martí 等 Go 核心团队成员


背景:一个用了 15 年的老包

JSON 是当今互联网上最主流的数据交换格式,而 encoding/json 是 Go 标准库中第 5 个被引用最多的包。

这个包已经稳定服务了将近 15 年。总体来说,它表现不错——对任意 Go 类型进行序列化和反序列化的设计思路,加上可自定义的表示方式,被证明具有很强的灵活性。

但 15 年并不短。随着 JSON 规范的不断完善、社区需求的持续演进,encoding/json 的一些缺陷逐渐变得难以忽视,而且受制于 Go 1 兼容性承诺,这些问题根本无法在现有包里修复。

于是,encoding/json/v2 应运而生。


老版本有哪些问题?

行为缺陷

1. 对 JSON 语法的处理不够严格

  • encoding/json 目前接受非法的 UTF-8 字符。RFC 8259(最新的 JSON 互联网标准)明确要求有效的 UTF-8。接受非法输入会导致静默的数据损坏。
  • encoding/json 目前接受含有重复成员名的 JSON 对象。这在安全场景下存在风险——历史上已有真实 CVE(CVE-2017-12635)利用过这一点。

2. nil slice 和 map 序列化为 null

社区调查显示,大多数 Go 开发者希望 nil slice 和 nil map 默认序列化为空数组 [] 和空对象 {},而不是 null。当前行为在与其他语言的 JSON 实现交互时,容易引起兼容问题。

3. 大小写不敏感的反序列化

当前版本在将 JSON 字段名映射到 Go struct 字段时,默认是大小写不敏感的。这既令人意外,又是一个潜在的安全隐患,同时还影响性能。

4. 方法调用的不一致性

指针接收者上的 MarshalJSON 方法被调用的行为存在不一致性。这是一个公认的 bug,但由于太多应用依赖当前行为,已无法修复。

API 设计的局限性

  • json.NewDecoder(r).Decode(v) 这种惯用写法无法检测输入末尾的多余内容。
  • 选项只能设置在 Encoder/Decoder 上,无法传入 Marshal/Unmarshal 函数,也无法向下透传给自定义的 MarshalJSON/UnmarshalJSON 方法。
  • CompactIndentHTMLEscape 等函数只能写入 bytes.Buffer,不支持 io.Writer

性能瓶颈

  • MarshalJSON 接口方法强制实现方分配并返回 []byte,而 encoding/json 还需要再次验证和格式化这段 JSON。
  • UnmarshalJSON 需要先解析完整个 JSON 值才能确定边界,然后调用方再解析一遍——相当于解析了两次。
  • 如果自定义的 MarshalJSON/UnmarshalJSON 方法内部递归调用 Marshal/Unmarshal,性能会退化为二次方级别。

为什么不直接修改老包?

Go 团队不是没想过在原包里打补丁。问题是,上述缺陷大多是 API 设计本身带来的,而 Go 1 兼容性承诺明确规定,现有代码的行为不能被破坏。

在同一个包里新增 MarshalV2UnmarshalV2 这类名字,本质上只是在原包里建立一个平行命名空间,治标不治本。

所以,答案只有一个:建立独立的 v2 命名空间,也就是 encoding/json/v2


架构设计:语法与语义分离

v2 的一个核心设计决策是将 JSON 处理拆分为两层:

  • 语法层(Syntactic):只关心 JSON 的格式和语法,不涉及 Go 类型的含义。用 encode/decode 描述。
  • 语义层(Semantic):定义 JSON 值与 Go 值之间的映射关系。用 marshal/unmarshal 描述。

语法层由新的 encoding/json/jsontext 包实现,语义层由 encoding/json/v2 实现,后者构建在前者之上。

encoding/json/jsontext

这个包提供了纯粹的 JSON 语法处理能力,不依赖反射:

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)

EncoderDecoder 支持真正意义上的流式处理,构造函数接受可变参数的 Options,避免了 v1 中语法与语义混淆的问题。Token 类型被重新设计,可以表示任意 JSON token 而无需额外分配内存。


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

函数签名与 v1 相似,但每个函数都可以接受 Options 参数,这是一个关键改进。不再需要先构造 Encoder/Decoder 再去读写 io.Reader/io.Writer——MarshalWriteUnmarshalRead 直接支持。

新的接口:流式自定义序列化

v2 保留了 v1 的 Marshaler/Unmarshaler 接口,同时新增了更高效的流式版本:

type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}

type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

这两个新接口允许实现方直接写入/读取 Encoder/Decoder,避免了中间的 []byte 分配,也解决了双重解析的性能问题。

在 Kubernetes 的一个真实案例中,OpenAPI 规范的递归解析使用 UnmarshalJSON 严重影响了性能,切换到 UnmarshalJSONFrom 后性能提升了数个数量级。

调用方自定义序列化

这是 v2 的全新能力——调用方可以在不修改类型定义的情况下,为任意类型指定自定义的 JSON 表示:

func WithMarshalers(*Marshalers) Options
func MarshalFunc[T any](fn func(T "T any") ([]byte, error)) *Marshalers
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T "T any") error) *Marshalers

func WithUnmarshalers(*Unmarshalers) Options
func UnmarshalFunc[T any](fn func([]byte, T "T any") error) *Unmarshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T "T any") error) *Unmarshalers

例如,可以让所有 proto.Message 类型的序列化统一交由 protojson 包处理,只需在调用 Marshal 时传入一个 Option 即可,无需修改 proto 类型本身。


v2 的行为变化

v2 的设计目标是在直接迁移时大部分行为保持一致,但以下几点有明确变化:

行为v1v2
无效 UTF-8静默接受报错
重复 JSON 键静默接受报错
nil slice/map 序列化null[] / {}
struct 字段匹配大小写不敏感大小写敏感
omitempty 语义基于 Go 零值基于 JSON 空值(null、""、[]、{})
time.Duration 序列化输出整数报错(需显式指定格式)

对于大多数行为变化,都可以通过 struct tag 或 Options 参数回退到 v1 语义,迁移路径是渐进式的。


性能表现

  • Marshal:与 v1 大体持平,略有快慢之分。
  • Unmarshal:显著快于 v1,基准测试显示最高可达 10 倍的提升。

想要获得更大的性能收益,建议将现有的 Marshaler/Unmarshaler 实现同时也实现 MarshalerTo/UnmarshalerFrom,以充分利用流式处理的优势。


v1 与 v2 的关系

Go 团队不希望标准库中同时存在两套 JSON 实现,因此计划让 v1 在底层由 v2 实现。这带来三个好处:

渐进迁移:可以通过 Options 灵活混搭 v1 和 v2 的行为语义,而不是非此即彼。

功能继承:v2 新增的特性(如新的 struct tag 选项 inlineformat,以及流式接口)会自动被 v1 继承,无需改代码。

降低维护成本:一处修复,两个版本同时受益,无需单独 backport。

v1 不会被废弃,迁移是被鼓励的,而非强制的。


如何参与实验

encoding/json/jsontextencoding/json/v2 目前是实验性包,默认不可见。启用方式:

# 通过环境变量
GOEXPERIMENT=jsonv2 go test ./...

在不修改任何代码的情况下,在 jsonv2 实验模式下运行你的测试,理论上不应有新的失败用例——因为 v1 的底层实现已被替换为 v2,但对外行为在 Go 1 兼容性范围内保持一致。

如果发现问题,可以在 go.dev/issue/71497 上反馈。这个实验的结果将决定 v2 的命运——从被放弃到作为稳定包进入 Go 1.26,都有可能。


小结

encoding/json/v2 是 Go 社区历时 5 年、经过大量实际生产验证的成果,由许多非 Google 员工主导开发,体现了 Go 作为开放社区项目的本质。核心改进点:更严格的 JSON 语法校验,nil 值序列化更符合直觉,大小写敏感匹配更安全,Options 参数统一透传解决了长期 API 割裂问题,流式接口消除性能瓶颈,Unmarshal 性能最高提升 10 倍。

如果你的项目重度依赖 JSON 序列化,现在是参与测试、提供反馈的好时机。


参考资料:go.dev/blog/jsonv2…