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

encoding/json/v2 如何按字段覆盖默认序列化选项

来源:17golang原创

时间:2026-10-08 23:40:51 418浏览 收藏

我把一个 API DTO 迁到 encoding/json/v2 时,最先遇到的并不是调用方式,而是“全局策略太粗”:大多数数字应该保持 JSON number,唯独 64 位业务 ID 要输出为字符串;大多数字段按严格名称匹配,但一个兼容字段需要接受下划线写法。JSON v2 的解决方式不是为每个调用点堆更多判断,而是把稳定的字段协议写进 json 标签。

结论是:调用级 Options 负责一次 Marshal 或 Unmarshal 的共同策略,结构体字段标签负责单个字段的名称、字符串化、省略和匹配规则;两者语义重叠时,字段显式规则优先。不过字段标签不是任意 Options 的缩小版,只有文档列出的 omitzero、omitempty、string、case 和 embed 能在字段层声明。

包文档:https://pkg.go.dev/encoding/json/v2

迁移指南:https://go.dev/doc/jsonv2-migration

字段级规则速查
标签选项作用方向判断依据
string编码与解码只影响字段顶层、原本编码为 JSON number 的类型
omitempty编码字段编码结果是否为 null、空字符串、空对象或空数组
omitzero编码Go 零值,或类型的 IsZero() bool
case:strict解码只接受精确 JSON 名称,可收窄全局宽松匹配
case:ignore解码忽略大小写、短横线和下划线差异

项目目标:一份 DTO 同时处理四种字段协议

这个小项目模拟一个接口响应和一个更新请求。响应里的 requestId 需要以字符串传给可能丢失 64 位整数精度的客户端;空昵称、空标签和重试次数零都不应出现在输出里。请求侧则允许显示名使用多种命名风格,但用户 ID 必须精确写成 userId。

这几个要求适合放在字段定义上,因为它们属于协议,而不是某一次调用的临时偏好。相反,是否要求 map 输出稳定顺序、是否拒绝所有未知成员,属于一次编解码或某个入口的整体策略,应继续留在调用级 Options。

环境准备:建立 Go 1.27 小项目

Go 官方迁移指南将 encoding/json/v2 作为 Go 1.27 引入的新版包。新项目可以直接导入它;已有项目不必一次性迁移,v1 仍受 Go 1 兼容承诺保护。

# 创建独立目录,避免演示代码影响现有模块。
mkdir jsonv2-field-demo
cd jsonv2-field-demo

# 初始化最小 Go 模块,模块名只用于本地实验。
go mod init example.com/jsonv2-field-demo

如果当前工具链早于 Go 1.27,应先升级再运行本文代码。不要把早期实验阶段的 GOEXPERIMENT=jsonv2 当成 Go 1.27 项目的固定启动参数;是否需要实验开关应以所用 Go 版本的官方文档为准。

核心代码:把稳定规则写进字段标签

先完成编码侧 DTO。这里有一个很容易混淆的变化:JSON v2 的 omitempty 按“编码后的 JSON 是否为空”判断,而 omitzero 按 Go 零值判断。数值 0 编码后仍是 JSON number,不属于 JSON 空值,所以数字零应该使用 omitzero。

package main

import (
	"fmt"
	"log"

	json "encoding/json/v2"
)

type APIResponse struct {
	// 大整数只在这个字段上编码为 JSON 字符串,避免前端数值精度损失。
	RequestID uint64 `json:"requestId,string"`
	// 空字符串编码为空 JSON 字符串,因此可由 omitempty 省略。
	Alias string `json:"alias,omitempty"`
	// nil 或空切片在 v2 中编码为空数组,omitempty 都会将其省略。
	Labels []string `json:"labels,omitempty"`
	// 数字 0 不是空 JSON 值,应使用 omitzero 按 Go 零值省略。
	Retry int `json:"retry,omitzero"`
}

type UpdateRequest struct {
	// 即使调用点允许宽松匹配,敏感标识仍要求精确名称 userId。
	UserID string `json:"userId,case:strict"`
	// 显示名允许 displayName、display_name 等常见写法。
	DisplayName string `json:"displayName,case:ignore"`
}

func main() {
	resp := APIResponse{
		RequestID: 9_007_199_254_740_993,
		Labels:    []string{},
	}

	// Deterministic 是本次编码的整体选项;字段标签仍控制各成员表示。
	out, err := json.Marshal(resp, json.Deterministic(true))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(out))

	input := []byte(`{"USERID":"u-9","display_name":"Ada"}`)
	var req UpdateRequest
	// 全局打开宽松匹配,字段上的 case:strict 仍对 userId 优先生效。
	if err := json.Unmarshal(
		input,
		&req,
		json.MatchCaseInsensitiveNames(true),
	); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("UserID=%q DisplayName=%q\n", req.UserID, req.DisplayName)
}

这段代码的编码结果应为 {"requestId":"9007199254740993"}:requestId 是带引号的 JSON 字符串,另外三个零值字段被省略。解码后 UserID 仍为空,而 DisplayName 为 Ada,说明字段级 case:strict 收窄了全局宽松匹配,case:ignore 则明确允许了下划线变体。

APIResponse 字段、string、omitempty、omitzero 标签与 json Marshal 调用选项的静态关系图
图1:字段标签固定单个成员的表示规则,调用选项配置本次 Marshal 的整体行为;这是静态说明图,不是运行截图。

为什么 string 只放在大整数那个字段

json:",string" 会设置该字段的 StringifyNumbers 行为。官方文档强调,它只作用于字段值的顶层,而且字段类型必须本来就编码为 JSON number。把它加到切片、数组、map 或结构体上会报错,不会递归地把其中所有数字改成字符串。

这正是字段级覆盖比全局 json.StringifyNumbers(true) 更适合 DTO 的地方:业务 ID 可以按字符串传输,金额、计数和比例仍保留 number。对我来说,这也让协议审查更直观——看到字段定义就知道线上的 JSON 类型,而不必追踪每个调用点是否传了某个 Option。

反序列化时同一标签也生效:标记了 string 的数字字段要求输入是包含 JSON 数字文本的字符串,字符串内部不能带多余空白。它不是“随便把字符串转数字”的弱类型开关。

omitempty 与 omitzero 应该怎么选

这两个标签都能省略字段,但判断系统不同。omitzero 优先询问字段类型是否有 IsZero() bool,没有时再比较 Go 零值;omitempty 则看最终 JSON 表示是不是 null、""、{} 或 []。

字段值omitzeroomitempty
int(0)省略不省略,编码为 0
[]string(nil)省略省略,v2 默认编码为空数组
[]string{}不省略省略
""省略省略
带 IsZero 的类型按方法结果按 JSON 表示

如果两种语义都符合需求,官方文档倾向优先使用 omitzero,因为它与 Go 类型的零值定义一致。若业务明确要求“空切片和 nil 切片都不输出”,则 omitempty 更准确。两个标签也可以同时出现,满足任意一个条件就省略。

用 case:strict 覆盖调用点的宽松匹配

JSON v2 默认按大小写精确匹配字段名。迁移旧服务时,为兼容历史输入,调用点可能暂时传入 json.MatchCaseInsensitiveNames(true)。如果所有字段都跟着变宽松,鉴权标识、路由键或外部协议字段可能接受原本不希望接受的变体。

case:strict 的价值就在这里:它明确要求某个字段继续精确匹配,而且优先于调用级的宽松选项。相反,case:ignore 会在没有精确匹配时忽略大小写、短横线和下划线差异。若多个字段都可能匹配,v2 会优先精确名称;没有精确项且结果仍有歧义时会报告错误,而不是随意选择。

JSON 输入成员、Unmarshal 宽松匹配选项与字段级 case strict 和 case ignore 规则的静态关系图
图2:调用点可以为迁移开启宽松匹配,敏感字段仍能用 case:strict 收窄名称边界;这是静态结构图,不是运行证据。

运行与检查:不要只看一条成功输出

保存为 main.go 后运行:

# 编译并运行当前模块,观察编码结果和字段匹配结果。
go run .

# 运行格式化,确保提交前代码符合 Go 标准格式。
gofmt -w main.go

我会把验收拆成四组,而不是只保留一个“能跑”的示例:

  1. 数字表示:确认 requestId 带引号,普通数字字段仍是 JSON number。
  2. 字段存在性:分别测试 nil 切片、空切片、非空切片和数字零值。
  3. 名称匹配:为 userId 测试精确名、全大写、下划线和短横线变体。
  4. 错误路径:把 string 错加到复合类型,确认程序收到语义错误而不是静默忽略。

如果字段规则属于公开协议,最好把预期 JSON 文本写进表驱动测试。结构体字段顺序通常稳定,但如果测试包含 map,是否要求字节级稳定输出应由 json.Deterministic(true) 明确表达,而不是依赖未声明的 map 顺序。

哪些选项不能靠字段标签解决

字段标签只负责局部表示,并不能替代所有 Options。下面几类仍应放在调用层或类型层:

  • Deterministic 控制本次编码是否产生确定字节,不是某一个字段的标签。
  • RejectUnknownMembers 决定解码对象遇到未知成员时是否报错,属于入口级校验策略。
  • FormatNilSliceAsNull 与 FormatNilMapAsNull 是整体兼容策略;若单个字段需要完全不同的复杂表示,应考虑包装类型。
  • WithMarshalers 与 WithUnmarshalers 按类型覆盖行为,适合不受自己控制的类型或跨多个 DTO 的统一规则。
  • 自定义 MarshalerTo、UnmarshalerFrom 适合一个类型拥有完整独立 JSON 表示的情况。

一个实用判断是:规则若属于“这个字段在协议里永远这样表示”,优先写标签;若属于“这个入口本次必须这样处理”,使用 Options;若属于“这个类型在任何地方都有自己的 JSON 语义”,使用类型级 Marshaler。

接入现有服务时的迁移顺序

官方迁移指南建议低风险项目先用 v1 兼容 Options 保持旧行为,再逐项切到 v2。迁移时同样可以先把稳定字段协议写进标签,然后逐步减少调用点的兼容选项。后传入的 Options 会覆盖先传入的 Options,因此可以从兼容集合开始,再有选择地关闭某项旧语义。

我会按以下顺序落地:

  1. 锁定公开 DTO 的黄金 JSON 样例和错误样例。
  2. 切换导入路径,但先保留需要的 v1 兼容 Options。
  3. 把大整数、零值省略和字段匹配规则迁入标签。
  4. 逐个移除调用点的兼容选项,并比较输出差异。
  5. 最后再启用拒绝未知成员、严格 UTF-8 等入口级策略。

这种做法的好处是,字段协议和迁移开关不会混在一起。等迁移完成后,DTO 仍清楚表达协议,调用点也只保留真正属于请求边界的策略。

常见问题

字段标签真的会优先于调用级 Options 吗?

会,但仅限两者描述同一语义时。例如字段上的 case:strict 会优先于 MatchCaseInsensitiveNames(true)。没有对应字段标签的调用级选项,不能凭空按字段关闭。

能给一个切片字段加 string,让元素都变成字符串吗?

不能。JSON v2 的 string 只作用于字段顶层,而且要求该字段本身编码为 JSON number。复合类型会报错,不会递归处理元素。

为什么 int 字段用了 omitempty 仍然输出 0?

因为 v2 的 omitempty 看 JSON 空值,数字 0 不是空 JSON 值。要按 Go 零值省略数字,应使用 omitzero。

所有字段都要写 case:strict 吗?

不需要。JSON v2 默认就是大小写敏感。只有调用点开启了全局宽松匹配,而少数字段仍需精确名称时,显式写 case:strict 才最有价值。

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