encoding/json — JSON 序列化实战与陷阱
encoding/json 大概是 Go 里调用频率最高的标准库包:HTTP API 的请求响应、配置文件、消息队列载荷、日志结构化字段,几乎都走它。它的设计哲学是反射驱动、零注册——不需要像 protobuf 那样先定义 schema,给个结构体就能编解码,代价是运行时反射开销和一些"看起来生效其实没生效"的标签写法。
Go 1.27 对这个包做了一次底层替换:encoding/json 的实现换成了 encoding/json/v2,对外 API 与行为基本保持兼容,但错误文本、部分边界行为会变。官方发布说明原文:
The
encoding/jsonpackage is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ. […] Marshal performance is broadly at parity […], while unmarshal performance is significantly faster. Users who encounter compatibility problems […] may disable it by settingGOEXPERIMENT=nojsonv2at build time.
所以本文的组织方式是:先把日常用法和陷阱讲清楚(这些行为 v1/v2 一致或有明确差异会标注),再专门用一节梳理 v2 带来的变化与迁移策略。所有对比结论都在 go1.27.0 上实测过,引用的输出是程序真实打印结果。
1. 基础:标签语法与实际生效范围
type User struct {
ID int64 `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"-"` // 永不参与编解码
CreatedAt time.Time `json:"created_at,omitzero"`
Tags []string `json:"tags"`
secret string // 非导出字段:直接忽略,不报错
}标签形式是 json:"名称,选项1,选项2"。Go 1.27 认可的选项:omitempty、omitzero、string、case:ignore、embed,以及被解析但实测不可用的 format。
注意:
format选项在 1.27 的encoding/json和encoding/json/v2上都会让整次操作失败,go doc 的选项列表里能看到它,实现却尚未开放。实测:type T struct { A time.Time `json:"a,format:unix"` } json.Marshal(T{}) // json: unsupported value: Go struct field A has unsupported `format` tag option需要自定义时间格式,仍然只能自己实现
MarshalJSON/UnmarshalJSON(见 §6)。
几条反复踩到的基础规则:
- 只有导出字段会被编码。非导出字段静默跳过,不会报错;
Marshal时无法通过json:"X"强行导出。想让某个非导出字段参与编解码,只能给它写MarshalJSON。 - 字段名匹配大小写不敏感(v1 行为):JSON 里的
NAME、naMe都会落到Name字段。这是历史包袱,v2 收紧了(见 §2)。反过来,Marshal输出永远使用标签名或 Go 字段名,不会变。 - 匿名字段(嵌入结构体)的字段会被提升到外层,不需要任何标签。这是隐式行为;一旦你给嵌入字段显式写了标签名,就当作普通具名字段处理,不再提升。
json:"-"表示跳过,不是键名-。想让-成为真实键名,go doc给的两种写法实测都不能依赖:json:"-,"在 v1 兼容层可用(输出{"-":"k1"}),但底层 v2 对同一标签报Go struct field K1 has malformedjsontag: invalid trailing ',' character;文档推荐的单引号形式json:"'-'"两个引擎都不接受(v1 静默忽略标签、键名回落为字段名,v2 报invalid character '\'' at start of option)。真有这种畸形键名需求,改用map[string]any或json.RawMessage手工拼。
Marshal 与 MarshalIndent:
b, _ := json.Marshal(u) // 紧凑输出,无换行
bi, _ := json.MarshalIndent(u, "", " ") // 缩进输出生产里更推荐 json.NewEncoder(w) + SetIndent,原因在 §7(流式、少一次内存拷贝、注意尾部换行)。
2. JSON v2 时代:行为差异与迁移
2.1 三件套包结构
| 包 | 定位 |
|---|---|
encoding/json |
兼容层。API 不变(Marshal(v any) 不接选项),1.27 起底层是 v2 |
encoding/json/v2 |
新实现。Marshal/Unmarshal + 变参选项,另有 MarshalWrite/MarshalEncode/UnmarshalRead/UnmarshalDecode 直连 io 的入口 |
encoding/json/jsontext |
只管语法层(token/value 流),不做 Go 类型映射;json.RawMessage 现在是它的 Value 别名 |
关键的选项体系:encoding/json 导出了一批 Options 构造器(DefaultOptionsV1、OmitEmptyWithLegacySemantics、MergeWithLegacySemantics、FormatDurationAsNano、ReportErrorsWithLegacySemantics 等),但 v1 的 Marshal/Unmarshal 签名里没有选项参数——要精细控制 v1/v2 兼容度,必须显式 import v2,因为 Options 是同一类型别名,可以跨包传。实测:
type D struct { Dur time.Duration `json:"dur"` }
jsonv2.Marshal(D{Dur: time.Second})
// {"dur"(json: cannot marshal from Go time.Duration within "/dur": no default representation)
jsonv2.Marshal(D{Dur: time.Second}, json.FormatDurationAsNano(true))
// {"dur":1000000000}2.2 实测差异清单
以下都是同一个程序在 go1.27.0 上分别调用 encoding/json 与 encoding/json/v2 的输出对照,值得逐条过一遍——迁移时的问题基本都在这里。
| 场景 | v1 API(1.27 默认) | v2 API |
|---|---|---|
| HTML 转义 | {"Content":"<script>...&"} |
原样输出 <script>...& |
| nil slice / nil map | null |
[] / {} |
| map 键顺序 | 排序,确定性 | 非确定(两次运行顺序不同),Deterministic(true) 可恢复排序 |
| 非法 UTF-8 字符串 | 成功(坏字节替换为 U+FFFD) | 报错 jsontext: invalid UTF-8 within "/S" after offset 5 |
| JSON 对象重复键 | 成功,后者覆盖前者 | 报错 jsontext: duplicate object member name "a" |
| 大小写不同的键 | {"NAME":"upper"} → Name="upper" |
Name=""(除非标签写 case:ignore,实测 v1/v2 都生效) |
string 选项用在 bool |
忽略 | 报错 invalid use of 'string' tag option |
[4]byte |
[1,2,3,4] |
"AQIDBA=="(数组当 base64) |
time.Duration |
1000000000 |
报错,无默认表示 |
map 键类型有 MarshalJSON |
报错 object member name must be a string |
正常,{"m":{"addr":2}} |
畸形标签 json:"'-'" / json:"-," |
前者静默忽略(键名回落为字段名),后者输出 {"-":"..."} |
两者都报错:invalid character '\'' at start of option / invalid trailing ',' character |
| JSON 数组短于 Go 数组 | 逐元素写入,剩余清零 | 报错 too few array elements,且该字段完全不被改写 |
null 解码到非 nil 指针 |
指针置为 nil | 保留原值(合并语义) |
| 未知字段 | 忽略 | 忽略;RejectUnknownMembers(true) 报错 |
其中三条最容易在上线后暴露:
time.Duration直接编不过去了。v2 认为纳秒整数是"实现细节"而不是合法 JSON 表示。若接口里必须输出数字纳秒,用int64字段自己d.Nanoseconds();若可接受字符串,写"1s"(自定义类型包一层)。- nil slice →
[]。前端拿到空数组而非null通常是好事,但如果对端用null区分"字段缺失"和"空集合",这就是行为变更。要 v1 语义:jsonv2.Marshal(v, json.FormatBytesWithLegacySemantics(true))之类的 legacy 开关不覆盖它,实测最稳的办法是标签omitempty/omitzero或显式初始化。 - map 顺序不再确定。如果输出被用于 diff、缓存 key、签名、快照测试,必须
Deterministic(true),或者干脆改用 slice。
2.3 迁移策略
- 短期求稳:构建时加
GOEXPERIMENT=nojsonv2回到 v1 实现。这是过渡用的逃生阀,不是长期方案——官方明确它只为兼容问题兜底。 - 求性能:直接把热路径切到
encoding/json/v2。收益主要在解码(官方说明"unmarshal performance is significantly faster"),编码大致持平。注意 v2 的严格校验会暴露原来被吞掉的脏数据(非法 UTF-8、重复键),对接收外部输入的路径要处理好错误分支。 - 混合使用:
Options类型是别名,v1 包导出的 legacy 构造器可以传给 v2 函数(上面FormatDurationAsNano的实测就是证据)。别指望encoding/json本身能传选项。
注意:
json.Marshaler/json.Unmarshaler现在是jsonv2.Marshaler/jsonv2.Unmarshaler的别名,方法签名不变(MarshalJSON() ([]byte, error)),因此老代码的类型断言、接口实现继续有效。
3. omitempty 与 omitzero:两种"跳过"的语义
omitempty 的"empty"不是"零值",它是一套自己的判定;而 omitzero 才是"等于零值就跳过"。go1.27 实测(v1 与 v2 都对 omitzero 生效):
type Inner struct{ Value string }
type Row struct {
A Inner `json:"a,omitempty"`
B Inner `json:"b,omitzero"`
C string `json:"c,omitempty"`
D int `json:"d,omitzero"`
E []string `json:"e,omitempty"`
F []string `json:"f,omitzero"`
G map[string]int `json:"g,omitzero"`
H time.Time `json:"h,omitzero"`
}
// 全零值,F 设为 []string{}:实测输出 {"a":{"Value":""},"f":[]}
// 全零值,F 为 nil: 实测输出 {"a":{"Value":""}}结论拆开说:
omitempty对 struct 类型无效(A照样输出{"Value":""}),这是最经典的误解——struct 永远不算 empty。omitzero解决的就是它。omitempty对空 slice/map、空字符串、0、false、nil 指针都有效;omitzero同样有效,但对非 nil 的空 slice([]string{})不跳过(F输出了[]),因为[]string{}不等于零值。time.Time{}用omitempty永远输出(struct),用omitzero会被跳过——注意omitzero对time.Time的判定用的是它的零值,不是"零时长"。
陷阱:想让
omitempty生效于指针字段的指向值,靠的是指针本身 nil。var p *Inner = &Inner{}依然会输出{"Value":""},因为非 nil 指针不为 empty。这类"我明明写了 omitempty 为什么还是输出了"的问题,八成是指针/struct 组合。
新代码建议:语义上想表达"没有就不输出",一律 omitzero;只有需要区分 [] 与 null 这类历史兼容场景才留 omitempty。
4. json.RawMessage — 延迟解析
1.27 里 json.RawMessage = jsontext.Value,本质仍是 []byte,但类型定义在 encoding/json/jsontext。它的价值只有一个:解码时不解析,原样保存字节,是做多态载荷(envelope 模式)的核心手段。
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"` // 先不解析
}
type UserPayload struct {
Name string `json:"name"`
Age int `json:"age"`
}
type OrderPayload struct {
OrderID int `json:"order_id"`
Amount float64 `json:"amount"`
}
func handleMessage(data []byte) error {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
switch msg.Type {
case "user":
var u UserPayload
return json.Unmarshal(msg.Payload, &u)
case "order":
var o OrderPayload
return json.Unmarshal(msg.Payload, &o)
default:
return fmt.Errorf("unknown type %q", msg.Type)
}
}四点实践经验(都实测过):
- 网关/透传场景:
Payload json.RawMessage能把不认识的字段原样转发,避免"结构化后再序列化丢字段"。这是 Go 写 API gateway 最常见的技巧。 Marshal时会压缩:塞进 RawMessage 的"{\n \"a\" : 1\n}"输出成{"payload":{"a":1}},空白被吃掉。若你要逐字节保真(例如做签名),RawMessage 不是存储原始字节的地方。- 字段缺失时是 nil:
{"type":"t"}解出来Payload == nil,再编码时输出"payload":null(不会报错)。判空要判len(e.Payload) == 0。 - 校验前置:它不豁免语法检查——上游给
{"payload":123abc}时,Unmarshal直接报invalid character 'a' after object key:value pair,因为解析器必须自己定位值的边界。 - 配合
jsontext:需要更细粒度控制(只读某个 token、手写 JSON 而不经过反射),直接用encoding/json/jsontext的Encoder/Decoder,它工作在 token/value 层,不碰 Go 类型。
5. json.Number 与大整数精度
Unmarshal 到 any/map[string]any 时,所有 JSON 数字都变成 float64——超过 2^53 的整数必然丢精度。实测:
var v any
json.Unmarshal([]byte(`{"amount": 12345678901234567890}`), &v)
// map[amount:1.2345678901234567e+19]两条出路:
(1)字段类型直接写 json.Number——注意这不需要 UseNumber(),UseNumber 只影响解码目标为 any 的情形:
type Tx struct {
Amount json.Number `json:"amount"`
}
var tx Tx
err := json.Unmarshal(raw, &tx) // err = nil
fmt.Printf("%q\n", tx.Amount.String()) // "12345678901234567890"(2)解码到 any 时开 UseNumber():
dec := json.NewDecoder(r)
dec.UseNumber()
var v any
dec.Decode(&v) // v.(map[string]any)["n"] 的类型是 json.Number,不是 float64陷阱:
json.Number.Int64()在超范围时不是返回 0,而是返回math.MaxInt64(或 MinInt64)并给出错误——如果只取第一个返回值,会静默拿到一个"看起来合法"的巨大数字。实测:n := json.Number("12345678901234567890") i, err := n.Int64() // i = 9223372036854775807, err = strconv.ParseInt: ... value out of range所以
Int64()的 error 必须判。金额/ID 类字段如果要保真,一路用字符串传(或json:"id,string"让对端把数字写成字符串)。
6. 自定义 MarshalJSON / UnmarshalJSON
接口很薄,实现容易写出死循环和空指针问题。
6.1 自定义时间格式(避免递归)
type LocalTime struct {
time.Time
}
const layout = "2006-01-02 15:04:05"
// 值接收者即可,但要注意零值与空串输入
func (t LocalTime) MarshalJSON() ([]byte, error) {
return []byte(`"` + t.Format(layout) + `"`), nil
}
func (t *LocalTime) UnmarshalJSON(data []byte) error {
s := strings.Trim(string(data), `"`)
if s == "" || s == "null" {
t.Time = time.Time{}
return nil
}
parsed, err := time.ParseInLocation(layout, s, time.Local)
if err != nil {
return err
}
t.Time = parsed
return nil
}要点:
- 返回的字节必须是合法 JSON(含引号)。少写引号是最常见的 bug,症状是
invalid character报错指向调用方而不是你。 - 不要给同一个类型既嵌入又调用
json.Marshal(t)——那会再次命中你的MarshalJSON,栈溢出。惯例是定义新类型绕过:type alias T然后json.Marshal(alias(t))。 UnmarshalJSON的接收者必须是指针,否则无法写回。
6.2 枚举与 nil 指针
type Status int
const (
StatusUnknown Status = iota
StatusActive
StatusClosed
)
var statusNames = map[Status]string{StatusActive: "active", StatusClosed: "closed"}
func (s Status) MarshalJSON() ([]byte, error) {
if n, ok := statusNames[s]; ok {
return json.Marshal(n)
}
return json.Marshal(int(s)) // 未知值退化成数字,比报错友好
}注意:nil 指针永远不会走到你的
MarshalJSON。实测(go1.27,方法里已写if p == nil分支):json.Marshal([]*Ptr{nil}) // [null] —— 方法未被调用 json.Marshal(struct{ P *Ptr }{}) // {"p":null} —— 同样未被调用想让
null输出成别的字符串,只能在外层类型上做文章(字段用值类型 +omitzero,或用一个包装类型的MarshalJSON生成整个对象)。反方向不同:UnmarshalJSON是指针接收者,解码到 nil 指针字段时包会先分配再调用(实测recv nil = false);但 JSON 值恰好是null时方法同样不被调用,指针保持 nil。
另一个高频问题是 MarshalJSON 返回值不合法(少引号、多空格)。错误会被包装成类型名可定位的形式,实测:
json: error calling MarshalJSON for type *main.PtrOnly: invalid character '"' after top-level value看到 error calling MarshalJSON for type X 就说明问题在 X 自己的实现里,不要在被编码的结构体里瞎找。
6.3 只想要 RawMessage 的效果
如果目的只是"这个字段的 JSON 由外部决定",json.RawMessage 比手写 MarshalJSON 更简单,且天然可空(nil 时输出 null)。
7. 流式编解码
7.1 Encoder:NDJSON 与大响应
enc := json.NewEncoder(w)
enc.SetIndent("", " ") // 需要缩进时才开
if err := enc.Encode(item); err != nil {
return err
}陷阱:
Encoder.Encode每次写入都会在末尾追加一个换行符(实测输出{"k":1}\n)。这不是 bug,而是刻意设计:JSON stream(RFC 7464 的 text sequence 风格)与 NDJSON 都依赖这个分隔符。副作用是——
- 把
Encode结果当"完整 JSON 文档"再json.Unmarshal没问题(解析器容忍尾部空白);- 但做内容哈希、签名、逐字节 diff 时,这多出的
\n会让你和别的语言实现对不上;- 写到 HTTP response 时,Go 会走 chunked 编码,
Content-Length拿不到,需要精确长度的场景先Marshal再写 body。
Encode 单个值是原子的:实测把含 chan 字段的结构体编码失败时,writer 里没有半截字节(buf="",err=json: unsupported type: chan int),仍然是上一个完整值。所以连续 Encode 写 NDJSON 时,出错那一条不会污染文件,但也别指望它能把超大值边生成边吐出去——需要增量写字节流,v2 提供了 MarshalWrite/MarshalEncode。
7.2 Decoder:大文件与逐 token
// 逐条解码 JSON 数组(不整体读入内存)
dec := json.NewDecoder(f)
dec.Token() // 吃掉开头的 '['
for dec.More() { // 判断数组里是否还有元素
var item Record
if err := dec.Decode(&item); err != nil {
return fmt.Errorf("record %d: %w", n, err)
}
n++
}实用细节:
-
Decoder会缓冲多于一个值的数据。实测:解码{"a":1} trailing后,dec.Buffered()里仍留着未消费的trailin…。用它读http.Response.Body里的多个 JSON 值时,如果提前Closebody,留在Decoder缓冲里的字节就丢了;dec.Buffered()可以把剩余字节交还给后续逻辑(同一个连接上换协议继续读写时尤其重要)。 -
dec.UseNumber()只作用于解码到any的目标(见 §5)。 -
dec.DisallowUnknownFields()用来把"字段拼错"从静默忽略变成显式错误,配置文件解析强烈建议开:dec.DisallowUnknownFields() err := dec.Decode(&cfg) // json: unknown field "zzz"v2 等价写法:
jsonv2.Unmarshal(b, &cfg, jsonv2.RejectUnknownMembers(true))。 -
dec.InputOffset()返回出错时的字节偏移,处理上游大 JSON 时定位问题字段很有用(v2 的错误消息里本来就带after offset N)。
7.3 校验与格式化
if !json.Valid(data) { /* 非法 JSON */ } // 实测:json.Valid([]byte("{}{}")) == false
var buf bytes.Buffer
json.HTMLEscape(&buf, data)
json.Compact(&buf, data)
json.Indent(&buf, data, "", " ")json.Valid 只做语法检查,不做语义检查,适合"进来先挡一道"。注意它认定 {}{} 非法(一个文档只允许一个顶层值),而 Decoder 的流式语义允许连续多个值——两者判定标准不同,别把 Valid 用在 NDJSON 上。
8. 陷阱汇总表
| # | 现象 | 原因与对策 |
|---|---|---|
| 1 | Marshal 少字段 |
字段名首字母小写(非导出)。加导出字段或写 MarshalJSON |
| 2 | omitempty 没生效 |
对 struct 永远无效;改 omitzero(§3) |
| 3 | 大整数 ID 变成 1.2345678901234567e+19 |
any 目标走 float64;用 json.Number 字段或 UseNumber() |
| 4 | Int64() 返回天文数字且不报错? |
error 被忽略了;溢出时它返回 MaxInt64 + error(实测) |
| 5 | 富文本被转成 < |
v1 API 默认 HTML 转义。用 Encoder.SetEscapeHTML(false),或切 v2(默认不转义) |
| 6 | 上游字段名大小写不一致,接不到值 | v1 大小写不敏感、v2 严格。要容错写 json:"name,case:ignore"(实测 v1/v2 都生效) |
| 7 | time.Duration 编码到对端变成纳秒大整数 |
v1 行为;v2 直接报错。字段用秒(float64/int)或字符串(“1s”)自己转 |
| 8 | map 输出顺序变了导致 diff/签名不稳定 | v2 不排序;jsonv2.Deterministic(true),或换 slice |
| 9 | 循环引用 panic? | 不是 panic,是错误:json: unsupported value: encountered a cycle via map[string]interface {};NaN/Inf 同样报 unsupported value: NaN |
| 10 | Unmarshal 到已有值的结构体,旧数据没被清空 |
设计如此:逐字段合并,JSON 里没出现的键保留原值。复用变量前先归零 |
| 11 | map 的 key 类型不被支持 | 只有 string、整数、以及实现了 MarshalText/MarshalJSON 的类型可作 key(v1 不调 key 的 MarshalJSON,实测 v2 才支持);map[[2]int]int 直接报错 |
| 12 | 解码后 Rows/Items 是 null 而非 [] |
上游就给了 null,或 v1 语义下的 nil slice。前端判空要同时看 null 与 [] |
| 13 | json.Unmarshal(body, &x) 忽略了 Decode 的流语义 |
Unmarshal 要求整个输入恰好是一个值;带尾随数据({}{})会报错 |
| 14 | 错误类型判断 | 用 *json.SyntaxError/*json.UnmarshalTypeError 配合 errors.As;InvalidUTF8Error、UnmarshalFieldError 已标注 Deprecated 且不再产生 |
| 15 | 畸形键名标签 json:"'-'" / json:"-," |
v1 一个静默忽略、一个能用;v2 两个都报错。别再依赖这类写法(§1) |
9. 实战建议
API 服务的默认选择就是 encoding/json,不要一上来就引第三方。 Go 1.27 之后它的解码性能明显改善,反射开销不再是换库的首要理由。真到了 profile 显示 JSON 是热点时,再按顺序考虑:切 encoding/json/v2 → 减少一次性的 map[string]any 解析(改结构体)→ 热路径用 MarshalWrite 直接写 io.Writer 省一次 []byte 分配 → 最后才考虑 codegen 方案。
给外部系统的载荷,用结构体而不是 map[string]any。 结构体的字段顺序确定、编译期能查拼写、case:ignore 之类语义清晰;map 除了顺序(v2 不排序)还容易把 nil 与空 map 的区分弄丢。
解码入口做严格化,编码出口做宽松化。 配置文件、内部 RPC 一律 DisallowUnknownFields(拼错字段静默失效的成本,远高于升级时多改一行);对外 HTTP 响应则不要开这类严格校验,避免版本演进时把老客户端打死。
迁移到 1.27 时,先跑一遍解码路径的测试。 上面差异表里的非法 UTF-8、重复键、time.Duration、nil slice 四项,绝大多数只在"输入不干净"或"类型特殊"时炸,而这三类正是单元测试覆盖最薄的地方。真出问题时 GOEXPERIMENT=nojsonv2 给你退回的余地,但请把它当临时措施登记到 TODO 里,别变成永久技术债。
日志字段序列化不要用 json.Marshal 拼字符串。 结构化日志交给 log/slog(见 ../01-基础核心包/errors-and-log.md)处理;它的 Handler 内部有自己的编码策略,且能避免把错误对象、time.Duration 这类特殊类型序列化失败拖垮整条日志。
相关阅读:本目录的 encoding-gob.md(Go 内部 RPC/持久化的二进制格式)、hash-mac-aead.md(hex/base64 与哈希选型)、text-template.md(HTML 输出场景下的转义边界);HTTP 层用法见 ../03-网络编程/net-http.md;标准库新增 uuid 包见 ../01-基础核心包/uuid.md。