encoding/json/v2 如何按字段覆盖默认序列化选项
我把一个 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 则明确允许了下划线变体。

为什么 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、""、{} 或 []。
| 字段值 | omitzero | omitempty |
|---|---|---|
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 会优先精确名称;没有精确项且结果仍有歧义时会报告错误,而不是随意选择。

运行与检查:不要只看一条成功输出
保存为 main.go 后运行:
# 编译并运行当前模块,观察编码结果和字段匹配结果。 go run . # 运行格式化,确保提交前代码符合 Go 标准格式。 gofmt -w main.go
我会把验收拆成四组,而不是只保留一个“能跑”的示例:
- 数字表示:确认
requestId带引号,普通数字字段仍是 JSON number。 - 字段存在性:分别测试 nil 切片、空切片、非空切片和数字零值。
- 名称匹配:为
userId测试精确名、全大写、下划线和短横线变体。 - 错误路径:把
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,因此可以从兼容集合开始,再有选择地关闭某项旧语义。
我会按以下顺序落地:
- 锁定公开 DTO 的黄金 JSON 样例和错误样例。
- 切换导入路径,但先保留需要的 v1 兼容 Options。
- 把大整数、零值省略和字段匹配规则迁入标签。
- 逐个移除调用点的兼容选项,并比较输出差异。
- 最后再启用拒绝未知成员、严格 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 才最有价值。
PHP Fiber 如何让同步接口适配事件循环
- 上一篇
- PHP Fiber 如何让同步接口适配事件循环
- 下一篇
- Java ScopedValue 如何替代只读 ThreadLocal 上下文
-
- Golang · Go教程 | 26分钟前 | JSON · go · 兼容性 · encoding/json/v2 未知字段 jsontext.Value MarshalerTo UnmarshalerFrom
- encoding/json/v2 自定义 Marshaler 如何保留未知字段
- 318浏览 收藏
-
- Golang · Go教程 | 49分钟前 | 标准库 · JSON · Go教程 · Go jsontext encoding/json/v2 JSON流式读取 UnmarshalDecode
- 用 encoding/json/v2 流式读取连续 JSON 值
- 269浏览 收藏
-
- Golang · Go教程 | 3小时前 |
- 在二进制中嵌入迁移文件并按版本顺序执行
- 293浏览 收藏
-
- Golang · Go教程 | 3小时前 |
- 为前端资源建立开发期本地读取与发布期嵌入切换
- 275浏览 收藏
-
- Golang · Go教程 | 3小时前 | 单元测试 · Go教程 · 工程实践 · html/template embed.FS fs.FS Go嵌入资源 fstest.MapFS
- 用 embed.FS 打包静态模板并保持目录结构可测试
- 230浏览 收藏
-
- Golang · Go教程 | 4小时前 | docker · CGO · Go教程 · CGO_ENABLED Docker Buildx cgo交叉编译 Go交叉编译镜像 多架构镜像 GNU交叉编译器
- 为含 cgo 的项目设计可重复的交叉编译镜像
- 480浏览 收藏
-
- Golang · Go教程 | 4小时前 | CGO · 资源管理 · Go教程 · runtime.KeepAlive runtime/cgo.Handle Go cgo C库句柄 LockOSThread
- 封装 C 库句柄并明确创建、释放与线程约束
- 400浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 383次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 454次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 467次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 406次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 237次使用
-
- 接口返回 200 但前端仍报错怎么办:从响应格式到跨域一步步排查
- 2026-06-14 332浏览
-
- Go map 并发写 panic 怎么办:从共享 map 到可控写入路径
- 2026-06-30 123浏览
-
- golang生成JSON以及解析JSON
- 2023-01-17 329浏览
-
- Go如何实现json字符串与各类struct相互转换
- 2023-01-07 377浏览
-
- Go中使用gjson来操作JSON数据的实现
- 2023-01-07 141浏览

