encoding/json 解码时怎样区分字段缺失和显式 null
来源:17golang原创
时间:2026-11-13 11:13:40 301浏览 收藏
在部分更新接口里,JSON 字段“没有出现”和“明确写成 null”通常不是同一个动作:前者可能表示保持原值,后者可能表示清空。Go 的 encoding/json 如果直接把两种输入解码到普通字段,很多时候只留下一个零值,业务层就无法判断用户到底提交了什么。
解决思路是先确定接口需要几种状态,再选择承载方式:简单场景用指针表达“有值/无值”,需要严格区分字段是否出现时用 json.RawMessage,需要让业务代码只面对统一结果时,把判断封装进自定义 UnmarshalJSON。
不要先问“哪个字段类型最方便”,先问“字段缺失、显式 null 和具体值是否对应不同业务动作”。只要三者有区别,就不能把它们都当成普通零值处理。
先把字段状态定义成三态
以修改用户备注为例,接口可能有三种输入:请求体没有 remark,表示不修改;"remark": null,表示清空;"remark": "周末联系",表示替换成新内容。这个判断属于输入契约,不是 JSON 库自动替你做出的业务决策。

如果三种输入最后都落成空字符串或 nil,更新函数就无法区分“保持不变”和“清空”。因此先把状态写成表格更稳妥:
| JSON 输入 | 接口语义示例 | 需要保留的信号 |
|---|---|---|
| 字段未出现 | 保持数据库原值 | 字段是否出现 |
"remark": null | 清空数据库字段 | 出现且值为 null |
"remark": "周末联系" | 写入新值 | 出现且值可解析 |
用指针处理最简单的有值与无值
当业务只需要区分“请求里给了一个值”和“没有可用值”时,指针字段很直接。对指针字段解码时,JSON 的 null 会得到 nil;字段未出现时,字段也不会被填充。因此它适合“非 nil 就更新,nil 就跳过”的二态场景,但不能独立区分缺失和显式 null。
type PatchUser struct {
// 指针用来表达:本次请求是否提供了一个非 null 的备注值。
Remark *string `json:"remark"`
}
func applySimplePatch(dst *string, patch PatchUser) {
// nil 同时覆盖“字段缺失”和“字段显式为 null”,这里只能选择跳过。
if patch.Remark == nil {
return
}
// 非 nil 时才把请求中的具体字符串写回业务对象。
*dst = *patch.Remark
}
如果你的接口约定是“null 也不修改”,这个模型已经足够;如果约定是“null 要清空”,就必须增加字段存在信号。一个常见做法是给指针再包一层状态类型,而不是在业务层猜 nil 的含义。
用 RawMessage 捕获字段是否出现
json.RawMessage 可以暂存字段的原始 JSON 字节。把它放在带指针的中间结构中,可以先判断键是否出现,再判断原始内容是否为 null,最后才把具体值解码到目标类型。
type PatchUserRaw struct {
// RawMessage 保留字段的原始 JSON,便于区分缺失与 null。
Remark *json.RawMessage `json:"remark"`
}
func parseRemark(data []byte) (present bool, clear bool, value string, err error) {
var raw PatchUserRaw
if err := json.Unmarshal(data, &raw); err != nil {
// 输入不是合法 JSON 时,把错误交给上层,不把它当成字段缺失。
return false, false, "", err
}
if raw.Remark == nil {
// 指针为 nil 代表字段没有提供,或输入明确为 null;继续看原始结构无法区分。
return false, false, "", nil
}
if bytes.Equal(bytes.TrimSpace(*raw.Remark), []byte("null")) {
// 出现且是 null,交给业务层执行清空动作。
return true, true, "", nil
}
if err := json.Unmarshal(*raw.Remark, &value); err != nil {
// 出现但类型不对时保留解析错误,避免静默写入空值。
return true, false, "", err
}
return true, false, value, nil
}
这里有一个容易忽略的限制:*json.RawMessage 仍然会把缺失和显式 null 都表现为 nil。若要保留三态,应该使用自定义类型的 UnmarshalJSON,或者先把整个对象解码到 map[string]json.RawMessage,通过键是否存在来判断。
用 map 加 RawMessage 直接判断键是否存在
当字段数量不固定,或者只需要处理少数动态字段时,map[string]json.RawMessage 更直观:map 中没有键就是缺失;有键但字节内容是 null 就是显式 null;其他内容再解码成目标类型。
func parseDynamicRemark(data []byte) (present bool, clear bool, value string, err error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
// 顶层不是对象或 JSON 格式错误,都应作为输入错误返回。
return false, false, "", err
}
raw, ok := fields["remark"]
if !ok {
// map 中没有键,表示客户端没有提交 remark。
return false, false, "", nil
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
// 键存在且值为 null,表示显式清空。
return true, true, "", nil
}
if err := json.Unmarshal(raw, &value); err != nil {
// 类型不匹配时不要降级成默认字符串。
return true, false, "", err
}
return true, false, value, nil
}
这个方案的代价是字段检查和类型解码都由调用方维护,适合动态字段,不适合让大量业务结构都依赖字符串键。固定请求结构更推荐下一节的自定义类型。
用自定义 UnmarshalJSON 封装三态结果
可以定义一个带 Set、Null、Value 的字段类型:Set=false 表示缺失;Set=true, Null=true 表示显式 null;Set=true, Null=false 表示出现了具体值。自定义解码器只负责把输入翻译成这三个状态,更新逻辑仍由业务层决定。

type OptionalString struct {
// Set 表示字段是否在 JSON 中出现,Null 表示出现后是否为 null。
Set bool
Null bool
Value string
}
func (o *OptionalString) UnmarshalJSON(data []byte) error {
// 进入自定义解码器就说明字段已经出现,先记录这一事实。
o.Set = true
o.Null = bytes.Equal(bytes.TrimSpace(data), []byte("null"))
if o.Null {
// 显式 null 不再尝试解码成字符串,清空旧值避免残留。
o.Value = ""
return nil
}
// 具体值沿用 encoding/json 的类型检查和转义处理。
return json.Unmarshal(data, &o.Value)
}
type PatchUserTriState struct {
// 缺失时不会调用 OptionalString.UnmarshalJSON,Set 会保持 false。
Remark OptionalString `json:"remark"`
}
func applyTriState(dst *string, patch PatchUserTriState) {
if !patch.Remark.Set {
// 字段缺失:保持原值,不执行更新。
return
}
if patch.Remark.Null {
// 显式 null:执行接口约定的清空动作。
*dst = ""
return
}
// 有效值:写入已经由解码器检查过类型的字符串。
*dst = patch.Remark.Value
}
这种类型的关键不在字段名字,而在“缺失时不会调用字段解码方法”这一点:只有键出现时,UnmarshalJSON 才会收到对应字节。若结构体对象本身会被复用,解码前应创建新值或显式重置状态,避免上一次请求留下的 Set 和 Value 影响下一次判断。
按接口场景选择承载方式
| 需求 | 推荐方式 | 注意点 |
|---|---|---|
| 只区分有值与无值 | 指针字段 | 缺失和 null 通常都会落到 nil |
| 动态字段或少量临时判断 | map[string]json.RawMessage | 通过 map 键存在性保留缺失信号 |
| 固定结构需要三态 | 自定义 UnmarshalJSON | 把 Set、Null 和 Value 封装起来 |
| 部分更新要清空或保留 | 三态类型 + 业务更新函数 | 不要在解码层直接写数据库 |
写测试时至少覆盖三份 JSON:{}、{"remark":null}、{"remark":"周末联系"}。再增加一个类型错误样例,确认错误不会被吞成空字符串。测试的重点不是输出一模一样,而是三个输入能触发三个明确的业务分支。
相关问题
为什么普通 string 字段分不出缺失和 null? 因为两者都可能让字段保持空字符串这个零值,类型本身没有保存字段是否出现的信号。
指针字段能直接实现 PATCH 的三态更新吗? 不能。指针可以表达非 nil 值,但缺失与显式 null 仍可能都对应 nil,需要额外的存在标记或自定义类型。
自定义 UnmarshalJSON 是否会影响字段缺失? 字段缺失时该字段的解码方法通常不会被调用,所以可以用 Set 的初始值 false 表示缺失;复用对象时要注意清理旧状态。
-
407 收藏
-
Golang · Go教程 | 3个月前 | golang · JSON · 故障排查 · Go教程 · 接口设计 · JSON Go 接口兼容性 DisallowUnknownFields 严格解码174 收藏
-
Golang · Go教程 | 3个月前 | golang · sse · Go教程 · net/http · 接口设计 · HTTP Go SSE FLUSH 流式响应 ResponseController463 收藏
-
427 收藏
-
Golang · Go教程 | 2个月前 | 标准库 · golang · 性能优化 · 接口设计 · 性能优化 Go字符串 缓冲区复用 encoding.TextAppender MarshalText245 收藏
-
211 收藏
-
368 收藏
-
233 收藏
-
232 收藏
-
412 收藏
-
491 收藏
-
369 收藏
-
185 收藏
-
302 收藏
-
446 收藏
-
413 收藏
-
Golang · Go问答 | 38分钟前 | 连接池 · 性能排查 · database/sql · Go问答 · Go 连接池 MaxOpenConns MaxIdleConns database/sql sql.DB DB.Stats476 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习