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

Go jsonnull 如何限定字段范围

来源:17golang原创

时间:2026-09-13 10:38:25 204浏览 收藏

如果一个 Go 接口只需要“有值或没值”,普通指针已经够用;但 PATCH 更新常常还要区分“不修改”“明确清空”和“设置为空数组”。这时可以把 jsonnull 作为一个小型三态包装器,只放在确实需要这三种语义的请求字段上。它的关键不是让所有模型都变复杂,而是把字段范围限定在更新边界。

本文示例使用标准库 encoding/json 的自定义解码接口来实现这个边界。Go 官方文档说明,JSON 映射到 Go 值时,null 对非指针普通值不会自动留下“字段出现过”的信息,因此需要由包装类型自己记录。

先划清 jsonnull 的适用字段

建议先问一个问题:服务端是否必须知道客户端有没有提交这个字段?答案为“否”的响应 DTO、查询结果和只读配置,不需要使用 jsonnull。它们可以使用普通值、指针或 sql.Null* 等更符合自身边界的类型。

答案为“是”的 PATCH DTO 才适合使用它。例如个人资料更新中,nickname 可能被清空,tags 还要区分 null[]

jsonnull 字段范围与三态语义的静态关系图
图1:字段范围示意图,只有 PATCH DTO 的目标字段进入 jsonnull 三态边界。

这样做的收益是边界清楚:接收层负责表达请求意图,业务层负责把意图翻译成更新动作,持久化层不必猜测一个零值到底代表什么。

用三态值区分缺失、null 和空数组

一个可读的泛型类型可以包含三个状态:Present=false 表示字段缺失;Present=true、Valid=false 表示显式 null;两个布尔值都为真时,Value 才是有效值。对于 []string,有效值可以是空切片,因此它和 null 不是一回事。

// JSONNull 只描述请求字段的三态状态,不承担数据库持久化职责。
type JSONNull[T any] struct {
	Value   T
	Valid   bool // true 表示字段不是 JSON null
	Present bool // true 表示请求中出现了该字段
}

// NewValue 构造一个已提交的有效值,包括空切片。
func NewValue[T any](v T) JSONNull[T] {
	return JSONNull[T]{Value: v, Valid: true, Present: true}
}

// NewNull 构造一个需要清空目标字段的显式 null。
func NewNull[T any]() JSONNull[T] {
	return JSONNull[T]{Present: true}
}

这里的 jsonnull 是应用内命名,不是 Go 标准库中的独立类型。命名可以按项目习惯调整,但三态字段必须有稳定、可读的判断方法,不能依赖调用方直接猜布尔字段组合。

把字段范围收口到 PATCH 更新层

解码时要使用指针接收者,因为只有它能修改包装器中的状态。对 null 只记录出现,不把零值误当成有效值;对其他 JSON 值再解码到 Value。下面的代码只展示边界逻辑,调用方仍应处理返回错误。

// UnmarshalJSON 记录字段是否出现,并保留 null 与空数组的差别。
func (j *JSONNull[T]) UnmarshalJSON(data []byte) error {
	j.Present = true
	if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
		j.Valid = false // 显式 null:业务层通常解释为清空
		var zero T
		j.Value = zero
		return nil
	}
	if err := json.Unmarshal(data, &j.Value); err != nil {
		j.Valid = false // 解码失败时不产生可用值
		return err
	}
	j.Valid = true
	return nil
}

// PatchProfile 只在部分更新入口使用三态字段。
type PatchProfile struct {
	Nickname JSONNull[string]   `json:"nickname"`
	Tags     JSONNull[[]string] `json:"tags"`
}

// ApplyProfilePatch 把三态请求翻译为明确的业务动作。
func ApplyProfilePatch(p PatchProfile, dst *Profile) {
	if p.Nickname.Present {
		if p.Nickname.Valid {
			dst.Nickname = p.Nickname.Value // 有效字符串,包括空字符串
		} else {
			dst.Nickname = "" // null 表示清空
		}
	}
	if p.Tags.Present {
		if p.Tags.Valid {
			dst.Tags = append([]string(nil), p.Tags.Value...) // [] 表示设置为空集合
		} else {
			dst.Tags = nil // null 表示移除集合值
		}
	}
}

字段范围就在 PatchProfile 这一层收口。不要为了“统一”把数据库实体的所有可空列都替换成 JSONNull;那会把 HTTP 输入协议泄漏到存储模型,也容易让查询和响应出现不必要的三态判断。

PATCH DTO 中 nickname 和 tags 的三态边界关系图
图2:更新边界示意图,nickname 与 tags 分别把有效值、null、空数组映射到明确动作。

用表格测试边界而不是猜 tag

最小测试只需覆盖字段缺失、显式 null、普通值和空数组。注意:encoding/json 的字段标签只决定名称匹配,不会替你记录字段是否出现;这个信息必须由 UnmarshalJSON 保存。

// 这组表驱动用例验证请求意图,而不是只比较最终零值。
tests := []struct {
	name string
	body  string
	p     PatchProfile
	wantPresent bool
	wantValid   bool
}{
	{"缺失", `{}`, PatchProfile{}, false, false},
	{"null", `{"tags":null}`, PatchProfile{}, true, false},
	{"空数组", `{"tags":[]}`, PatchProfile{}, true, true},
}

for _, tt := range tests {
	var got PatchProfile
	err := json.Unmarshal([]byte(tt.body), &got) // 注释:解码失败应让测试直接失败
	if err != nil {
		t.Fatalf("%s: %v", tt.name, err)
	}
	if got.Tags.Present != tt.wantPresent || got.Tags.Valid != tt.wantValid {
		t.Fatalf("%s: got present=%v valid=%v", tt.name, got.Tags.Present, got.Tags.Valid)
	}
}

实际项目还应补上错误 JSON、数组元素类型错误以及更新后持久化失败的用例。最终判断标准不是“用了哪个包”,而是每个字段的协议是否明确:缺失不更新,null 清空,空数组设置为空集合。

常见问题

jsonnull 能替代所有指针吗?不能。只在“字段出现与否”会改变业务动作时使用;普通可选响应字段用指针通常更简单。

为什么不只用 omitemptyomitempty 主要影响编码时是否省略空值,不能在解码后告诉你字段是否出现在请求中,也不能单独表达 null 与空数组的业务含义。

应该把 Present 和 Valid 存进数据库吗?通常不应该。它们是请求期间的意图标记,业务层消费后只持久化真正的领域值。

总结

  • jsonnull 的核心价值是保存“出现过”这一信息。
  • 只把它放在 PATCH 等部分更新 DTO 的字段上。
  • 对集合字段明确约定:null 是清空或移除,空数组是设置为空集合。

官方参考:https://pkg.go.dev/encoding/json

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