当前位置:首页 > 文章列表 > Golang > Go问答 > 接口字段可能缺失也可能显式为 null,模型该怎么设计

接口字段可能缺失也可能显式为 null,模型该怎么设计

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

结论先说:只要业务需要区分“调用方没有提交这个字段”和“调用方明确要求把字段清空”,就不要只用普通值类型,也不要只用单层指针。更稳妥的做法是在请求传输模型中保存三个维度:字段是否出现、是否为 null、具体值是什么;进入业务层后,再把这三种输入翻译成保持、清空或写入。

Go 标准库参考地址是 https://pkg.go.dev/encoding/json。当前文档把它标为 v1,并提示新项目也可以关注 encoding/json/v2;不过本文讨论的是接口状态建模,核心思路并不依赖具体 JSON 解析器版本。

为什么 *T 仍然不够

假设接口支持局部更新昵称。下面三个请求的含义完全不同:

{}
{"nickname": null}
{"nickname": "Ada"}

第一个请求没有碰昵称,第二个请求主动清空昵称,第三个请求写入新值。如果模型定义成 Nickname string,字段缺失和空字符串都会落到字符串零值;如果定义成 Nickname *string,字段缺失和显式 null 在一个新建的零值结构体中都会得到 nil。单层指针只能表达“有值/无值”,不能稳定表达完整三态。

Go JSON 字段缺失、显式 null 与具体值的三态关系图

图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 输入SetNullValue业务含义
字段缺失falsefalse零值保持原值
显式 nulltruetrue零值清空或拒绝
具体值truefalse解码结果写入新值

这里的 Set 由“自定义解码方法是否被调用”得到。字段缺失时,标准库不会为该字段调用 UnmarshalJSON,所以零值自然保留为 Set=false;显式 null 和具体值都会触发方法,因此可以继续区分。

把三态收口到更新流程

传输模型只记录输入事实,不应该自行决定数据库怎么改。是否允许清空、值的范围是否合法、调用方是否有修改权限,都应该集中在业务层门禁中。这样同一个 DTO 可以服务 HTTP、消息队列和批处理入口,而领域规则只有一份。

Go 局部更新请求经过规则门禁进入领域更新的关系图

图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”分开保存,局部更新、校验、审计和重试都会更可控。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
PHP 8.5 迁移 PDO 驱动常量时要改哪些代码PHP 8.5 迁移 PDO 驱动常量时要改哪些代码
上一篇
PHP 8.5 迁移 PDO 驱动常量时要改哪些代码
为后台任务建立启动、取消、等待三段式生命周期
下一篇
为后台任务建立启动、取消、等待三段式生命周期
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之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工具。
    430次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    381次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    208次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码