当前位置:首页 > 文章列表 > Golang > Go教程 > 为可选字段设计自定义类型,区分缺失值与零值

为可选字段设计自定义类型,区分缺失值与零值

来源:17golang原创 2026-10-07 04:46:22 0浏览 收藏

做 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 代表清空,而缺失代表不修改,就需要额外记录字段是否出现。

Go JSON 可选字段三态模型

图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”,应由父结构体的编码逻辑决定,而不是让单个字段凭空省略自己。

UnmarshalJSON 与 MarshalJSON 职责边界

图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

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Streams 消费者组积压怎么处理:认领、重试与裁剪Streams 消费者组积压怎么处理:认领、重试与裁剪
上一篇
Streams 消费者组积压怎么处理:认领、重试与裁剪
VS Code Remote SSH 连接后配置远端专属设置
下一篇
VS Code Remote SSH 连接后配置远端专属设置
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    360次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    417次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    429次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    381次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    208次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码