Go 1.27 的 encoding/json/v2:重复键和非法 UTF-8 从静默通过变成报错
Go 1.27 新增两个包:encoding/json/v2 与 encoding/json/jsontext。
encoding/json/v2 是 encoding/json 的大幅修订版,提供 Marshal、MarshalWrite、MarshalEncode、Unmarshal、UnmarshalRead、UnmarshalDecode,这些函数都接受可变参数 Options,用来配置具体行为。
encoding/json/jsontext 负责更底层的 JSON 语法处理:Encoder 与 Decoder 以 Token 和 Value 序列操作 JSON,并维护状态机,确保产出或消费的序列是合法 JSON 文本。
v2 默认值更严格
v2 的默认值比 v1 更严格,互操作性也更好,最直接的两条差异是:
- 拒绝 JSON 字符串中的非法 UTF-8;
- 拒绝同一 JSON 对象内的重复名字。
这两类输入在 v1 下都属于「能读就读」:字符串里的坏字节会被容错处理,重复键则由后出现的值覆盖先出现的值。v2 不再这样处理,直接报错。完整差异与可调整的选项见 v1 encoding/json 包文档。
v1 的行为保留,错误文案变了
encoding/json 现在由 v2 实现支撑。marshal / unmarshal 的行为保持不变,但错误消息的精确文本可能不同。这意味着不要用字符串比较来判断错误:判类型用 errors.As / errors.Is,不要把 err.Error() 的字面量写进分支逻辑,否则升级后会出现「逻辑没改、判断全失效」的情况。
用 Options 退回 v1 语义
encoding/json 新增了一些 Options,可以让 v2 以 v1 的语义运行,从而避免强制全量迁移。Unmarshal 支持可变参数 Options,需要旧行为的地方显式传参放宽,而不是整体切回旧实现——显式放宽的地方后续可以逐条审计。
性能
marshal 大致与旧实现持平,unmarshal 显著更快。
最适合出问题的地方:重复键
以前重复键取最后一个,配置合并、接口对接这种「多层叠加 + 后者覆盖前者」的写法天然依赖这个行为。切到严格默认后,这类输入会直接解析失败:
- 配置合并:多份配置文本叠加、同一项被写了两遍;
- 接口兼容旧字段:对端同时下发新旧字段名,或在同一对象里重复填充。
非法 UTF-8 同理,历史数据里混进的坏字节以前被无声吞掉,现在会在解析阶段暴露出来。
对比代码
package main
import (
"fmt"
jsonv1 "encoding/json"
jsonv2 "encoding/json/v2"
)
func main() {
// 同一对象内的重复名字
dup := `{"name":"a","name":"b"}`
var a, b map[string]string
fmt.Println("v1:", jsonv1.Unmarshal([]byte(dup), &a), a)
// v2 不传 Options 即走默认的严格模式
fmt.Println("v2:", jsonv2.Unmarshal([]byte(dup), &b), b)
// JSON 字符串中的非法 UTF-8
bad := "{\"name\":\"\xff\"}"
var c, d map[string]string
fmt.Println("v1:", jsonv1.Unmarshal([]byte(bad), &c), c)
fmt.Println("v2:", jsonv2.Unmarshal([]byte(bad), &d), d)
}
迁移建议
- 先不动 API:不换包、不改结构体标签,只升级 Go 版本跑一遍全量测试。
- 让失败点自己暴露:把重复键、非法 UTF-8 这两类样例补进解析入口附近的测试,或在联调环境里直接灌历史数据。
- 逐项决定:对每个失败点判断「这个输入本来就该被拒绝」还是「历史兼容更重要」。该拒绝的保留严格默认;需要兼容的,在该调用点显式传 v1 语义的
Options,不要为了省事全局放宽。
局限
v1 API 不会被废弃,继续支持,因此没必要为了「跟上版本」做全量迁移。引入 v2 的收益集中在三处:更严格的默认值、Options 带来的可配置行为,以及 jsontext 那层基于 Token / Value 的流式处理与状态校验。用不到这三点,留在 v1 是合理选择。
参考: