《Go 语言高级编程》4.1 encoding/json/v2 实跑与迁移

encoding/json/v2 是 Go 1.27 才默认可用的新 JSON 实现。本节先给出它在 1.26 必须开 GOEXPERIMENT=jsonv2、1.27 默认开启的版本归属证据,再用本机实测逐条对比 v1 与 v2 在大小写匹配、重复字段、HTML 转义、map 键序与非法 UTF-8 上的语义差异,最后给出 import 替换、Options 组合与迁移检查表。

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.26Go 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。推荐的分步迁移:

  1. 先加测试:为每个 DTO 补一组「大小写混用键」「重复键」的用例,跑在 v1 上记录现状。
  2. 换 import:把 encoding/json 换成 encoding/json/v2,重新编译。注意 v2 的 Marshal/Unmarshal 签名与 v1 一致,替换成本低。
  3. 逐项对齐:若需要保留 v1 行为,用 DefaultOptionsV1() 或逐项 Option;v2 的完整默认语义等价于 DefaultOptionsV2()。
  4. 收紧边界:入口解析建议显式加 RejectUnknownMembers(true),把「静默忽略未知字段」改成「显式报错」。
  5. 契约测试:对外的 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 versiongo1.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 组合子 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练