Go 怎样延迟解析 JSON 中的可变字段
来源:17golang原创
时间:2026-10-17 16:27:46 397浏览 收藏
Go 接收 JSON 接口时,最稳妥的做法不一定是一次把所有字段都解码成最终结构体。对于同一个信封里可能出现订单、通知或其他业务 payload 的接口,可以先解析稳定的 id、kind 等公共字段,把变化部分保留为 json.RawMessage,确认类型后再延迟解析。这样既能让公共字段先可用,也能把错误准确归到具体 payload。
官方文档:https://pkg.go.dev/encoding/json#RawMessage
下面的示例只围绕标准库 encoding/json 的延迟解析与错误定位展开。核心思路是:第一层负责识别信封,第二层根据类型选择结构体,第三层才处理业务字段;不要因为字段可变就把整个对象退化成 map[string]any。
先把固定字段与可变字段分开
假设接口返回的消息都有三个公共字段:id 是消息标识,kind 表示业务类型,payload 的结构则随 kind 改变。直接给 payload 选一个结构体,会让另一种消息无法解码;直接用 map[string]any,又会失去字段类型和错误位置。
json.RawMessage 本质上是保存原始 JSON 编码的字节切片。把它作为结构体字段时,第一次 Unmarshal 会先把对应 JSON 片段交给该字段保存,后续可以把这段片段再次交给具体目标类型。这个动作不是“跳过校验”,整体 JSON 仍需要先是合法的。
package main
import (
"encoding/json"
"fmt"
)
// Envelope 只描述所有消息都稳定存在的公共字段。
type Envelope struct {
ID string `json:"id"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
// OrderPayload 是 kind=order 时才使用的业务结构。
type OrderPayload struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
}
// NoticePayload 是 kind=notice 时才使用的另一种结构。
type NoticePayload struct {
Level string `json:"level"`
Text string `json:"text"`
}
func main() {
input := []byte(`{"id":"m-1001","kind":"order","payload":{"order_id":"o-7","amount":128}}`)
var envelope Envelope
if err := json.Unmarshal(input, &envelope); err != nil {
// 这一步失败说明整个 JSON 或公共字段已经无法进入下一层。
fmt.Println("读取消息信封失败:", err)
return
}
switch envelope.Kind {
case "order":
var payload OrderPayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
// 错误应带上 kind,避免和信封解析错误混在一起。
fmt.Println("订单 payload 解析失败:", err)
return
}
fmt.Printf("订单=%s 金额=%d\n", payload.OrderID, payload.Amount)
case "notice":
var payload NoticePayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
// 不同 kind 使用不同的目标类型,字段检查保持类型化。
fmt.Println("通知 payload 解析失败:", err)
return
}
fmt.Printf("通知级别=%s 内容=%s\n", payload.Level, payload.Text)
default:
// 未知类型不能静默当成某一种已知消息处理。
fmt.Println("未知消息类型:", envelope.Kind)
}
}
这个结构的价值在于把“识别消息类型”和“解释业务字段”拆开。将来增加 refund 时,只需增加对应结构和分支,不必让现有订单结构承载所有可能字段。示例中的输出是帮助读者理解的预期,不是本机运行记录。

按类型延迟解析 RawMessage
延迟解析最重要的判断点是 kind。不要先把 payload 解成 map[string]any 再在业务层断言数字、字符串;那样会引入浮点数、缺失键和类型断言失败等额外分支。先读出类型,再让 json.Unmarshal 对目标结构做字段级转换,错误更接近真实问题。
如果 kind 来自外部请求,应该把可接受类型限制在明确的分支中。未知类型可以返回一个可识别的业务错误,或者暂存原始消息等待兼容处理,但不能默认当成空对象,因为这会让调用方误以为消息已经处理成功。
// decodePayload 根据已识别的kind返回类型化结果,避免调用方重复判断。
func decodePayload(envelope Envelope) (any, error) {
if len(envelope.Payload) == 0 {
// 缺少payload与空对象不是一回事,先保留这个差异。
return nil, fmt.Errorf("kind=%q 缺少 payload", envelope.Kind)
}
switch envelope.Kind {
case "order":
var value OrderPayload
if err := json.Unmarshal(envelope.Payload, &value); err != nil {
return nil, fmt.Errorf("kind=%q 的 payload: %w", envelope.Kind, err)
}
return value, nil
case "notice":
var value NoticePayload
if err := json.Unmarshal(envelope.Payload, &value); err != nil {
return nil, fmt.Errorf("kind=%q 的 payload: %w", envelope.Kind, err)
}
return value, nil
default:
// 业务层决定未知kind是拒绝、记录还是转入兼容队列。
return nil, fmt.Errorf("不支持的消息类型 %q", envelope.Kind)
}
}
辅助函数的返回值可以换成项目自己的接口,例如所有 payload 都实现 MessagePayload。文章示例使用 any 是为了突出解析边界;真实项目应根据后续调用方式选择接口、泛型包装或显式结果类型。
处理缺失、null 与非法 JSON
这几个状态看起来相似,但含义不同。字段缺失时,RawMessage 通常保持为 nil;字段写成 JSON null 时,原始内容有明确的空值语义;字段存在但类型不对时,第二次 Unmarshal 会返回类型错误;整个文档括号、引号或逗号不合法时,第一层就应该失败。
因此不要只写一个“解析失败”日志。至少应记录消息类型、失败层级和经过脱敏的字段名,不要直接把整份输入写入日志。对可选 payload 可以用一个小判断区分缺失和 null,对必须存在的 payload 则尽早返回。
// payloadState 把RawMessage的常见状态转换成调用方能理解的结果。
func payloadState(raw json.RawMessage) (string, error) {
if raw == nil {
// 字段没有出现,属于缺失状态。
return "missing", nil
}
if string(raw) == "null" {
// 字段出现但明确为null,是否允许由业务规则决定。
return "null", nil
}
if !json.Valid(raw) {
// 这通常意味着外层解析流程被绕过或原始数据被错误拼接。
return "invalid", fmt.Errorf("payload 不是合法 JSON")
}
return "value", nil
}
通常不需要在第一次 json.Unmarshal 后再次调用 json.Valid,因为第一层已经对整个文档做过语法解析。这里单独展示它,是为了说明当 RawMessage 来自缓存、拼接结果或自定义输入层时,应该先确认它仍是合法 JSON。

把解码边界接进接口代码
对于一个 HTTP 请求体,json.Decoder 适合从流中读取 JSON;如果要在错误日志中给出读取位置,可以使用 InputOffset 获取解码器当前输入偏移。它帮助定位“读到哪里出错”,但不会自动告诉你业务字段是哪一个,所以仍要在错误消息中补充解析阶段和字段上下文。
如果接口约定一次请求只能有一个 JSON 值,解码第一个对象后还应考虑是否存在多余内容。若请求体很大,还应设置读取上限;这些是接口边界控制,不应由 RawMessage 代替。
// decodeRequest 从Reader读取一个信封,并把语法错误转换为带位置的信息。
func decodeRequest(r io.Reader) (Envelope, error) {
decoder := json.NewDecoder(r)
var envelope Envelope
if err := decoder.Decode(&envelope); err != nil {
// InputOffset是当前解码位置,适合帮助定位输入边界。
return Envelope{}, fmt.Errorf("JSON 语法错误,偏移量=%d: %w", decoder.InputOffset(), err)
}
if envelope.ID == "" || envelope.Kind == "" {
// 公共字段校验放在信封层,不要等到业务payload才报错。
return Envelope{}, fmt.Errorf("消息信封缺少 id 或 kind")
}
return envelope, nil
}
这里把语法错误、公共字段错误和 payload 类型错误分成三层,调用方可以据此决定返回 400、进入兼容队列还是记录为内部数据问题。错误包装使用 %w 时,仍可在上层用 errors.As 检查原始类型;不要为了统一日志而丢掉底层错误。
什么时候适合采用这种拆分
当一个接口有稳定信封、payload 类型较少且不同类型的字段确实不同,RawMessage 通常能减少无效结构体和重复的可选字段。它也适合事件总线、Webhook 和兼容多版本消息的入口:公共层先记录元数据,业务层再按类型选择解码器。
风险也很明确:同一段 JSON 会经历两次处理,payload 很大时会增加 CPU 和内存成本;类型分支多到几十种时,单个 switch 会变成新的维护中心。此时可以把类型到解码函数的映射登记在表中,或按版本拆分消费者,但仍应保留“先识别、后解释”的边界。
| 场景 | 建议 | 原因 |
|---|---|---|
| 公共字段稳定、payload 类型有限 | 使用 RawMessage 延迟解析 | 结构清晰,错误可绑定到具体类型 |
| payload 只有少数字段可选 | 直接使用一个结构体 | 避免不必要的二次解析 |
| 类型数量很多且持续扩展 | 注册解码器或拆分消费者 | 减少巨型 switch 的耦合 |
| 只需要透传未知字段 | 保留 RawMessage 并记录版本 | 避免未知类型被误当成成功处理 |
常见问题
RawMessage 会让非法 JSON 直接绕过检查吗?
不会。第一次解码仍需通过整体 JSON 语法解析;它延迟的是可变字段到具体 Go 类型的转换,不是把非法文档当成合法输入。
payload 是 null 时能直接 Unmarshal 吗?
可以得到目标类型的零值,但这不代表业务上“有一个空对象”。如果 null 与缺失的含义不同,应先检查原始字段状态,再决定是否允许继续。
为什么不一直使用 map[string]any?
map 适合确实未知且只做透传的字段;对于已知的订单或通知类型,结构体能提供更明确的字段类型、错误信息和后续重构边界。
如何定位 payload 中的具体字段错误?
按 kind 单独 Unmarshal,并在错误中包上消息类型;整体语法错误则使用 Decoder 的 InputOffset 辅助定位输入位置。两类错误不要混为一个“解析失败”。
JSON 可变字段的处理重点不是把所有情况都塞进一个万能结构,而是保留清晰的生命周期:先识别稳定信封,再按类型延迟解释 payload,最后把缺失、null、类型不匹配和未知类型分别交给对应层处理。这样接口扩展时,新增类型不会悄悄改变旧消息的解析语义。
-
332 收藏
-
369 收藏
-
344 收藏
-
329 收藏
-
377 收藏
-
233 收藏
-
362 收藏
-
165 收藏
-
490 收藏
-
407 收藏
-
474 收藏
-
412 收藏
-
261 收藏
-
181 收藏
-
175 收藏
-
167 收藏
-
Golang · Go教程 | 45分钟前 | JSON · bytes.Buffer · Go教程 · encoding/json · Go encoding/json bytes.Buffer json.Compact JSON压缩 JSON空白200 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习