跳至内容
JSON 边界上的坑:零值、nil、omitempty、数字精度与未知字段

JSON 边界上的坑:零值、nil、omitempty、数字精度与未知字段

2026年6月16日·
yanlong

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。需要完整三态时,可以写一个带 SetNull 标记的泛型类型并实现 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 会省略 false0、空字符串、长度为零的数组/切片/map,以及 nil 指针和接口。它适合“零值与缺失语义相同”的字段,不适合需要明确输出 false0 的契约。

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 包文档