接口字段可能缺失也可能显式为 null,模型该怎么设计
结论先说:只要业务需要区分“调用方没有提交这个字段”和“调用方明确要求把字段清空”,就不要只用普通值类型,也不要只用单层指针。更稳妥的做法是在请求传输模型中保存三个维度:字段是否出现、是否为 null、具体值是什么;进入业务层后,再把这三种输入翻译成保持、清空或写入。
Go 标准库参考地址是 https://pkg.go.dev/encoding/json。当前文档把它标为 v1,并提示新项目也可以关注 encoding/json/v2;不过本文讨论的是接口状态建模,核心思路并不依赖具体 JSON 解析器版本。
为什么 *T 仍然不够
假设接口支持局部更新昵称。下面三个请求的含义完全不同:
{}
{"nickname": null}
{"nickname": "Ada"}
第一个请求没有碰昵称,第二个请求主动清空昵称,第三个请求写入新值。如果模型定义成 Nickname string,字段缺失和空字符串都会落到字符串零值;如果定义成 Nickname *string,字段缺失和显式 null 在一个新建的零值结构体中都会得到 nil。单层指针只能表达“有值/无值”,不能稳定表达完整三态。

图1:Go JSON 字段三态模型。字段缺失、显式 null 和具体值分别映射到不同的 Set、Null、Value 组合。
有时可以借助预填充旧值再解码,但这会让解析依赖对象当前状态,批量处理、重试和测试都更难推理。请求 DTO 最好只描述调用方实际发送了什么,不要偷偷混入数据库旧值。
用 Optional[T] 保存三种输入状态
一个通用做法是定义结构体值类型 Optional[T]。这里有一个容易忽略的细节:它应当作为父结构体的值字段使用。如果写成 *Optional[T],外层指针为 nil 时又可能把字段缺失和显式 null 合并。
package api
import (
"bytes"
"encoding/json"
)
// Optional 保存一个 JSON 字段是否出现、是否为 null 以及实际值。
type Optional[T any] struct {
Set bool
Null bool
Value T
}
// UnmarshalJSON 只会在字段确实出现在 JSON 对象中时被调用。
func (o *Optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
// 显式 null 需要单独保留,不能与字段缺失合并。
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
o.Null = true
var zero T
o.Value = zero
return nil
}
// 非 null 输入按目标类型继续解码。
o.Null = false
return json.Unmarshal(data, &o.Value)
}
请求模型直接使用这个值类型:
type UpdateUserRequest struct {
// 昵称允许主动清空。
Nickname Optional[string] `json:"nickname"`
// 年龄不允许清空,但仍需要识别调用方是否传入 null。
Age Optional[int] `json:"age"`
}
解码后的状态表如下:
| JSON 输入 | Set | Null | Value | 业务含义 |
|---|---|---|---|---|
| 字段缺失 | false | false | 零值 | 保持原值 |
| 显式 null | true | true | 零值 | 清空或拒绝 |
| 具体值 | true | false | 解码结果 | 写入新值 |
这里的 Set 由“自定义解码方法是否被调用”得到。字段缺失时,标准库不会为该字段调用 UnmarshalJSON,所以零值自然保留为 Set=false;显式 null 和具体值都会触发方法,因此可以继续区分。
把三态收口到更新流程
传输模型只记录输入事实,不应该自行决定数据库怎么改。是否允许清空、值的范围是否合法、调用方是否有修改权限,都应该集中在业务层门禁中。这样同一个 DTO 可以服务 HTTP、消息队列和批处理入口,而领域规则只有一份。

图2:局部更新规则门禁。传输模型记录输入事实,业务层再决定保持原值、清空字段或写入新值。
package user
import (
"errors"
"strings"
)
type User struct {
Nickname *string
Age int
}
var ErrAgeCannotBeNull = errors.New("年龄不能为 null")
// ApplyUpdate 把请求三态翻译成领域更新动作。
func ApplyUpdate(dst *User, req UpdateUserRequest) error {
if req.Nickname.Set {
if req.Nickname.Null {
// 昵称允许显式清空。
dst.Nickname = nil
} else {
nickname := strings.TrimSpace(req.Nickname.Value)
// 空字符串与 null 的含义由业务规则明确区分。
dst.Nickname = &nickname
}
}
if req.Age.Set {
if req.Age.Null {
// 年龄字段不允许主动清空。
return ErrAgeCannotBeNull
}
if req.Age.Value 150 {
return errors.New("年龄超出允许范围")
}
dst.Age = req.Age.Value
}
return nil
}
这个流程可以总结成三条固定规则:
Set=false:调用方没有提交字段,业务层不做任何修改。Set=true && Null=true:调用方要求清空;允许清空就执行,不允许就返回参数错误。Set=true && Null=false:校验Value,通过后写入,包括0、false和空字符串等合法零值。
校验规则要按字段拆开
三态模型解决的是“调用方发送了什么”,并不会替代业务校验。每个字段至少要回答四个问题:能否缺失、能否为 null、零值是否合法、具体值有哪些约束。
| 字段 | 缺失 | null | 零值 | 建议处理 |
|---|---|---|---|---|
| nickname | 允许 | 允许 | 空串可按产品规则处理 | 缺失保持,null 清空 |
| age | 允许 | 拒绝 | 0 需按业务判断 | null 返回明确错误 |
| enabled | 允许 | 通常拒绝 | false 是有效值 | 不能用真假判断是否提交 |
尤其是布尔值和数字值,不能用 if req.Enabled.Value 或 if req.Count.Value != 0 判断字段是否出现;那会再次把合法零值吞掉。判断是否提交只看 Set。
编码响应时别让缺失重新变成 null
读取请求和生成响应是两个方向。结构体类型配合 omitempty 时,结构体值未必会像预期那样被完全省略。若响应也需要严格区分“省略”和“输出 null”,最直接的方式是显式构造对象,或在父类型上实现 MarshalJSON。
// MarshalPatch 只输出调用方真正提交过的字段。
func MarshalPatch(req UpdateUserRequest) ([]byte, error) {
body := make(map[string]any)
if req.Nickname.Set {
if req.Nickname.Null {
// nil 会编码为 JSON null。
body["nickname"] = nil
} else {
body["nickname"] = req.Nickname.Value
}
}
if req.Age.Set {
if req.Age.Null {
body["age"] = nil
} else {
body["age"] = req.Age.Value
}
}
return json.Marshal(body)
}
对外响应模型通常可以与更新请求模型分开:请求模型强调“是否提交”,响应模型强调“当前值是什么”。只有审计回放、代理转发或补丁重放等场景,才需要把三态完整编码回 JSON。
少量特殊字段也可以用 RawMessage
如果只有一两个字段需要三态,并且不想引入通用泛型类型,可以先解码成 map[string]json.RawMessage。Map 的键是否存在负责判断缺失,原始字节是否为 null 负责判断显式空值。
func readNickname(data []byte) (set bool, null bool, value string, err error) {
var obj map[string]json.RawMessage
if err = json.Unmarshal(data, &obj); err != nil {
return false, false, "", err
}
raw, ok := obj["nickname"]
if !ok {
// 键不存在代表字段缺失。
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
}
RawMessage 的优点是局部、透明,缺点是字段多时重复代码明显,类型约束和错误定位也更分散。字段较多或多个接口复用同一模式时,Optional[T] 更容易形成统一规范。
失败处理与日志怎么记
解析错误、规则错误和存储错误应分层处理。JSON 类型不匹配属于请求格式错误;字段明确传 null 但业务禁止清空,属于规则错误;数据库更新失败则是服务端执行错误。三类错误不要统一包装成一句“更新失败”。
- 记录字段名与状态,例如
set=true、null=true,但不要把密码、令牌等敏感值直接写入日志。 - 返回稳定的错误码,例如
INVALID_JSON、NULL_NOT_ALLOWED、VALUE_OUT_OF_RANGE。 - 事务失败时可以重试整个更新命令,但不要在重试时重新解释字段含义。
- 审计日志同时保存修改前后值和请求动作,避免只看到最终
nil而不知道是主动清空。
测试至少覆盖这组矩阵
三态模型最适合做表驱动测试。除了成功路径,还要覆盖类型错误、非法范围和禁止清空。
func TestOptionalString(t *testing.T) {
tests := []struct {
name string
input string
set bool
null bool
value string
}{
// 字段缺失时 UnmarshalJSON 不会被调用。
{name: "missing", input: `{}`, set: false},
// 显式 null 必须与缺失区分。
{name: "null", input: `{"nickname":null}`, set: true, null: true},
// 空字符串也是调用方明确提交的具体值。
{name: "empty", input: `{"nickname":""}`, set: true, value: ""},
// 普通字符串进入 Value。
{name: "value", input: `{"nickname":"Ada"}`, set: true, value: "Ada"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req UpdateUserRequest
if err := json.Unmarshal([]byte(tt.input), &req); err != nil {
t.Fatalf("解析请求失败:%v", err)
}
if req.Nickname.Set != tt.set || req.Nickname.Null != tt.null || req.Nickname.Value != tt.value {
t.Fatalf("状态不符合预期:%+v", req.Nickname)
}
})
}
}
上线前再补三类集成测试:只更新一个字段时其他字段保持不变;禁止清空的字段返回稳定错误;同一个补丁重试时结果一致。这样模型、规则和存储层的边界会比较清楚。
常见问题
双指针 **T 能不能解决?
理论上可以表达更多状态,但标准 JSON 解码行为、初始化方式和团队可读性都更绕。通用接口中显式的 Set/Null/Value 更容易审查和测试。
Optional[T] 应该放在领域模型里吗?
通常不建议。它表达的是传输协议中的提交状态,适合放在 API DTO。领域模型应保存已经确定的业务值,避免让“字段是否出现在某次请求中”污染长期状态。
PUT 接口也需要三态吗?
如果 PUT 被严格定义为完整替换,字段缺失通常可以直接判为无效,此时三态需求较弱;如果实际实现仍允许局部提交,就应按 PATCH 语义明确建模,不能只依赖接口名字。
换成 encoding/json/v2 后还需要这种模型吗?
仍然需要先定义业务语义。解析器可以改变默认行为或提供更多选项,但“缺失、主动清空、写入具体值”是否不同,最终仍是接口契约问题。先把契约写清楚,再选择实现方式。
最重要的设计原则是:请求模型负责保留事实,业务层负责解释事实。只要把“是否出现”和“是否为 null”分开保存,局部更新、校验、审计和重试都会更可控。
PHP 8.5 迁移 PDO 驱动常量时要改哪些代码
- 上一篇
- PHP 8.5 迁移 PDO 驱动常量时要改哪些代码
- 下一篇
- 为后台任务建立启动、取消、等待三段式生命周期
-
- Golang · Go问答 | 36分钟前 | 并发 · channel · goroutine · go · Context · context 并发限制 工作池 Go channel worker pool Goroutine生命周期
- 任务数很多时应该每任务一个协程还是固定工作池
- 458浏览 收藏
-
- Golang · Go问答 | 59分钟前 | 并发 · goroutine · go · pprof · 故障排查 · goroutine泄漏 并发排查 Goroutine生命周期 Go pprof runtime metrics
- Goroutine 数量持续上涨却没有报错,如何定位泄漏入口
- 458浏览 收藏
-
- Golang · Go问答 | 2小时前 | JSON · go · json.Unmarshal UseNumber json.Number Go JSON处理
- json.Unmarshal 为什么会把大整数变成浮点数,怎样保留精度
- 273浏览 收藏
-
- Golang · Go问答 | 2小时前 | go · 文件上传 · 流式处理 · 内存优化 · net/http · 流式读取 ParseMultipartForm Go HTTP服务 MaxBytesReader Go大文件上传 MultipartReader
- 大文件上传占满内存通常错在哪里,何时应流式读取
- 141浏览 收藏
-
- Golang · Go问答 | 2小时前 |
- 优雅停机时新请求为何还会进入,怎样关闭监听并等待在途请求
- 232浏览 收藏
-
- Golang · Go问答 | 3小时前 |
- HTTP Server 的读超时、写超时和空闲超时分别保护什么
- 128浏览 收藏
-
- Golang · Go问答 | 3小时前 | net/http · Go问答 · 性能边界 · 高并发 连接池 http.Transport Go HTTP客户端 http.DefaultClient
- 默认 Client 能否直接用于高并发服务,连接池边界怎么判断
- 151浏览 收藏
-
- Golang · Go问答 | 4小时前 |
- HTTP客户端明明设置了超时,为什么仍会长时间占用连接
- 378浏览 收藏
-
- Golang · Go问答 | 5小时前 | go · Go 垃圾回收 内存限制 GOMEMLIMIT
- Go GOMEMLIMIT 为什么不是硬性内存上限
- 109浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 360次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 417次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 430次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 381次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 208次使用
-
- 接口返回 200 但前端仍报错怎么办:从响应格式到跨域一步步排查
- 2026-06-14 332浏览
-
- 有关Go语言拼接URL路径的方法
- 2023-03-09 185浏览
-
- golang生成JSON以及解析JSON
- 2023-01-17 329浏览
-
- Go如何实现json字符串与各类struct相互转换
- 2023-01-07 377浏览
-
- go语言能不能做后端
- 2023-03-03 460浏览

