4.1 encoding/json/v2 实跑与迁移
encoding/json 是 Go 标准库里被吐槽最多、却又最不能换掉的包。它的行为怪癖(大小写不敏感匹配、重复字段静默取最后一个、默认 HTML 转义、map 键排序)被无数项目当作既定事实写进了测试用例。encoding/json/v2 是一次彻底的语义重做,但它不是「把 v1 修好」,而是换一套默认值——这意味着升级不会自动发生,迁移是有代价的。
本节要回答:
encoding/json/v2到底在哪个版本可用、它和 v1 的语义差在哪几处、迁移要改哪些代码。结论是:v2 在 Go 1.27 才默认可用(1.26 需要GOEXPERIMENT=jsonv2),且默认语义有六处与 v1 不同,其中最容易被线上数据打中的是大小写敏感与重复字段报错。
4.1.1 版本归属:用差分实测确认「1.27 才默认」
关于 encoding/json/v2 的可用版本,很容易凭印象记错。本机用两套证据核对:
证据 A(api 清单):/usr/local/go 是 go1.26.0,其 api/ 下只有到 go1.26.txt,其中没有 encoding/json/v2:
$ grep -ln "^pkg encoding/json/v2," /usr/local/go/api/go1.*.txt
$ echo "exit=$?"
exit=1
证据 B(1.26 与 1.27 差分):比较两个工具链的标准库包列表:
$ diff <(GOTOOLCHAIN=local go list std) <(GOTOOLCHAIN=go1.27.0 go list std)
115a120,126
> encoding/json/internal
> encoding/json/internal/jsonflags
> encoding/json/internal/jsonopts
> encoding/json/internal/jsontest
> encoding/json/internal/jsonwire
> encoding/json/jsontext
> encoding/json/v2
再确认符号级存在性:
$ GOTOOLCHAIN=local go doc encoding/json/v2
doc: cannot find package "encoding/json/v2" in any of: ...
$ GOTOOLCHAIN=go1.27.0 go doc encoding/json/v2 | head -1
package json // import "encoding/json/v2"
结论:encoding/json/v2 与 encoding/json/jsontext 在 Go 1.27 才进入默认可见的标准库(1.26 的 go list std 里没有它们)。措辞要精确:它们的源码在 1.26 已存在,但被 //go:build goexperiment.jsonv2 挡住,不打开实验开关就不可见(下一小节实测)。所以「1.27 新增」严格说应表述为「1.27 起默认可用」。
4.1.2 GOEXPERIMENT:1.26 要开,1.27 默认开
encoding/json/v2 在正式可用前长期藏在实验开关后面。本机实测该开关在 1.26 与 1.27 都被识别:
$ GOTOOLCHAIN=local GOEXPERIMENT=jsonv2 go env GOEXPERIMENT
jsonv2
$ GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=bogusxyz go env GOEXPERIMENT
go: unknown GOEXPERIMENT bogusxyz
差别在于默认是否开启。用 1.26 模块(go 1.26)实测导入:
# go.mod: module jsonv2probe126 / go 1.26
$ GOTOOLCHAIN=local go run .
package jsonv2probe126
imports encoding/json/v2: build constraints exclude all Go files in .../src/encoding/json/v2
$ GOTOOLCHAIN=local GOEXPERIMENT=jsonv2 go run .
v2 in 1.26+exp: {"a":1} err=<nil>
$ GOTOOLCHAIN=go1.27.0 go run . # 1.27 无需任何开关
v2 in 1.26+exp: {"a":1} err=<nil>
1.27 的默认实验基线可以从工具链源码直接读出:
$ python3 - <<'PY'
t=open("$(go env GOROOT)/src/internal/buildcfg/exp.go").read()
i=t.find("baseline := goexperiment.Flags{")
print(t[i:i+260])
PY
在 1.27 工具链里,该结构体包含 GreenTeaGC: true、JSONv2: true、SizeSpecializedMalloc: true;而本机 1.26 的同一结构体里只有 GreenTeaGC: true,没有 JSONv2。这条证据把「JSONv2 默认开启于 1.27」钉死在工具链源码上。
| 项目 | Go 1.26 | Go 1.27 |
|---|---|---|
encoding/json/v2 包存在 | 否(api 与 std 列表均无) | 是 |
导入是否需 GOEXPERIMENT=jsonv2 | 是 | 否 |
buildcfg 基线含 JSONv2 | 否 | 是 |
4.1.3 六处真实语义差异(本机实测)
把同一个结构体同时喂给 v1 与 v2,差异一次暴露。测试程序与输出(GOTOOLCHAIN=go1.27.0):
type T struct {
ID int `json:"id"`
Name string `json:"name"`
Note string `json:"note,omitempty"`
}
func main() {
t := T{ID: 1, Name: "a", Note: ""}
b1, _ := jsonv1.Marshal(t)
b2, _ := jsonv2.Marshal(t)
fmt.Printf("v1 marshal: %s\n", b1)
fmt.Printf("v2 marshal: %s\n", b2)
in := []byte(`{"ID":3,"NAME":"B"}`) // 大写键
var a, c T
jsonv1.Unmarshal(in, &a)
jsonv2.Unmarshal(in, &c)
fmt.Printf("v1 case: %+v\n", a)
fmt.Printf("v2 case: %+v\n", c)
}
真实输出:
v1 marshal: {"id":1,"name":"a"}
v2 marshal: {"id":1,"name":"a"}
v1 case: {ID:3 Name:B Note:}
v2 case: {ID:0 Name: Note:}
{"ID":3} 在 v1 里能填进 ID,在 v2 里被忽略——v2 默认大小写敏感。这是最容易造成「上线后字段全空」的一处。
继续测重复字段、HTML 转义、map 键序、非法 UTF-8:
v1 dup: {ID:2 Name: Note:} err=<nil>
v2 dup: {ID:1 Name: Note:} err=jsontext: duplicate object member name "id"
v1 html: "<a>&"
v2 html: "<a>&"
v1 map: {"a":1,"b":2,"c":3}
v2 map: {"a":1,"c":3,"b":2}
v1 badutf8: "??" err=<nil>
v2 badutf8: err=jsontext: invalid UTF-8
汇总成一张迁移影响表:
| 语义点 | encoding/json(v1) | encoding/json/v2 默认 | 迁移风险 |
|---|---|---|---|
| 字段名匹配 | 大小写不敏感 | 大小写敏感 | 高:旧数据大小写混用会静默丢字段 |
| 重复成员 | 取最后一个,静默 | 报错(duplicate object member name) | 高:脏数据会从「能跑」变成「报错」 |
| HTML 字符 | 转义 < > & | 不转义 | 中:嵌 HTML 的输出需自己兜底 |
| map 键序 | 排序输出 | 不保证顺序 | 中:依赖稳定输出的测试会挂 |
| 非法 UTF-8 | 替换为 U+FFFD | 报错 | 中:二进制脏数据会暴露 |
| 未知字段 | 忽略 | 忽略(可用 RejectUnknownMembers 收紧) | 低 |
v2 还提供 v1 没有的显式开关。它们都是函数式 Option,可以叠加:
b, err := jsonv2.Marshal(m, jsonv2.Deterministic(true)) // 恢复 map 键排序
err = jsonv2.Unmarshal(data, &v, jsonv2.RejectUnknownMembers(true))
err = jsonv2.Unmarshal(data, &v, jsonv2.MatchCaseInsensitiveNames(true))
实测 RejectUnknownMembers(true) 的行为:
v2 reject unknown: json: cannot unmarshal JSON string into Go main.T: unknown object member name "b"
4.1.4 迁移路径
encoding/json/v2 的迁移是显式的:只要不改 import,encoding/json 就还是 v1。本机实测,即便在 1.27 下打开 GOEXPERIMENT=jsonv2,v1 包的行为也不变:
$ GOTOOLCHAIN=go1.27.0 go run . # 默认
v1 case: {ID:3 Name:B} err=<nil>
v1 dup: {ID:2 Name:} err=<nil>
v1 map: {"a":1,"b":2}
$ GOTOOLCHAIN=go1.27.0 GOEXPERIMENT=jsonv2 go run .
v1 case: {ID:3 Name:B} err=<nil>
v1 dup: {ID:2 Name:} err=<nil>
v1 map: {"a":1,"b":2}
也就是说,1.27 的 jsonv2 实验开关对 v1 包已经是 no-op,迁移只能靠改 import 或改用 Options。推荐的分步迁移:
- 先加测试:为每个 DTO 补一组「大小写混用键」「重复键」的用例,跑在 v1 上记录现状。
- 换 import:把
encoding/json换成encoding/json/v2,重新编译。注意 v2 的Marshal/Unmarshal签名与 v1 一致,替换成本低。 - 逐项对齐:若需要保留 v1 行为,用
DefaultOptionsV1()或逐项 Option;v2 的完整默认语义等价于DefaultOptionsV2()。 - 收紧边界:入口解析建议显式加
RejectUnknownMembers(true),把「静默忽略未知字段」改成「显式报错」。 - 契约测试:对外的 JSON 输出加 golden 测试,防止 HTML 转义与键序变化打穿下游。
4.1.5 jsontext:v2 拆出来的语法层
v2 把 JSON 处理明确拆成两层:encoding/json/v2 负责语义(Go 值 ↔ JSON 值),encoding/json/jsontext 负责语法(字节流 ↔ 词法记号)。后者的定位接近一个手写的流式词法分析器,MarshalEncode / UnmarshalDecode 就是这两层的粘合点:
// 语义层写到语法层:MarshalEncode 接收一个 *jsontext.Encoder
var sb bytes.Buffer
enc := jsontext.NewEncoder(&sb)
if err := jsonv2.MarshalEncode(enc, value); err != nil {
return err
}
// 反过来:UnmarshalDecode 从 *jsontext.Decoder 读
dec := jsontext.NewDecoder(&sb)
var out T
if err := jsonv2.UnmarshalDecode(dec, &out); err != nil {
return err
}
拆层的直接收益是流式处理不必再靠 json.Decoder 的临时缓冲:语法层可以逐个 token 推进,语义层只在需要构造 Go 值时才介入。对日志聚合、代理转发这类「看一眼字段再决定要不要解码整包」的场景,省下的是整包反序列化。
值得注意的是,v2 报错时抛出的错误文本用的是 jsontext: 前缀(前面实测的 jsontext: duplicate object member name "id" 与 jsontext: invalid UTF-8)。写迁移断言时不要按 json: 前缀匹配,否则新老两版都会漏。
4.1.6 迁移检查表
| 检查项 | 命令 / 动作 | 通过标准 |
|---|---|---|
| 工具链版本 | GOTOOLCHAIN=go1.27.0 go version | go1.27.0 |
| v2 可用 | GOTOOLCHAIN=go1.27.0 go doc encoding/json/v2 | 打印包文档 |
| 大小写 | 用大写键喂旧结构体 | 字段不再静默丢失 |
| 重复键 | 用重复键喂入 | 明确报错或显式允许 |
| HTML | 序列化含 < > & 的串 | 下游是否需要转义已确认 |
| 键序 | 序列化 map | 若依赖顺序则加 Deterministic(true) |
| 未知字段 | 入口解析 | 是否开启 RejectUnknownMembers 已决策 |
一句话收束:encoding/json/v2 不是「更好的 v1」,而是「另一套默认值的 JSON」。升级的核心工作量不在 API 替换,而在逐条确认默认值变化不会打穿既有数据契约。
阅读导航:上一节:3.3 Green Tea GC 与容器感知 GOMAXPROCS · 下一节:4.2 math/rand/v2 与 iter.Pull 组合子 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。