Go 写 HTTP 服务,encoding/json 大概是最容易被低估的包。
平时看起来就是两行:
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return err}b, err := json.Marshal(resp)真到线上接口,坑就来了:
• 列表字段为什么返回了 null,不是[]?• omitempty为什么把false吃掉了?• map 输出为什么每次顺序固定,性能上有没有代价? • JSON 里有重复字段,到底该信第一个还是最后一个? • 字段名大小写不一致,为什么 Go 还能解出来? • 想改这些默认行为,为什么标准库一直不动?
encoding/json/v2 的出现,不是 Go 团队突然想“优化一下 JSON”。更准确地说,是老的 encoding/json 已经被 Go 1 兼容承诺锁住了,很多行为明知道不理想,也不能直接改。
先看最常见的 nil slice。
type Response struct { Items []string `json:"items"`}var resp Responseb, err := json.Marshal(resp)if err != nil { return err}fmt.Println(string(b))老的 encoding/json 输出:
{"items":null}但很多 API 客户端期望的是:
{"items":[]}这不是前端矫情。JSON 里的 null 和 [] 语义不一样。一个是没有值,一个是空列表。TypeScript、Kotlin、Swift 这些客户端一旦按数组生成类型,null 就可能直接炸。
解决办法也不是没有:
resp := Response{ Items: make([]string, 0),}但这要求每个返回路径都记得初始化。项目大了以后,总会漏。
json/v2 的默认行为改了:nil slice 默认编码成空 JSON array,nil map 默认编码成空 JSON object。如果你确实想保留老行为,可以用选项:
//go:build goexperiment.jsonv2package apiimport json "encoding/json/v2"func EncodeOldNullShape(resp Response) ([]byte, error) { return json.Marshal( resp, json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true), )}这就是 v2 的思路:更安全、更符合多数 API 直觉的默认值,同时给迁移保留开关。
omitempty 这个名字太容易误导人
omitempty 我用了很多年,但它的语义一直不够直观。
type UserPatch struct { Nickname string `json:"nickname,omitempty"` Enabled bool `json:"enabled,omitempty"` LoginAt time.Time `json:"login_at,omitempty"`}问题来了:
patch := UserPatch{ Enabled: false,}如果你想表达“把 enabled 改成 false”,omitempty 会把这个字段省掉。因为在老 encoding/json 里,false、0、空字符串、长度为 0 的 slice/map/string 都算 empty。
但 time.Time{} 又是另一个坑。它是结构体,不属于老 omitempty 的 empty 集合,所以零值时间也会被编码出去。
Go 1.24 给老包加了 omitzero,这个选择更清楚:
type Event struct { CreatedAt time.Time `json:"created_at,omitzero"`}omitzero 看的是 Go 的零值,还会尊重类型自己的 IsZero() bool 方法。想省略零值时间,用它比 omitempty 明确。
到了 json/v2,这两个词的边界更清楚:
• omitzero:按 Go 类型系统判断零值• omitempty:按编码后的 JSON 值判断空值,比如null、空字符串、空对象、空数组
所以我现在写 API DTO 会更谨慎:
type UserPatch struct { Nickname *string `json:"nickname,omitempty"` Enabled *bool `json:"enabled,omitempty"`}Patch 语义里,false 是一个有效值。用 *bool 区分“没传”和“传了 false”,比指望 omitempty 猜语义可靠得多。
map key 顺序:稳定输出也有成本
Go 的 map 遍历是无序的,这个前面讲 map 的时候说过。但老的 encoding/json 在编码 map 时会排序 key。
m := map[string]int{ "b": 2, "a": 1,}b, err := json.Marshal(m)if err != nil { return err}fmt.Println(string(b)) // {"a":1,"b":2}这个行为很舒服。日志稳定、测试快照稳定、签名也容易做。
但它也有代价:每次编码 map 都要处理 key 的排序。对小对象没什么感觉,对大 map、高频编码路径就可能有影响。
json/v2 把这个选择显式化。它的文档说 map 默认按非确定性顺序遍历,如果需要稳定输出,用 Deterministic:
//go:build goexperiment.jsonv2package apiimport json "encoding/json/v2"func EncodeForSnapshot(v any) ([]byte, error) { return json.Marshal(v, json.Deterministic(true))}这不是说稳定输出不重要,而是不要让所有调用都默认为少数场景付费。
我的建议很简单:
• API 响应:不要让客户端依赖 object key 顺序 • 日志快照:可以开 deterministic • 签名验签:不要靠普通 JSON marshal,应该使用明确的 canonical JSON 或自定义规范
JSON object 在标准里本来就是无序集合。把顺序当业务语义,迟早会吃亏。
更严格的解析:安全问题经常藏在“宽容”里
老 encoding/json 有一些很宽容的行为。比如:
• 接受无效 UTF-8,并替换成 Unicode replacement character • 接受重复 object member name • 解 struct 字段时默认大小写不敏感
这些行为当年可能是为了兼容更多输入。但在今天的服务间调用里,宽容不一定是好事。
重复字段就是一个典型例子:
{ "role": "user", "role": "admin"}不同语言、不同 JSON 库可能处理得不一样。有的取第一个,有的取最后一个,有的报错。如果一个鉴权服务和一个业务服务对同一段 JSON 理解不同,就可能出安全问题。
json/v2 的默认值更偏严格:默认拒绝无效 UTF-8,默认拒绝重复字段名,struct 字段匹配也更偏大小写敏感。你可以用 option 放宽,但放宽是显式选择。
这也是我觉得 v2 最有价值的地方。它不是只在追性能,而是在把“JSON 到底是什么意思”这件事变得更可控。
jsontext 和 json/v2 分开,是一个很 Go 的设计
老包里,语法层和语义层基本揉在一起。你要么 Marshal 一个 Go 值,要么 Unmarshal 到一个 Go 值。
新实验里拆成两层:
• encoding/json/jsontext:处理 JSON 语法,读写 token/value• encoding/json/v2:处理 Go 值和 JSON 值之间的语义映射
官方文档里把这组词分得很清楚:
• encode/decode:更偏 JSON 语法层 • marshal/unmarshal:更偏 Go 值语义层
这个拆分对普通业务 CRUD 可能没感觉,但对框架、网关、代理、日志清洗、JSON patch、流式处理很有用。
比如你只是想检查一段 JSON、重排格式、按 token 流处理,不一定非要先反射成某个 Go struct。
而业务代码大多还是用高层 API:
//go:build goexperiment.jsonv2package apiimport ( "fmt" "net/http" json "encoding/json/v2")func DecodeCreateUser(r *http.Request) (CreateUserRequest, error) { var req CreateUserRequest if err := json.UnmarshalRead(r.Body, &req); err != nil { return CreateUserRequest{}, fmt.Errorf("decode create user request: %w", err) } return req, nil}这里用 UnmarshalRead 直接从 io.Reader 读,错误继续按上一篇说的方式包出去。
实际 HTTP handler 里还应该配合请求生命周期做限制,比如用 http.MaxBytesReader 限制 body 大小,用 r.Context() 传给后续数据库或第三方调用。JSON 解码本身只是入口,不要让它承担整个请求控制流。
现在能不能直接迁移到 json/v2
先说结论:大多数生产项目不用急着全量换。
截至我写这篇时,官方包文档仍然标着 experimental,不受 Go 1 兼容承诺保护,并且只有在 GOEXPERIMENT=jsonv2 下才可见。Go 1.25 的发布说明也说,设计还会继续演进。
这意味着:
• 应用项目可以试 • 内部工具可以试 • 新模块可以小范围试 • 公共库要非常谨慎 • 对外 API 不要在没有契约测试的情况下直接切
更稳的第一步不是改 import,而是跑测试:
GOEXPERIMENT=jsonv2 go test ./...在这个实验下,老的 encoding/json 会用新实现承载,但目标仍然是保持 v1 行为。官方也提醒,错误文本这类细节可能不同。你的测试如果依赖错误字符串,可能会先暴露出问题。
真正迁移到 encoding/json/v2 前,我会先补这些测试:
• nil slice / nil map 的输出契约 • omitempty/omitzero对零值字段的影响• map 输出是否被测试或签名依赖 • 重复字段名、大小写字段名、未知字段的处理 • 大整数、时间、 []byte、自定义 Marshal/Unmarshal 类型• 错误类型和错误文案是否被上层依赖
没有这些测试,迁移 JSON 包就是拿线上接口碰运气。
性能不要听一句“更快”就下结论
Go 1.25 发布说明里对性能的说法很克制:新实现很多场景明显更好,整体上编码大致持平,解码明显更快,并建议看 github.com/go-json-experiment/jsonbench 仓库里的数据做更细分析。
这句话不能翻译成“换 v2 就一定更快”。
JSON 性能和很多东西有关:
• payload 大小 • struct 字段数量 • map 多不多 • 自定义 Marshal/Unmarshal 多不多 • 是否需要确定性输出 • 是否频繁分配 • 是否走流式读写
我会按这种方式测:
func BenchmarkDecodeCreateUser(b *testing.B) { data := []byte(`{"name":"arix","age":18}`) for b.Loop() { var req CreateUserRequest if err := json.Unmarshal(data, &req); err != nil { b.Fatal(err) } }}然后分别跑:
go test -bench=. -benchmem ./...GOEXPERIMENT=jsonv2 go test -bench=. -benchmem ./...只看自己项目的热路径。别拿别人的 benchmark 给自己的接口背书。
我对 encoding/json/v2 的判断
json/v2 不是“老包太烂,推倒重写”这么简单。老包最大的问题是太成功了。
它被无数项目、框架、SDK、命令行工具依赖。任何一个默认行为改变,都可能让别人的线上接口悄悄变形。
所以 v2 只能用新的 import path 和实验开关慢慢来:
import json "encoding/json/v2"这也是 Go 一贯的保守:能不破坏就不破坏,真要改变,就把边界画清楚。
对日常业务代码来说,我现在会这样处理:
• 继续默认使用 encoding/json• 新接口 DTO 明确区分空值、零值、缺失值 • 不依赖 map key 顺序表达业务语义 • 对请求 JSON 更严格,尤其是网关和鉴权链路 • 用 GOEXPERIMENT=jsonv2跑测试,提前发现兼容风险• 等 v2 稳定后,再按模块迁移
错误处理那篇说的是 error 边界,JSON 这篇说的是数据边界。下一篇可以继续往外走一步:HTTP 请求从进来到出去,context、JSON、错误、超时、响应码怎么组成一个完整生命周期。
参考资料
• A new experimental Go API for JSON[1] • Go 1.25 Release Notes: New experimental encoding/json/v2 package[2] • Package encoding/json/v2[3] • Package encoding/json/jsontext[4] • Go 1.24 Release Notes: encoding/json omitzero[5]
引用链接
[1] A new experimental Go API for JSON: https://go.dev/blog/jsonv2-exp[2] Go 1.25 Release Notes: New experimental encoding/json/v2 package: https://go.dev/doc/go1.25#json_v2[3] Package encoding/json/v2: https://pkg.go.dev/encoding/json/v2[4] Package encoding/json/jsontext: https://pkg.go.dev/encoding/json/jsontext[5] Go 1.24 Release Notes: encoding/json omitzero: https://go.dev/doc/go1.24#encoding/json
夜雨聆风