JSON 边界上的坑:零值、nil、omitempty、数字精度与未知字段
JSON 的难点不在编解码,而在“缺失、空值、零值”是否表达同一件事。边界模型一旦含糊,Go 的零值会悄悄替业务做决定。
nil 和空集合在 JSON 中不同
type Result struct {
Items []string `json:"items"`
Meta map[string]string `json:"meta"`
}
b1, _ := json.Marshal(Result{})
// {"items":null,"meta":null}
b2, _ := json.Marshal(Result{
Items: []string{}, Meta: map[string]string{},
})
// {"items":[],"meta":{}}如果 API 契约规定集合永远是数组/对象,就在构造响应时初始化它们,或设计专门的响应 DTO。不要把内部领域对象直接序列化出去:内部的 nil 可能只意味着“尚未加载”,对外却变成了 null。
Patch 请求需要三态,而不是一个零值
flowchart LR
M["字段缺失"] --> A["保持原值"]
N["字段为 null"] --> B["清空值"]
V["字段有值(含 0/false/空字符串)"] --> C["更新为该值"]
普通字段只能表达两态:
type UpdateUser struct {
Age int `json:"age"`
}解码后无法区分 {} 和 {"age":0}。*int 能区分缺失与数字,但 null 和缺失都会得到 nil。需要完整三态时,可以写一个带 Set、Null 标记的泛型类型并实现 UnmarshalJSON:
type Optional[T any] struct {
Value T
Set bool
Null bool
}
func (o *Optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if bytes.Equal(data, []byte("null")) {
o.Null = true
return nil
}
return json.Unmarshal(data, &o.Value)
}注意:只有 JSON 中出现字段时才会调用字段的 UnmarshalJSON,因此 Set 可以识别缺失。
omitempty 是编码规则,不是业务规则
omitempty 会省略 false、0、空字符串、长度为零的数组/切片/map,以及 nil 指针和接口。它适合“零值与缺失语义相同”的字段,不适合需要明确输出 false 或 0 的契约。
type Response struct {
Enabled *bool `json:"enabled,omitempty"`
}指针可以表达“未提供”,代价是调用方需要处理 nil。对于复杂边界模型,清晰的请求/响应类型通常比在领域结构体上堆标签更可靠。
数字默认会丢掉类型信息
解码到 any 时,JSON 数字默认成为 float64。超过 JavaScript 安全整数范围的 ID 会有跨语言精度风险;极大的整数转为 float64 也不能保持原值。
dec := json.NewDecoder(r.Body)
dec.UseNumber()
var v map[string]any
if err := dec.Decode(&v); err != nil { return err }
n := v["order_id"].(json.Number)
id, err := n.Int64()更好的办法是尽量解码到静态结构体。跨系统的大整数标识符常用 JSON 字符串表达,并在边界显式校验。
面向外部输入应严格解码
json.Unmarshal 默认忽略未知字段,字段名匹配还不区分大小写。这对向后兼容方便,却会让客户端拼错字段也得到成功响应。
func decodeJSON[T any](r io.Reader, dst *T) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil { return err }
var extra any
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil { return errors.New("multiple JSON values") }
return err
}
return nil
}只调用一次 Decode 会接受 {} {} 这种尾随第二个 JSON 值;检查下一次必须是 io.EOF 才算完整。HTTP 入口还应配合 MaxBytesReader,否则严格语法也挡不住超大 Body。
自定义 MarshalJSON / UnmarshalJSON 适合时间、金额、枚举等明确的线协议,但应避免递归调用自身:通常定义一个无方法的别名再编解码。还要记住 Encoder 默认会转义 HTML 字符;若关闭 SetEscapeHTML(false),要确认输出所处的 HTML 上下文不会引入注入风险。
边界层的原则
- 为 API 定义独立 DTO,并为
null、空集合、缺失字段写契约测试。 - 请求严格、响应稳定;兼容策略由版本治理决定,而非解码器默认值。
- 金额使用最小货币单位整数或十进制定点类型,ID 不要经过
float64。 - PATCH 等部分更新显式建模三态。
- 不在日志中原样打印未知 JSON,避免敏感数据和日志注入。
进一步阅读:encoding/json 包文档。