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

Go JSON null、缺失字段和零值怎样区分

来源:17golang原创

时间:2026-10-31 19:11:51 138浏览 收藏

在 Go 里,JSON 的“字段缺失”、显式 null 和数字 0 不是同一件事;但如果直接解码到普通的 int 字段,三者很容易都表现成 0。解决办法取决于接口是否需要保留字段存在性:只关心 null 与非 null 时用指针,需要判断键是否出现时用 json.RawMessage,需要完整保留缺失、null、有效零值时用带状态的自定义类型。

核心区分规则
  • 新建的普通值结构体中,缺失字段保持零值;null 解码到普通值类型通常不改变原值;JSON 数字 0 则明确写入零值。
  • *int 可以把有效的 0 与 null 区分开,但缺失和 null 在全新结构体里都会是 nil。
  • 要表达三态,必须额外记录“键是否出现”,不能只看字段的最终值。

默认解码为什么会丢掉状态

先看最小结构体。下面的代码分别解码三个 JSON 对象,并打印普通 int 字段:

package main

import (
    "encoding/json"
    "fmt"
)

type Request struct {
    Count int `json:"count"`
}

func main() {
    inputs := []string{`{}`, `{"count":null}`, `{"count":0}`}
    for _, input := range inputs {
        var req Request
        // 每次都新建结构体,避免上一次请求留下字段状态。
        if err := json.Unmarshal([]byte(input), &req); err != nil {
            // 输入不是合法 JSON 时立即报告错误。
            panic(err)
        }
        fmt.Printf("%-18s => Count=%d\n", input, req.Count)
    }
}

在“全新结构体”的前提下,三次输出都可能是 Count=0。缺失键没有可写入的目标,字段保持 Go 零值;null 对普通数值字段没有效果;数字 0 才是一次明确的赋值。最终值相同,并不代表输入语义相同。

JSON 缺失、null 和 0 进入 Go int 字段后汇聚为零值的静态结构说明图
图1:静态结构说明图,展示 JSON 缺失字段、null 与 0 在普通 Go 值字段上的信息汇聚,不是运行截图。

先用 *int 区分有效零值和 null

如果业务只需要知道“客户端是否明确传入了一个数”,可以把字段改成指针。非 null 的 JSON 数字会分配目标值,即使数字是 0;显式 null 会把指针设为 nil。

type PointerRequest struct {
    Count *int `json:"count"`
}

func decodePointer(input string) (string, error) {
    var req PointerRequest
    // 全新结构体下,缺失键和 null 都从 nil 开始。
    if err := json.Unmarshal([]byte(input), &req); err != nil {
        return "", err
    }
    if req.Count == nil {
        // nil 可能表示缺失,也可能表示显式 null,当前类型无法再细分。
        return "nil(缺失或 null)", nil
    }
    // 指针非 nil 时,0 仍然是一个明确传入的有效值。
    return fmt.Sprintf("value=%d", *req.Count), nil
}

指针的优点是能表达“没有数值”和“数值为 0”。它的边界也很重要:在全新结构体里,字段缺失不会调用任何解码动作,显式 null 又会把指针设为 nil,所以二者仍然无法仅靠 *int 区分。

用 json.RawMessage 先判断键是否存在

要区分缺失与显式 null,可以先把对象解码成 map[string]json.RawMessage。map 的第二个返回值记录键是否出现,而 RawMessage 保留该字段的原始 JSON。

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
)

func readCount(input string) (string, error) {
    var fields map[string]json.RawMessage
    // 先保留字段级原文,不急着把 null 转成 Go 零值。
    if err := json.Unmarshal([]byte(input), &fields); err != nil {
        return "", err
    }

    raw, exists := fields["count"]
    if !exists {
        // map 的第二个返回值表示 JSON 键根本没有出现。
        return "missing", nil
    }
    if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
        // 键存在但值为 null,这与 missing 是两种输入。
        return "null", nil
    }

    var value int
    // 只有确认不是 null 后才按业务类型解码具体数值。
    if err := json.Unmarshal(raw, &value); err != nil {
        return "", fmt.Errorf("count 类型错误: %w", err)
    }
    return fmt.Sprintf("value=%d", value), nil
}

这种方式适合少量可选字段或需要根据原文决定后续类型的接口。缺点是调用方需要自己维护字段解析逻辑;如果每个请求都有很多三态字段,继续堆叠 map 判断会让业务代码变得重复。

用包装类型完整保留缺失、null 和有效零值

更稳定的接口模型是把三种状态直接放到一个类型里:Set 表示键出现过,Valid 表示出现的值不是 null,Value 才保存具体数字。注意,缺失字段不会调用字段类型的 UnmarshalJSON;因此新建结构体时 Set=false 就自然代表缺失。

type OptionalInt struct {
    Value int
    Set   bool
    Valid bool
}

func (o *OptionalInt) UnmarshalJSON(data []byte) error {
    o.Set = true
    data = bytes.TrimSpace(data)
    if bytes.Equal(data, []byte("null")) {
        // null 是已出现但没有有效数值,不把它误判成缺失。
        o.Value = 0
        o.Valid = false
        return nil
    }

    var value int
    // 具体类型仍交给 encoding/json,保留其类型错误语义。
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }
    o.Value = value
    o.Valid = true
    return nil
}

type StrictRequest struct {
    Count OptionalInt `json:"count"`
}

使用这个类型时可以得到明确的三态结果:

JSON 输入SetValidValue语义
{}falsefalse0缺失
{"count":null}truefalse0显式 null
{"count":0}truetrue0有效零值
通过键存在性与 OptionalInt 的 Set、Valid、Value 恢复 JSON 三态语义的静态流程说明图
图2:静态流程说明图,展示键存在性、null 判断与有效零值的分流关系,不是运行截图。

复用结构体、默认值和 omitempty 的边界

如果一个结构体会被重复解码,必须特别小心“缺失字段不会覆盖旧值”。例如第一次请求把 Count 设为有效值,第二次请求缺失 count,普通解码并不会自动把它清零;自定义包装类型的 Set 和 Valid 也不会因为字段缺失而自动复位。最简单的做法是每次请求使用新结构体,或在复用前显式重置整个对象。

omitempty 只影响 Go 值编码为 JSON 时是否省略字段,不是反序列化阶段的存在性检测器。需要向外输出三态时,通常还应明确约定:缺失是否省略、null 是否保留、有效零值是否必须输出,而不是把这些决定交给默认标签行为。

按需求选择实现方式

需求建议类型能区分什么
只要普通默认值int、string 等值类型不保留输入存在性
区分有效 0 与 null*int、*string非 null 与 null;缺失和 null 仍可能相同
少量字段需要检查原文map[string]json.RawMessage键是否出现、是否为 null、原始值
大量接口字段需要稳定三态自定义包装类型缺失、null、有效零值全部保留

判断顺序可以固定成一句话:先问接口是否关心“键有没有出现”,再问是否要区分 null,最后才选择普通值、指针、RawMessage 或包装类型。这样就不会把“最后读到的 Go 零值”误当成完整的输入语义。

常见问题

普通 int 字段能判断客户端有没有传 0 吗?

不能。缺失字段和显式 0 都可能得到 0;需要把字段改成指针或保存原始字段。

把 int 改成 *int 就能区分缺失和 null 吗?

不能保证。全新结构体中两者都可能是 nil;要区分它们,需要键存在性记录或三态包装类型。

为什么自定义 UnmarshalJSON 没有处理缺失字段?

因为缺失字段没有进入解码流程,字段类型的方法不会被调用。把 Set 初始为 false,正好可以利用这一点表示缺失。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>