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

json/v2 解码 null 到指针字段的兼容处理

来源:17golang原创

时间:2026-10-10 10:53:32 235浏览 收藏

接口迁移到 encoding/json/v2 时,最容易漏掉的不是字段名,而是 null 的业务含义。官方规则是:JSON null 解码到 Go 指针会把指针置为 nil;字段完全缺失时,解码器不会调用该字段的解码逻辑。若请求 DTO 只写成 *T,新对象上的“缺失”和“显式 null”最终都可能表现为 nil。

官方地址:https://pkg.go.dev/encoding/json/v2

要点速览
  • *T 适合表达“有值或没有值”,不适合独立承载三态 PATCH 语义。
  • 先决定 null 是清空、忽略,还是必须区别于缺失,再选择 DTO 形状。
  • 需要三态时,用 Present、Null、Value 包装,并把它转换成业务命令。

先区分指针字段的三种输入

把字段初始化为旧值,再分别解码三种请求,能看清兼容边界:缺失字段会保留原值,显式 null 会清成 nil,字符串则会分配或覆盖指针指向的值。可是新建一个零值 DTO 时,缺失与 null 都可能得到 nil,这正是旧接口迁移后出现“没有更新却被清空”或“无法清空”的根源。

输入*string 结果适合表达
字段缺失通常保持已有值未提交该字段
nullnil显式清空
\"Ada\"指向字符串设置新值
json v2 指针字段对缺失 null 和具体值的状态说明图
图1:json/v2 指针字段状态说明图,展示 null 与缺失的兼容边界。
package main

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

type Patch struct {
	Nickname *string `json:"nickname"`
}

func main() {
	old := "旧昵称"
	for label, input := range map[string][]byte{
		"缺失": []byte(`{}`),
		"null": []byte(`{"nickname":null}`),
		"新值": []byte(`{"nickname":"Ada"}`),
	} {
		p := Patch{Nickname: &old}
		if err := json.Unmarshal(input, &p); err != nil {
			panic(err) // 示例中直接终止,生产代码应返回带请求上下文的错误
		}
		fmt.Printf("%s: %#v\\n", label, p.Nickname)
	}
}

确定旧代码要保留的契约

迁移前先写一张策略表,不要用 omitempty 或多套指针层级猜业务意图。表单更新通常有三种契约:字段缺失代表“不改”,null 代表“清空”,具体值代表“替换”;另一类旧接口会把 null 当成“忽略”,这时就必须在 DTO 到命令的转换层显式保留旧规则。

  • 允许清空:普通 *T 可以接收 null,但要让业务层知道这是一次删除动作。
  • 忽略 null:解码后不要直接覆盖领域对象,先把 nil 解释为“无操作”。
  • 区分缺失:不要把零值 DTO 直接交给更新逻辑,改用存在性感知类型或同时记录原始字段集合。

兼容的关键是把“解析成功”与“业务动作”分开:解码器只负责把输入变成稳定状态,清空、保留或更新由命令层决定。

用存在性感知类型承接 null

需要三态语义的字段可以用一个小型泛型类型记录字段是否出现、是否为 null 以及实际值。缺失字段不会触发 UnmarshalJSON,因此 Present 能天然区分缺失;出现 null 时再把 Null 置为 true。

package patch

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

type Field[T any] struct {
	Present bool // 字段是否出现在请求 JSON 中
	Null    bool // 字段是否明确要求写入 null
	Value   T    // 非 null 时的业务值
}

func (f *Field[T]) UnmarshalJSON(data []byte) error {
	f.Present = true
	trimmed := bytes.TrimSpace(data)
	if bytes.Equal(trimmed, []byte("null")) {
		f.Null = true
		var zero T
		f.Value = zero // 清除复用对象中的旧值,避免状态串线
		return nil
	}
	f.Null = false
	return json.Unmarshal(trimmed, &f.Value) // 类型不匹配时把错误交给调用方
}

type UpdateProfile struct {
	Nickname Field[string] `json:"nickname"`
}

func toCommand(in UpdateProfile) string {
	if !in.Nickname.Present {
		return "忽略"
	}
	if in.Nickname.Null {
		return "清空"
	}
	return "更新为: " + in.Nickname.Value
}

这个包装只应放在确实有三态需求的字段上。普通响应对象若只需要“可有可无”,继续使用 *T 更直观;如果整个业务都依赖三态,可再统一封装请求命令,避免把 Present 泄漏到领域模型。

请求 JSON 经存在性感知字段进入业务命令的结构说明图
图2:兼容处理结构说明图,展示存在性感知字段到业务决策的边界。

用迁移测试锁住边界

最后把旧样本和业务动作写成表驱动测试,至少覆盖缺失、null、合法值和非法类型。测试不要只断言指针是否为 nil,还要断言转换后的命令:缺失应为忽略,null 应为清空,合法字符串应为更新,数字等错误类型应被拒绝。

func TestUpdateProfileStates(t *testing.T) {
	cases := []struct {
		name, input, want string
	}{
		{"缺失", `{}`, "忽略"},
		{"清空", `{"nickname":null}`, "清空"},
		{"更新", `{"nickname":"Ada"}`, "更新为: Ada"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var req UpdateProfile
			if err := json.Unmarshal([]byte(tc.input), &req); err != nil {
				t.Fatalf("解码失败: %v", err) // 失败时保留输入场景,便于定位迁移差异
			}
			if got := toCommand(req); got != tc.want {
				t.Fatalf("动作=%q,期望=%q", got, tc.want)
			}
		})
	}
}

上线前再检查两件事:是否有复用同一个请求结构体的池化代码,以及响应端的 omitempty 是否仍符合旧客户端协议。json/v2 对空值和零值的定义存在差异,输入兼容通过并不代表输出 JSON 可以直接替换。

相关问题

只想判断字段是否传入,必须自定义类型吗?

不一定。可以先解码到字段集合记录存在性,再解码到普通结构体;字段少且需要三态的 PATCH 请求,使用 Field[T] 更容易让业务转换保持单一入口。

json/v2 会自动把旧指针字段变成三态吗?

不会。它会按规则处理 null 和具体值,但缺失字段仍然没有“出现”标记。三态是业务协议,需要由 DTO、原始字段集合或自定义解码类型主动承载。

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