encoding/json/v2 自定义 Marshaler 如何保留未知字段
服务网关收到一段 JSON,读取并修改 id、created_at 后再转发。上游后来新增了 region 和 feature,你的 Go 结构体还没升级,但中间层不能把这些字段吞掉。encoding/json/v2 原生支持未知成员回退;真正容易踩坑的是:一旦类型实现了自定义 MarshalerTo,默认结构体表示会被替换,回退字段也必须由自定义方法显式带回线上 JSON。
本文以 Go 1.27 正式版 API 为准。实验期示例中的 unknown 标签、DiscardUnknownMembers 和 inline 名称已经过时,不应直接复制。
官方文档:https://pkg.go.dev/encoding/json/v2
使用场景:网关为什么会悄悄丢字段
先看一个很常见的事件模型。业务只认识两个字段,但上游可能随时添加新成员:
{
"id": "evt_42",
"created_at": "2026-10-08T15:30:00Z",
"region": "ap-southeast-1",
"feature": {"beta": true}
}
如果只定义 ID 和 CreatedAt,默认解码会忽略另外两个成员。即使中间层只是改时间再编码,region 与 feature 也会消失。v2 的直接解法是在结构体里放一个“嵌入回退字段”:
package event
import (
"time"
"encoding/json/jsontext"
)
type Event struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
// 未匹配的对象成员按字段保存原始 JSON 值。
Extra map[string]jsontext.Value `json:",embed"`
}
当类型没有自定义 JSON 方法时,这已经够用:已知成员进入普通字段,未知成员进入 Extra,再次编码时又被提升回对象顶层。问题发生在你为了固定时间格式、兼容旧字段名或添加业务校验而实现 MarshalJSONTo:类型专用方法优先于默认表示,如果方法构造的输出只有两个已知字段,Extra 就不会自动出现。
候选方案:三种保留未知字段的方式
同样是“未知字段不丢”,其实有三种不同粒度的方案。不要一上来就写自定义 Marshaler,先看是否真的需要改变线上形态。

方案一:结构体加 map 回退字段
map[string]jsontext.Value 最适合“已知字段需要类型安全,未知字段只要求透传”的中间层。每个未知成员独立保存,日志、过滤和删除都很方便。对大多数 API 网关、Webhook 消费者和事件升级场景,这是首选。
方案二:保留整个 jsontext.Value
如果要求尽可能保留整个对象的原始 JSON 表示,或者几乎不访问具体字段,可以直接把对象作为 jsontext.Value 持有。它的保真边界更大,但修改一个已知字段时需要重新解析或重建对象,类型校验也更弱。它更适合归档、签名验证前的原文保存和纯代理,而不是日常业务模型。
方案三:自定义方法加 wire 结构
当已知字段确实需要自定义格式时,定义一个只描述线上 JSON 的辅助结构,例如 wireEvent。业务类型和 wire 类型都携带同一个 Extra,再通过 json.MarshalEncode 与 json.UnmarshalDecode 复用 v2 的默认字段匹配。这样既不会递归调用自己的方法,也不会手工拼接 JSON。
对比维度:类型安全、保真度与维护成本
| 方案 | 已知字段类型安全 | 未知字段访问 | 自定义输出 | 维护成本 |
|---|---|---|---|---|
结构体 + map[string]jsontext.Value | 强 | 按名称直接访问 | 默认格式 | 最低 |
整个 jsontext.Value | 弱 | 需要额外解析 | 偏向原样保存 | 中等 |
| 自定义方法 + wire 结构 | 强 | 按名称直接访问 | 最灵活 | 最高 |
选择的核心不是性能,而是“谁负责定义线上 JSON”。默认结构体表示已经满足需求时,使用方案一;完全不想理解对象内部语义时,使用方案二;只有已知字段的线上格式与业务类型不同,才进入方案三。
推荐选择:wireEvent 显式带上 Extra
下面给出完整实现。业务模型使用 time.Time,线上固定 RFC3339Nano 字符串;未知成员由 Extra 保存。辅助类型没有自定义方法,所以调用 MarshalEncode 或 UnmarshalDecode 时会正常执行 v2 的默认结构体逻辑。

package event
import (
"fmt"
"time"
"encoding/json/jsontext"
"encoding/json/v2"
)
type Event struct {
ID string
CreatedAt time.Time
Extra map[string]jsontext.Value
}
// wireEvent 只描述线上 JSON,不实现自定义方法。
type wireEvent struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
// 未匹配成员会被收集,并在编码时提升回对象顶层。
Extra map[string]jsontext.Value `json:",embed"`
}
func (e *Event) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var wire wireEvent
// 使用辅助类型触发默认字段匹配,避免递归调用本方法。
if err := json.UnmarshalDecode(dec, &wire); err != nil {
return err
}
// 已知字段仍执行严格的业务格式校验。
createdAt, err := time.Parse(time.RFC3339Nano, wire.CreatedAt)
if err != nil {
return fmt.Errorf("created_at 格式错误: %w", err)
}
// 所有检查成功后再更新接收者,避免留下半成品。
e.ID = wire.ID
e.CreatedAt = createdAt
e.Extra = wire.Extra
return nil
}
func (e Event) MarshalJSONTo(enc *jsontext.Encoder) error {
// 手工填充 Extra 是保留未知成员的关键。
wire := wireEvent{
ID: e.ID,
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339Nano),
Extra: e.Extra,
}
// 辅助类型没有自定义方法,因此不会再次进入 MarshalJSONTo。
return json.MarshalEncode(enc, &wire)
}
这段代码的关键不是方法签名,而是读写两端都经过同一个 wireEvent。如果 UnmarshalJSONFrom 带上 Extra,但 MarshalJSONTo 构造 wire 值时漏掉它,代码依旧能编译,数据却会在重新编码时丢失。自定义方法应当成对评审。
调用端不需要知道回退细节:
package main
import (
"fmt"
"log"
"time"
"encoding/json/v2"
"example.com/project/event"
)
func main() {
input := []byte(`{
"id":"evt_42",
"created_at":"2026-10-08T15:30:00Z",
"region":"ap-southeast-1",
"feature":{"beta":true}
}`)
var value event.Event
// 未知的 region 和 feature 会进入 value.Extra。
if err := json.Unmarshal(input, &value); err != nil {
log.Fatal(err)
}
// 业务只修改认识的字段。
value.CreatedAt = value.CreatedAt.Add(time.Minute)
// 自定义 Marshaler 会把 Extra 中的成员一并写回。
output, err := json.Marshal(&value)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(output))
}
输出中的时间已经变化,而 region 和 feature 仍然存在。未知值由 jsontext.Value 承载,不需要先变成 map[string]any,因此不会额外引入数字变成 float64 的问题。
冲突与严格模式:别让回退字段覆盖已知字段
正常解码时,id 和 created_at 会匹配已知字段,不会同时落入 Extra。但业务代码可能手工修改 Extra,甚至写入同名键。v2 默认拒绝重复对象成员,与其把底层错误留到编码器,不如在业务边界给出清楚提示:
func rejectKnownNameCollisions(extra map[string]jsontext.Value) error {
// 回退字段不得重新定义已经由结构体管理的名称。
for _, name := range []string{"id", "created_at"} {
if _, exists := extra[name]; exists {
return fmt.Errorf("扩展字段与已知字段 %q 冲突", name)
}
}
return nil
}
可在 MarshalJSONTo 构造 wireEvent 前调用该函数。对允许插件写扩展字段的系统,还可以为扩展名增加前缀、白名单或数量限制,避免一个透传字段集合无限膨胀。
RejectUnknownMembers(true) 是另一种策略:它要求输入出现未识别成员时直接报错,适合严格配置文件和安全敏感请求;它不是“保留未知字段”的开关。当结构体声明了嵌入回退字段时,未被普通字段匹配的成员已有承载位置,不应再把“拒绝”和“透传”混成一个需求。
不适用情况与决策表
| 实际需求 | 推荐方案 | 原因 |
|---|---|---|
| 已知字段正常编解码,只需透传新增成员 | map[string]jsontext.Value + json:",embed" | 字段级访问方便,代码最少 |
| 已知字段需要自定义日期、兼容名或业务校验 | 成对自定义方法 + wire 结构 + Extra | 自定义线上形态,同时保留回退成员 |
| 主要目标是存档或原文转发,几乎不访问字段 | 整个 jsontext.Value | 最大化保留原始 JSON 表示 |
| 未知字段代表客户端拼写错误或越权输入 | RejectUnknownMembers | 应拒绝而不是静默保存 |
| 需要深度合并未知对象、重命名或按值转换 | 显式领域模型或 JSON 变换层 | 简单回退 map 不负责递归业务语义 |
还要注意两个约束:一个结构体只能有一个嵌入回退字段;embed 不能与 JSON 名称或其他标签选项组合。嵌入的回退类型可以是 jsontext.Value、以字符串为键的 map,或符合文档约束的结构体类型。若目标只是逐成员透传,map[string]jsontext.Value 的意图最清晰。
结论
在 encoding/json/v2 中,未知字段保留本身并不复杂:用 json:",embed" 声明回退字段即可。复杂性来自自定义 Marshaler 改写了类型的默认 JSON 表示。只要记住一条规则——自定义 wire 结构必须同时携带已知字段和回退字段,读写方法必须成对实现——中间层就能在更新已知字段的同时安全透传未来版本新增的成员。
相关问题
可以把 Extra 定义成 map[string]any 吗?
可以承载值,但会把动态数字映射到默认 Go 类型,并丢失部分原始 JSON 表示。只为未知成员透传时,map[string]jsontext.Value 更直接。
只实现 MarshalJSONTo,不实现 UnmarshalJSONFrom 行不行?
如果输入仍走默认结构体解码且业务结构本身带有正确标签,技术上可以。但一旦已知字段的输入格式也经过定制,读写规则就容易不对称。使用同一个 wire 类型成对实现更容易审查和测试。
能否在 MarshalerTo 里手工拼接 Extra 的字节?
不建议。手工拼接需要自行处理逗号、名称转义、重复键和无效值。把 wire 结构交给 json.MarshalEncode,可以继续使用 v2 的语法检查和重复名称规则。
为什么不用实验期的 unknown 或 inline 标签?
Go 1.27 正式 API 使用 embed,并移除了部分实验期名称。新代码应以当前标准库文档为准,迁移旧示例时也要同步调整标签和选项。
CSS Anchor Positioning 如何配置 position-try 回退位置
- 上一篇
- CSS Anchor Positioning 如何配置 position-try 回退位置
- 下一篇
- 农产品加工厂做食品生产许可前要准备哪些场地资料
-
- Golang · Go教程 | 35分钟前 |
- Go SIMD 如何批量处理 RGBA 像素通道
- 478浏览 收藏
-
- Golang · Go教程 | 36分钟前 |
- Go test 如何提前发现超出 go.mod 版本的标准库调用
- 311浏览 收藏
-
- Golang · Go教程 | 43分钟前 |
- Go 请求参数怎样先归一化再统一校验
- 172浏览 收藏
-
- Golang · Go教程 | 52分钟前 |
- 为 HTTP 服务建立 goroutine 泄漏基线与差异对比
- 101浏览 收藏
-
- Golang · Go教程 | 1小时前 | goroutine · go · pprof · 后台任务 net/http/pprof goroutineleak go tool pprof Go goroutine 泄漏剖析
- Go 如何用 goroutine 泄漏剖析定位未退出的后台任务
- 306浏览 收藏
-
- Golang · Go教程 | 1小时前 | 标准库 · JSON · Go教程 · Go jsontext encoding/json/v2 JSON流式读取 UnmarshalDecode
- 用 encoding/json/v2 流式读取连续 JSON 值
- 269浏览 收藏
-
- Golang · Go教程 | 2小时前 | JSON · go · Go教程 · omitzero Go JSON encoding/json/v2 json 标签 case strict
- encoding/json/v2 如何按字段覆盖默认序列化选项
- 418浏览 收藏
-
- 前端进阶之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工具。
- 468次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 409次使用
-
- 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浏览

