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 吞掉。