Go encoding/json 的八个默认行为,以及 Go 1.25 的 json/v2 改了哪几个(附实测输出)

Go 的 encoding/json 用了十几年,大部分人对它的印象是「能用,偶尔有点怪」。怪的那部分,其实是几条写死在包里的默认行为:大小写不敏感匹配、重复 key 取最后一个、非法 UTF-8 静默替换......平时不出事,一出事就很难查。

Go 1.25 带了一个实验性的 encoding/json/v2(GOEXPERIMENT=jsonv2 开启),把这些默认行为几乎全改了一遍。这篇把 v1 的八个默认行为逐个跑一遍,再用 v2 跑同样的输入,对照看改了什么、哪些没改。

环境:Go 1.25.0,darwin/arm64。下面的输出都是实际跑出来的。

测试用的结构体

go 复制代码
type Order struct {
	ID      int64             `json:"id"`
	Amount  float64           `json:"amount,omitempty"`
	Paid    bool              `json:"paid,omitempty"`
	Created time.Time         `json:"created,omitempty"`
	Tags    []string          `json:"tags"`
	Meta    map[string]string `json:"meta"`
}

v1 的八个默认行为

完整代码(v1.go):

go 复制代码
//go:build !goexperiment.jsonv2

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

// Order 定义同上,省略

func main() {
	// 1. omitempty 对 struct(time.Time)无效
	b, _ := json.Marshal(Order{ID: 1})
	fmt.Println("1 omitempty:", string(b))

	// 2. 大整数进 any 变 float64
	var m map[string]any
	json.Unmarshal([]byte(`{"id": 1234567890123456789}`), &m)
	fmt.Printf("2 any: %T %v -> int64 %d\n", m["id"], m["id"], int64(m["id"].(float64)))

	// 3. 字段名大小写不敏感
	var o Order
	json.Unmarshal([]byte(`{"ID": 7, "Id": 8}`), &o)
	fmt.Println("3 case-insensitive:", o.ID)

	// 4. 重复 key 取最后一个
	json.Unmarshal([]byte(`{"id": 1, "id": 2}`), &o)
	fmt.Println("4 dup key:", o.ID)

	// 5. 非法 UTF-8 被静默替换
	b, _ = json.Marshal(string([]byte{0xff, 'a'}))
	fmt.Println("5 invalid utf8:", string(b))

	// 6. 默认转义 HTML 字符
	b, _ = json.Marshal("<a>&")
	fmt.Println("6 html escape:", string(b))

	// 7. Unmarshal 到已有 map 是合并
	mm := map[string]int{"a": 1}
	json.Unmarshal([]byte(`{"b":2}`), &mm)
	fmt.Println("7 merge map:", mm)

	// 8. 未知字段默认忽略
	err := json.Unmarshal([]byte(`{"id":1,"amout":9.9}`), &o)
	fmt.Println("8 unknown field err:", err)
}

输出:

sql 复制代码
1 omitempty: {"id":1,"created":"0001-01-01T00:00:00Z","tags":null,"meta":null}
2 any: float64 1.2345678901234568e+18 -> int64 1234567890123456768
3 case-insensitive: 8
4 dup key: 2
5 invalid utf8: "�a"
6 html escape: "<a>&"
7 merge map: map[a:1 b:2]
8 unknown field err: <nil>

逐条说。

1. omitempty 管不了 time.Time。 omitempty 只认 false、0、nil 指针、nil 接口、空数组/切片/map/字符串。struct 永远不算空,所以零值时间照样输出成 "0001-01-01T00:00:00Z"。前端拿到这个值,new Date() 出来是公元 1 年,表格里显示一串奇怪的日期。v1 下的常规解法是改成 *time.Time。

另外注意 tags 和 meta 输出的是 null 不是 [] / {}。前端写 data.tags.length 直接报错,这是 Go 服务最常见的接口兼容问题之一。

2. 大整数精度丢失。 解到 any 里的数字一律是 float64,超过 2^53 的整数就不准了:1234567890123456789 变成了 ...768,最后三位错了。雪花 ID、订单号最容易中招。解法是 decoder.UseNumber(),或者直接解到有类型的 struct 里。

3. 大小写不敏感。 "ID" 和 "Id" 都能匹配 json:"id",同时出现时后面的赢了,结果是 8。这个行为平时是「宽容」,但如果上游有人传了一个大小写不同的同名字段,你很难发现数据是从哪来的。

4. 重复 key 静默取最后一个。 RFC 8259 对重复 key 的行为没有强制规定,各家解析器不一致。如果网关用一个解析器做校验、后端用 Go 解析,同一份 JSON 两边可能读出不同的值。

5. 非法 UTF-8 被换成 U+FFFD。 不报错,数据悄悄变了。

6. 默认 HTML 转义。 <、>、& 变成 < 这种形式。为了防止 JSON 被嵌进 HTML 时出问题,但如果你是写 API,输出就是不好读。要关掉得用 json.NewEncoder 再 SetEscapeHTML(false),json.Marshal 没有开关。

7. 解到已有 map 是合并。 复用变量做多次 Unmarshal 时,上一次的 key 会残留。

8. 未知字段静默忽略。 客户端把 amount 拼成了 amout,服务端不报错,金额字段是零值。要严格校验得用 Decoder.DisallowUnknownFields()。

同样的输入,v2 跑出来什么样

v2 的包路径是 encoding/json/v2,需要 GOEXPERIMENT=jsonv2 才能编译。我用 build tag 让两份代码分别在开、关实验时生效:

go 复制代码
//go:build goexperiment.jsonv2

package main

import (
	"encoding/json/jsontext"
	json "encoding/json/v2"
	"fmt"
	"time"
)

type Order struct {
	ID      int64             `json:"id"`
	Amount  float64           `json:"amount,omitempty"`
	Paid    bool              `json:"paid,omitempty"`
	Created time.Time         `json:"created,omitzero"`
	Tags    []string          `json:"tags"`
	Meta    map[string]string `json:"meta"`
}

func main() {
	b, err := json.Marshal(Order{ID: 1})
	fmt.Println("1 omitzero + nil slice/map:", string(b), err)

	var m map[string]any
	err = json.Unmarshal([]byte(`{"id": 1234567890123456789}`), &m)
	fmt.Printf("2 any: %T %v %v\n", m["id"], m["id"], err)

	var o Order
	err = json.Unmarshal([]byte(`{"ID": 7}`), &o)
	fmt.Println("3 case-sensitive:", o.ID, err)

	err = json.Unmarshal([]byte(`{"id": 1, "id": 2}`), &o)
	fmt.Println("4 dup key:", err)

	b, err = json.Marshal(string([]byte{0xff, 'a'}))
	fmt.Println("5 invalid utf8:", string(b), err)

	b, _ = json.Marshal("<a>&")
	fmt.Println("6 html escape:", string(b))

	mm := map[string]int{"a": 1}
	json.Unmarshal([]byte(`{"b":2}`), &mm)
	fmt.Println("7 merge map:", mm)

	err = json.Unmarshal([]byte(`{"id":1,"amout":9.9}`), &o, json.RejectUnknownMembers(true))
	fmt.Println("8 unknown field:", err)

	err = json.Unmarshal([]byte(`{"id": 1, "id": 2}`), &o, jsontext.AllowDuplicateNames(true))
	fmt.Println("9 allow dup:", o.ID, err)
}

GOEXPERIMENT=jsonv2 go run . 的输出:

go 复制代码
1 omitzero + nil slice/map: {"id":1,"amount":0,"paid":false,"tags":[],"meta":{}} <nil>
2 any: float64 1.2345678901234568e+18 <nil>
3 case-sensitive: 0 <nil>
4 dup key: jsontext: duplicate object member name "id"
5 invalid utf8:  jsontext: invalid UTF-8
6 html escape: "<a>&"
7 merge map: map[a:1 b:2]
8 unknown field: json: cannot unmarshal JSON string into Go main.Order: unknown object member name "amout"
9 allow dup: 2 <nil>

对照一下:

v1 v2
nil 切片 / map null [] / {}
零值 time.Time 输出 0001-01-01... 用 omitzero 可省略
大小写 不敏感 敏感 ,"ID" 不再匹配 id
重复 key 取最后一个 报错
非法 UTF-8 替换成 U+FFFD 报错
HTML 转义 默认转义 默认不转义
大整数进 any float64 仍是 float64
解到已有 map 合并 仍是合并
未知字段 忽略 仍默认忽略,可选项拒绝

三个容易看漏的地方

v2 的 omitempty 换了含义。 注意第 1 行输出里 "amount":0,"paid":false 还在。v1 的 omitempty 按 Go 的值判断(0、false 算空),v2 的 omitempty 按编码后的 JSON 判断:只有编码成 null、""、{}、[] 才省略。0 和 false 编码后不是空 JSON 值,所以不省略。想要「Go 零值就省略」,v2 里要用新的 omitzero。

这条是迁移时最容易出事的:代码一行不改,换成 v2 之后接口里突然多出一堆 0 和 false。

大小写敏感会让一部分老客户端解析失败。 如果你的接口曾经靠 v1 的宽容接住了 "UserId" 这种字段名,换成 v2 之后这个字段就是零值,而且不报错(未知字段默认忽略)。可以用 json.MatchCaseInsensitiveNames(true) 先恢复旧行为,或者在字段标签上单独加 case:ignore。

开实验不会改变 v1 包的行为。 我在开着 GOEXPERIMENT=jsonv2 的情况下用 v1 包再跑了一遍,nil 切片还是 null,大小写还是不敏感。v1 在实验模式下是用 v2 重新实现的,但语义保持不变,所以可以开着实验、逐个文件迁移。

现在要不要用

我的判断是:新项目可以开始用 v2 的思路写代码,生产上先别切。

具体能做的:

  • v1 下就把 Decoder.DisallowUnknownFields()、UseNumber() 用起来,这两个是 v2 方向上的行为,越早越好;
  • 零值时间字段改成指针或者自定义类型,别依赖 omitempty;
  • 接口返回的切片在构造时就初始化成空切片,别让前端去判断 null;
  • 写一组覆盖上面八种情况的测试,开 GOEXPERIMENT=jsonv2 跑一遍,看哪些用例变了。这是迁移成本最直观的估算方式。

局限也要说清楚:v2 在 1.25 里是实验特性,API 和默认行为在正式发布前还可能调整,上面的输出只代表 Go 1.25.0 的状态;性能我没做基准测试,这篇只比较行为,不比较速度。

顺带一提,我平时把接口 JSON 转成 Go struct 用的是 forxi.cn 的 JSON 转 Go 工具,它有一个 omitempty 开关。看完第 1 条你大概会和我一样,不再无脑勾上它:对 time.Time 这种 struct 字段勾了也没用,对金额、库存这种「0 有意义」的字段勾了反而会把真实的 0 吞掉。

相关推荐
合橱瑰2 小时前
踩坑实录:包是好的,代码却报错?一次“数字ID”引发的插件启动血案
架构·go
NetX行者4 小时前
五大主流语言:Go、Python、Rust、Java、C# 的比较
go
Thinker QAQ20 小时前
并发编程(六):Atomic 的实现——从 Runtime 到 CPU
java·go·cas·并发编程·atomic·cpython
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:多租户与行级数据隔离
后端·go
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:脚本系统实战
javascript·后端·go
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:通知域实战
后端·go
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:认证与会话管理实战
后端·go
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:六类审计日志与等保合规
后端·安全·go
喵个咪1 天前
GoWind Admin|风行 — 开箱即用的企业级全栈中后台框架:AI 模块
后端·go·ai编程