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 和 []:

这样做的收益是边界清楚:接收层负责表达请求意图,业务层负责把意图翻译成更新动作,持久化层不必猜测一个零值到底代表什么。
用三态值区分缺失、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 输入协议泄漏到存储模型,也容易让查询和响应出现不必要的三态判断。

用表格测试边界而不是猜 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 能替代所有指针吗?不能。只在“字段出现与否”会改变业务动作时使用;普通可选响应字段用指针通常更简单。
为什么不只用 omitempty?omitempty 主要影响编码时是否省略空值,不能在解码后告诉你字段是否出现在请求中,也不能单独表达 null 与空数组的业务含义。
应该把 Present 和 Valid 存进数据库吗?通常不应该。它们是请求期间的意图标记,业务层消费后只持久化真正的领域值。
总结
jsonnull的核心价值是保存“出现过”这一信息。- 只把它放在 PATCH 等部分更新 DTO 的字段上。
- 对集合字段明确约定:null 是清空或移除,空数组是设置为空集合。
官方参考:https://pkg.go.dev/encoding/json
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
360 收藏
-
361 收藏
-
Golang · Go问答 | 46分钟前 | JSON · 错误处理 · go · encoding/json · 接口边界 · Go json.Unmarshal UnmarshalJSON JSON自定义解码433 收藏
-
118 收藏
-
242 收藏
-
287 收藏
-
266 收藏
-
Golang · Go问答 | 1小时前 | 连接池 · HTTP客户端 · Go问答 · Transport · 生命周期管理 · Go HTTP客户端 连接池 http.Transport CloseIdleConnections Transport生命周期262 收藏
-
Golang · Go问答 | 2小时前 | 连接池 · HTTP客户端 · Go问答 · 端口排查 · Transport · TIME_WAIT httptrace http.Transport Go transport Go端口增长 HTTP连接复用 CLOSE_WAIT297 收藏
-
Golang · Go问答 | 2小时前 | 性能排查 · HTTP客户端 · Go问答 · 连接复用 · Transport · MaxIdleConnsPerHost Go连接池 Go transport http.Transport连接复用 HTTP keep-alive268 收藏
-
Golang · Go问答 | 2小时前 | Context · HTTP客户端 · Go问答 · Transport · 请求超时 · Go请求超时 Go clienttimeout http.Client Timeout Go客户端超时 Transport阶段超时338 收藏
-
465 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习