为可选字段设计自定义类型,区分缺失值与零值
做 Go 接口时,我最早也习惯把请求体直接绑定到业务结构体:年龄用 int,昵称用 string,可选字段就改成指针。这个办法处理新增接口通常够用,但一到 PATCH 局部更新,信息就开始丢失。客户端没发送 age、发送 "age": 0,用普通 int 解码后都是 0;改成 *int 后,字段缺失和显式 null 又都会得到 nil。
真正的问题不是 JSON 解码失败,而是请求语义比 Go 字段的零值多一层。局部更新至少需要表达三种状态:没有提供字段,因此保留原值;提供了 null,因此执行清空;提供了具体值,即使这个值恰好是 0,也要明确写入。本文用一个泛型 Optional[T] 把这三种状态保存下来,并讨论它在解码、业务校验、出站编码和结构体复用中的边界。
为什么普通零值不够
encoding/json 按字段名匹配 JSON 对象。当某个 key 不存在时,对应 Go 字段不会被赋值;如果目标是刚创建的结构体,看起来就是保留该类型的零值。与此同时,客户端显式发送数字 0 或空字符串,也会合法地得到相同零值。只观察字段内容,已经无法还原客户端意图。
指针能多保存一位信息,但仍不是完整答案。*int 可以区分“非空具体值”和“没有具体值”,却无法在新结构体上区分字段缺失与 JSON null。如果业务把两者都当作“不设置”,指针很合适;如果 null 代表清空,而缺失代表不修改,就需要额外记录字段是否出现。

图1:可选字段三态模型。缺失表示不修改,null 表示显式清空,0 表示明确写入零值。
我更倾向把这个状态命名为 Set,把“值是否非 null”命名为 Valid,再用 Value 保存具体值。于是三个核心不变量很清楚:
Set=false:JSON 中没有该字段,业务层不应修改原值。Set=true, Valid=false:字段存在且为null,业务层执行清空或拒绝。Set=true, Valid=true:字段存在且有具体值,Value即使为零值也有效。
把解码职责收进 Optional[T]
关键点在于:对象中出现某个字段时,encoding/json 才会调用该字段类型的 UnmarshalJSON。因此方法一旦被调用,就能先把 Set 设为 true;如果输入是 null,记录 Valid=false;否则解码临时值并记录为有效。
package optional
import (
"bytes" // 用于识别 JSON null
"encoding/json" // 复用标准库的类型解码
"fmt" // 包装字段解码错误
)
// Optional 同时保存字段是否出现、是否非 null,以及实际值。
type Optional[T any] struct {
Value T
Set bool
Valid bool
}
// UnmarshalJSON 只会在对象中出现该字段时被调用。
func (o *Optional[T]) UnmarshalJSON(data []byte) error {
o.Set = true
if bytes.Equal(data, []byte("null")) {
var zero T
o.Value = zero
o.Valid = false
return nil
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
return fmt.Errorf("decode optional value: %w", err)
}
o.Value = value
o.Valid = true
return nil
}
// MarshalJSON 负责把有效值编码为 JSON,把无效值编码为 null。
func (o Optional[T]) MarshalJSON() ([]byte, error) {
if !o.Valid {
return []byte("null"), nil
}
return json.Marshal(o.Value)
}
这里没有把 Set 用进 MarshalJSON,是刻意划分职责:Set 回答“输入里有没有这个字段”,Valid 回答“当前是否有非 null 值”。对于出站 JSON,如果还要区分“完全省略”与“输出 null”,应由父结构体的编码逻辑决定,而不是让单个字段凭空省略自己。

图2:Optional[T] 的解码与编码职责边界。解码记录字段存在性,编码根据有效性输出 null 或具体值。
在 PATCH 入口只做一次状态翻译
可选类型的价值,不是让业务代码到处出现三层判断,而是把协议层的三态集中翻译成领域动作。下面以用户年龄和昵称为例:年龄不允许 null,昵称允许 null,并把它解释为清空。
package profile
import (
"errors" // 返回业务校验错误
"example.com/project/optional" // 项目内的可选字段类型
)
// PatchUser 描述局部更新请求,而不是数据库实体。
type PatchUser struct {
Age optional.Optional[int] `json:"age"`
Nickname optional.Optional[string] `json:"nickname"`
}
// User 是已经存在的领域对象。
type User struct {
Age int
Nickname string
}
// Apply 把传输层三态翻译为领域对象的确定修改。
func (p PatchUser) Apply(user *User) error {
if p.Age.Set {
if !p.Age.Valid {
return errors.New("age 不允许为 null")
}
if p.Age.Value
这段代码有两个好处。第一,age=0 会正常通过校验并写入,不会被“非零才更新”之类的判断误伤。第二,是否允许 null 是业务规则,不是 JSON 类型替业务做的决定。同一个 Optional[string],在昵称里可以把 null 解释为清空,在另一个必填字段里也可以直接拒绝。
用表驱动测试锁住三态契约
这类类型代码不长,但非常适合先写契约测试。重点不是只验证成功样例,而是把字段缺失、null、零值和错误类型放在同一张表中。以后调整 DTO 或升级依赖时,只要这组测试仍然通过,PATCH 的核心语义就没有漂移。
package optional_test
import (
"encoding/json" // 执行标准 JSON 解码
"testing" // 提供表驱动测试
"example.com/project/optional" // 被测试的可选字段类型
)
// TestOptionalInt 覆盖缺失、null、零值与类型错误。
func TestOptionalInt(t *testing.T) {
type request struct {
Age optional.Optional[int] `json:"age"`
}
tests := []struct {
name string
body string
wantSet bool
wantValid bool
wantValue int
wantErr bool
}{
{name: "missing", body: `{}`, wantSet: false},
{name: "null", body: `{"age":null}`, wantSet: true, wantValid: false},
{name: "zero", body: `{"age":0}`, wantSet: true, wantValid: true, wantValue: 0},
{name: "value", body: `{"age":18}`, wantSet: true, wantValid: true, wantValue: 18},
{name: "wrong type", body: `{"age":"18"}`, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var got request
err := json.Unmarshal([]byte(test.body), &got)
if (err != nil) != test.wantErr {
t.Fatalf("Unmarshal() error = %v, wantErr %v", err, test.wantErr)
}
if test.wantErr {
return
}
if got.Age.Set != test.wantSet ||
got.Age.Valid != test.wantValid ||
got.Age.Value != test.wantValue {
t.Fatalf("Age = %+v", got.Age)
}
})
}
}
风险主要来自复用与出站编码
第一个风险是复用同一个请求结构体。缺失字段不会触发 UnmarshalJSON,所以如果上一次解码已经把 Set 设为 true,下一次在同一个对象上解码一个缺少该字段的 JSON,旧状态会继续保留。HTTP handler 通常每次创建新的请求值;如果在对象池、批处理循环或长生命周期消费者中复用,就要在每次解码前整体清零。
第二个风险是误把 omitempty 当作存在性检测。它只影响编码阶段,并不会告诉解码器字段是否出现。更需要注意的是,自定义结构体字段实现 MarshalJSON 后,单靠传统 omitempty 未必能得到“未设置就完全省略”的效果。若响应或持久化格式必须保留省略语义,可以为父结构体实现 MarshalJSON,或先构造 map[string]any,仅在 Set=true 时加入字段。
第三个风险是把一个类型扩展成万能容器。Optional[T] 适合协议边界,不建议直接替换数据库模型里的所有字段。领域对象最好仍保持明确类型;输入三态在 DTO 的 Apply 方法中消化掉,避免 Set 状态渗透到查询、计算和展示层。
什么时候值得采用
如果接口只做完整替换,或者缺失与 null 本来就等价,指针通常更简单。只有当“字段是否提供”会改变业务动作时,自定义可选类型才真正有收益。常见场景包括 PATCH、分层配置覆盖、事件演进、命令行参数与配置文件合并,以及需要审计客户端明确意图的接口。
采用后可以观察几项指标:因零值判断造成的误更新是否减少;请求 DTO 中的裸指针和 map[string]json.RawMessage 是否减少;新增字段时是否能直接复用同一套三态测试;业务日志能否明确区分“未提供、清空、赋值”。如果这些指标没有改善,就说明场景可能只需要普通指针,不必为了抽象而抽象。
我的经验是,Optional 的核心不是泛型技巧,而是把协议中的信息完整保存到业务决策发生之前。只要坚持 Set 管存在性、Valid 管 null、Value 管具体值,并在 DTO 边界及时翻译,缺失值与零值就不再依赖约定或猜测。
官方文档:https://pkg.go.dev/encoding/json
Streams 消费者组积压怎么处理:认领、重试与裁剪
- 上一篇
- Streams 消费者组积压怎么处理:认领、重试与裁剪
- 下一篇
- VS Code Remote SSH 连接后配置远端专属设置
-
- Golang · Go教程 | 47分钟前 | 标准库 · JSON · go · JSON Go encoding/json time.Time RawMessage UseNumber
- 统一处理未知字段、数字精度和时间格式
- 297浏览 收藏
-
- Golang · Go教程 | 1小时前 | JSON · 流式处理 · Go教程 · 内存优化 · 内存优化 encoding/json 流式解析 json.Decoder Go JSON处理 超大JSON数组
- 用 Decoder 流式解析超大 JSON 数组并控制内存峰值
- 449浏览 收藏
-
- Golang · Go教程 | 2小时前 |
- 为上传接口设置请求体上限并正确清理临时文件
- 331浏览 收藏
-
- Golang · Go教程 | 2小时前 | Go教程 · 可观测性 · net/http · HTTP客户端 · 请求头注入 http.Client Go RoundTripper HTTP耗时 Transport中间件
- 用自定义 RoundTripper 注入请求头与耗时记录
- 500浏览 收藏
-
- Golang · Go教程 | 3小时前 |
- 封装可重试的 JSON API 客户端并限制重试边界
- 345浏览 收藏
-
- Golang · Go教程 | 4小时前 | 标准库 · Go教程 · Go text/template block Template.Clone
- Go template.Clone 怎么复用基础模板并覆盖局部块
- 148浏览 收藏
-
- Golang · Go教程 | 4小时前 | 标准库 · 错误处理 · Go text/template FuncMap execute ExecError
- Go template.FuncMap 怎么返回可中断渲染的错误
- 151浏览 收藏
-
- Golang · Go教程 | 5小时前 | 标准库 · 模板 · 迭代器 · Go教程 · text/template iter.Seq2 Go template range-over-func
- Go template 里怎么遍历 iter.Seq2 数据
- 416浏览 收藏
-
- Golang · Go教程 | 5小时前 | golang · 数据库 · 连接池 · Go 连接池 database/sql sql.DB SetConnMaxIdleTime
- Go sql.DB.SetConnMaxIdleTime 怎么淘汰长期空闲连接
- 271浏览 收藏
-
- 前端进阶之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工具。
- 429次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 381次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 208次使用
-
- 接口返回 200 但前端仍报错怎么办:从响应格式到跨域一步步排查
- 2026-06-14 332浏览
-
- Go map 并发写 panic 怎么办:从共享 map 到可控写入路径
- 2026-06-30 123浏览
-
- golang生成JSON以及解析JSON
- 2023-01-17 329浏览
-
- Go如何实现json字符串与各类struct相互转换
- 2023-01-07 377浏览
-
- Go中使用gjson来操作JSON数据的实现
- 2023-01-07 141浏览

