当前位置:首页 > 文章列表 > Golang > Go教程 > encoding/json/v2 自定义 Marshaler 如何保留未知字段

encoding/json/v2 自定义 Marshaler 如何保留未知字段

来源:17golang原创 2026-10-09 00:22:35 0浏览 收藏

服务网关收到一段 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,先看是否真的需要改变线上形态。

encoding/json/v2 保留未知字段的三种方案对比
关系图:字段级回退最省事,整对象保留最完整,自定义 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 的默认结构体逻辑。

Event、wireEvent、Extra 与 v2 流式编解码接口的关系
关系图:业务模型和线形态都携带 Extra,成对的流式方法才能在读写两端保留未知字段。
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,并移除了部分实验期名称。新代码应以当前标准库文档为准,迁移旧示例时也要同步调整标签和选项。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
CSS Anchor Positioning 如何配置 position-try 回退位置CSS Anchor Positioning 如何配置 position-try 回退位置
上一篇
CSS Anchor Positioning 如何配置 position-try 回退位置
农产品加工厂做食品生产许可前要准备哪些场地资料
下一篇
农产品加工厂做食品生产许可前要准备哪些场地资料
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    383次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    454次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    468次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    409次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    237次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码