登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

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

来源:17golang原创

时间:2026-10-07 05:22:54 354浏览 收藏

结论先说:只要业务需要区分“调用方没有提交这个字段”和“调用方明确要求把字段清空”,就不要只用普通值类型,也不要只用单层指针。更稳妥的做法是在请求传输模型中保存三个维度:字段是否出现、是否为 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>