登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go encoding/json 怎样区分缺失字段和显式 null完成一个可复用的最小示例

来源:17golang原创

时间:2026-11-07 03:30:20 260浏览 收藏

我在做一个“部分更新配置”的接口时,最容易被忽略的不是 JSON 语法,而是字段状态:客户端不传字段,通常表示保持原值;客户端传入 null,可能表示清空;客户端传入具体值,则表示覆盖。如果直接把请求解码到普通 Go 结构体,这三种意思很容易被压成同一个零值。

官方文档:https://pkg.go.dev/encoding/json

这篇保留一个足够小、可以复用的模式:固定协议使用带状态的 Optional[T],动态协议使用 map[string]json.RawMessage。前者适合业务更新对象,后者适合字段不固定的 JSON。

先看普通结构体为什么丢失状态

标准 encoding/json 在解码结构体时,会按 JSON 键匹配字段;输入中没有出现的键不会主动替你写入。对于指针字段,JSON null 会把指针设为 nil,于是“没出现”和“明确传 null”都可能表现为同一个结果。

package main

import (
	"encoding/json"
	"fmt"
)

type Patch struct {
	// 普通指针只能表达当前值,不能可靠表达字段是否出现过。
	Name *string `json:"name"`
}

func main() {
	for _, input := range []string{`{}`, `{"name":null}`, `{"name":"Go"}`} {
		var patch Patch
		if err := json.Unmarshal([]byte(input), &patch); err != nil {
			// 输入不是合法 JSON 时,先返回解析错误,不继续处理更新。
			fmt.Println("decode error:", err)
			continue
		}
		fmt.Printf("%s => name is nil: %v\n", input, patch.Name == nil)
	}
}

这里前两种输入都会得到 patch.Name == nil。如果目标是“把 null 当成清空指令”,这个信息已经无法从结构体字段本身恢复。更隐蔽的是:如果复用同一个目标对象,缺失字段还会保留它原来的值,所以每次解码前都要明确是否使用新对象。

把三种含义收进 Optional 状态封装

我更常用的做法是给可选字段增加两个状态位:Present 表示键在 JSON 中出现过,Null 表示它的值是显式 null。只有既出现又非 null 时,Value 才有业务意义。

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
)

type Optional[T any] struct {
	// Present 区分“请求没有这个键”和“请求明确传了一个值”。
	Present bool
	// Null 区分“出现了 null”和“出现了可解码的具体值”。
	Null  bool
	Value T
}

func (o *Optional[T]) UnmarshalJSON(data []byte) error {
	// UnmarshalJSON 会在字段出现时调用,包括输入为 null 的情况。
	o.Present = true
	o.Null = bytes.Equal(bytes.TrimSpace(data), []byte("null"))
	if o.Null {
		var zero T
		o.Value = zero
		return nil
	}
	// 具体值解码失败时返回原始错误,避免把坏输入误当成缺失字段。
	return json.Unmarshal(data, &o.Value)
}

type Patch struct {
	Name Optional[string] `json:"name"`
}

func main() {
	for _, input := range []string{`{}`, `{"name":null}`, `{"name":"Go"}`} {
		var patch Patch
		if err := json.Unmarshal([]byte(input), &patch); err != nil {
			fmt.Println("decode error:", err)
			continue
		}
		fmt.Printf("%s => present=%v null=%v value=%q\n",
			input, patch.Name.Present, patch.Name.Null, patch.Name.Value)
	}
}

结果可以按三态读取:Present=false 是缺失;Present=true, Null=true 是显式 null;两个状态都为真且 Null=false 时读取 Value。这个模式的关键不是泛型,而是 UnmarshalJSON 只负责记录“字段出现过”,业务层再决定缺失意味着保持、null 意味着清空、具体值意味着覆盖。

Optional 状态封装把 JSON 字段映射到 Present、Null 和 Value 的静态关系说明图
图1:结构说明图,查看请求字段、Optional 封装和三种状态之间的静态关系;这不是运行截图。

动态字段用 RawMessage 检查键是否存在

如果字段集合不是固定的,或者你只想在某个局部判断状态,可以先解码成 map[string]json.RawMessage。map 的第二个返回值负责判断键是否存在,RawMessage 的字节内容再负责判断是否为 null。

func classify(input []byte, key string) (string, error) {
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(input, &fields); err != nil {
		// 语法错误直接交给调用方,避免把错误输入当成字段缺失。
		return "invalid-json", err
	}
	raw, ok := fields[key]
	if !ok {
		// 键不存在,表示调用方没有提出这项更新。
		return "missing", nil
	}
	if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
		// 键存在但值为 null,通常对应清空动作。
		return "null", nil
	}
	return "value", nil
}

这个方式不会自动把具体值解码成目标类型,因此适合先分流、后按字段策略解码的入口。需要注意的是,空字符串、数字零、空数组都不是 null;它们属于具体值,是否视为“清空”要由协议约定。

map[string]json.RawMessage 通过键存在性和 null 字节判断动态字段状态的关系说明图
图2:结构说明图,查看动态字段从 map 到 RawMessage 再到 missing、null、value 分支的关系;这不是运行截图。

复用对象时的反例和选择清单

第一个反例是把同一个 Patch 连续用于多个请求:第二次输入缺失 name 时,旧的 Present 和 Value 可能仍在。最简单的修复是每次请求创建新目标;如果必须复用,就在解码前完整清零,而不是只清理 Value。

第二个反例是只使用 *string 或 interface{},然后把 nil 直接解释成“清空”。这会把客户端没有提出变更的请求误判成清空请求。固定字段优先选 Optional[T],需要动态键或延迟解码时选 map[string]json.RawMessage。

最后可以用这张小清单做判断:要区分缺失和 null,就必须保留“键是否出现”的状态;要区分 null 和具体值,就必须检查原始 JSON 或在自定义解码器里设置标记;要避免旧值污染,就不要把缺失字段当成“自动清零”。

常见问题

只用 json.RawMessage 字段可以吗?

可以。结构体中的非指针 json.RawMessage 通常能用 nil 表示缺失、用字节串 null 表示显式 null,但要注意目标对象复用时的旧值问题;固定业务协议仍建议用带状态封装表达意图。

为什么不直接给字段加 omitempty?

omitempty 影响的是编码输出,不是解码时的字段存在性判断。它不能让普通结构体在解码后自动知道某个键是否出现过。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>