Go 基础体系 · 第 29/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go JSON 编解码:结构体标签、Decoder、数字与未知字段
本文所有代码与运行行为均以 Go 1.26.4 为基准。encoding/json 能用很少代码完成 JSON 与 Go 值之间的转换,但“能够解码”不等于“契约正确”:字段是否缺失、null 是否允许、未知字段怎样处理、大整数是否失真、输入后是否夹带第二个值,都需要应用明确决定。
本文聚焦 JSON 数据模型、编解码语义、边界和诊断。结构体嵌入的一般规则属于结构体主题,any 与类型断言属于动态类型主题,HTTP body 限制、状态码和超时属于 HTTP 主题。示例会使用 reader/writer,但字节流细节不在这里重复展开。
1. JSON 值与 Go 类型怎样对应
JSON 只有 object、array、string、number、boolean 和 null。结构体适合有稳定契约的 object,slice/array 对应 array,map 对应动态 object,字符串和布尔值直接映射。编码只观察导出字段;未导出字段即使有标签也不会出现在结果中。
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
Tags []string `json:"tags,omitempty"`
Draft bool `json:"draft"`
version int // 未导出,不参与 JSON
}
稳定 API 应优先定义请求/响应 DTO,而不是直接暴露数据库模型或领域对象。DTO 能明确字段名、可选性和演进策略,也避免内部新增导出字段意外改变外部协议。编码成功只证明值可表示,不证明业务有效;长度、范围、枚举、跨字段约束仍需单独校验。
2. Marshal、Unmarshal 与流式 Encoder/Decoder
json.Marshal(v) 返回完整 []byte,适合小对象;json.Unmarshal(data, &dst) 从完整字节切片解码。Encoder/Decoder 面向 io.Writer/io.Reader,可避免先复制成整块字节,并支持连续 JSON 值或流式数组处理。Encoder.Encode 会在每个值后追加换行,作为文件格式时要把这一点纳入协议。
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields()
var request CreateRequest
if err := decoder.Decode(&request); err != nil {
return fmt.Errorf("decode request: %w", err)
}
Unmarshal 的目标必须是非 nil 指针,否则返回 InvalidUnmarshalError。重复使用同一个目标时要小心残留:输入缺失的结构体字段保持原值,解码到非 nil map 会保留未被新输入覆盖的旧键,slice 则重置长度后追加(空数组会替换为空 slice)。请求处理通常每次声明新的零值 DTO,避免状态串入下一次解码。
3. 标签、字段选择和冲突
标签形式为 json:"name,omitempty"。名字 - 表示始终忽略;若真要编码名为 - 的字段可写 json:"-,"。无标签时使用 Go 字段名,外部契约最好显式标注,避免重命名 Go 字段时破坏协议。
匿名嵌入字段会参与字段提升,但同层存在同名候选时按标签和层级规则选择,冲突字段可能被静默忽略而不是报错。复杂嵌入 DTO 很难审查,API 边界更适合扁平、显式字段。标签语法错误也可能直到运行测试才被发现,应对关键 DTO 做编解码契约测试。
omitempty 根据 Go 的“空值”判断:false、数值零、nil 指针或接口、长度为零的字符串/数组/slice/map 会被省略。结构体值通常不会因为内部全为零而自动省略;需要可选结构体时常用指针。omitzero 按零值或类型的 IsZero() bool 判断,和 omitempty 的集合长度语义不同;选择应以协议为准,而不是为了缩短 JSON 随意添加。
4. 缺失、null、零值与 PATCH 语义
普通值字段无法区分“没提供”和“提供零值”。例如 Limit int 解码 {} 与 {"limit":0} 后都是 0。指针可以区分缺失与非 null 值,但 {} 和 {"limit":null} 都会得到 nil,因此仍是两态而非三态。
type Patch struct {
Title *string `json:"title"`
Limit *int `json:"limit"`
}
若协议必须区分缺失、null、具体值,应定义带 Set、Null 和 Value 的可选类型并实现 UnmarshalJSON,或先解码为 map[string]json.RawMessage 检查键是否存在,再对各字段解码。不能把校验库的 required 当作自动解决三态,它通常只在解码之后看到 Go 值。
nil slice 编码为 null,非 nil 空 slice 编码为 [];nil map 同样为 null。若 API 约定集合永远是数组,应在构造响应时初始化空 slice,或用自定义类型统一行为。反向解码时 JSON null 对指针、map、slice、interface 会置 nil;对多数非指针标量通常没有效果且不报错,这可能掩盖契约错误,严格 API 应显式拒绝不允许的 null。
5. 数字精度与范围
解码到具体 int64、uint64 或浮点字段时,库按目标类型解析并检查溢出。解码到 any 时,JSON number 默认成为 float64;大于 2^53 的整数不能保证精确,ID 9007199254740993 可能悄悄改变。
dec := json.NewDecoder(strings.NewReader(`{"id":9007199254740993}`))
dec.UseNumber()
var value map[string]any
if err := dec.Decode(&value); err != nil {
return err
}
id, err := value["id"].(json.Number).Int64()
UseNumber 只影响解码到 interface 的数字,使其成为保存原始文本的 json.Number;之后仍要按业务范围调用 Int64、Float64 或交给十进制库。金钱不要用二进制浮点表达精确小数,可使用最小货币单位整数、字符串或经过验证的 decimal 类型。跨 JavaScript 客户端传递 64 位 ID 时常使用字符串,避免客户端先丢精度。
编码 NaN、正负无穷会返回 UnsupportedValueError,因为它们不是合法 JSON number。循环引用也无法编码;库检测后返回错误。编码前应避免把这些内部状态直接泄露到协议层。
6. 未知字段、大小写与重复键
默认结构体解码会忽略未知 object 字段,这对向前兼容有利,却会让拼写错误静默生效,例如客户端发 titlle 而服务端得到空标题。面向受控客户端的创建/更新接口通常启用 Decoder.DisallowUnknownFields(),再通过版本化或显式扩展区处理演进。
字段匹配优先精确标签或字段名,也可能接受不区分大小写的匹配。不要依赖宽松匹配作为协议;测试应使用规范字段名。DisallowUnknownFields 只针对解码到结构体的未知键,解码到 map 时所有键自然都被接受。
JSON 对象出现重复键时,后出现的值通常会替换或合并先前结果,具体受目标类型影响。这会造成不同实现之间的安全歧义:代理、签名器和业务若选择不同值,可能产生绕过。高风险协议应在进入业务前拒绝重复键;标准高层 API 没有一个简单开关完整完成此事,可用 Decoder.Token 跟踪每层 object 的键集合,或采用明确承诺拒绝重复键的验证层。
7. 解码完整性:一个值之后必须是 EOF
第一次 Decode 成功不代表整个输入只有一个 JSON 值。{"a":1}{"b":2} 会让第一次调用成功,若直接进入业务便忽略了尾随值。严格单文档协议应再解码一次并要求得到 io.EOF:
func decodeOne(r io.Reader, dst any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values")
}
return fmt.Errorf("trailing data: %w", err)
}
return nil
}
尾随空白是合法的。仅检查 dec.More() 不够,More 用于报告当前数组或对象是否还有元素,不是顶层 EOF 检查。Decoder.Buffered() 可取得 decoder 已经从底层多读、但尚未消费的字节,主要用于协议复用;它不替代第二次 Decode。
输入大小必须在 JSON 层之前限制。Decoder 是流式的,但单个巨大字符串、数组或深层结构仍会消耗大量内存和 CPU。上层应使用有明确“超限”判断的 reader,并为集合长度、字符串长度与嵌套深度制定业务限制。
8. Token 与大型数组的增量处理
Decoder.Token() 返回分隔符、字符串、数字、布尔或 nil,可用于不把整个大型数组装入内存。先读 [,在 dec.More() 为真时逐项 Decode,最后读 ]。每项仍需独立校验和错误策略。
dec := json.NewDecoder(r)
tok, err := dec.Token()
if err != nil || tok != json.Delim('[') {
return errors.New("expected array")
}
for dec.More() {
var item Item
if err := dec.Decode(&item); err != nil {
return err
}
if err := consume(item); err != nil {
return err
}
}
_, err = dec.Token() // 消费 ]
增量解码降低峰值内存,但数据库写入是否逐条提交、失败后能否重试、前面已处理项目是否回滚,是业务协议问题。若要求全有或全无,仍需事务或先落临时数据;不能因为解析是流式就默认处理也是原子的。
连续调用 Encode/Decode 很适合 NDJSON 或进程间流协议,每行一个完整 JSON 值。但标准 JSON 数组和 NDJSON 是不同格式,生产者与消费者必须明确约定,不能靠“Decoder 好像都能读”混用。
9. RawMessage 与延迟分派
json.RawMessage 本质是原始 JSON 字节,可用于先读取 envelope 的类型,再把 payload 解码成具体结构:
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
它适合多态消息、签名字段或需要原样转发的局部值。使用时仍应限制总大小,并在分派后严格解码 payload。把 RawMessage 长期保存会保留一份字节副本;它不是无需成本的懒对象。
动态 object 用 map[string]json.RawMessage 比 map[string]any 更能保留数值文本和字段级错误位置。解析已知字段后,应决定剩余键是拒绝、保留还是透传。盲目透传未知 JSON 可能把攻击内容带到另一个信任域。
10. 自定义 Marshaler/Unmarshaler
实现 json.Marshaler/json.Unmarshaler 可定义时间、枚举、加密字段或兼容格式。MarshalJSON 必须返回合法 JSON;UnmarshalJSON 的输入只在调用期间有效,需要保留时必须复制。错误应说明类型和约束,但不要包含敏感原文。
最常见错误是在方法内部再次对接收者调用 json.Marshal 或 json.Unmarshal,导致无限递归。使用不带方法集的新定义类型打断递归:
type Status string
func (s *Status) UnmarshalJSON(data []byte) error {
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch Status(raw) {
case "draft", "published":
*s = Status(raw)
return nil
default:
return fmt.Errorf("invalid status %q", raw)
}
}
指针接收者要考虑 null,值接收者要考虑复制语义。自定义逻辑是协议代码,应覆盖零值、null、错误类型、嵌套、往返和兼容旧格式的测试。不要在其中做数据库查询或网络请求,编解码应保持确定、快速且无外部副作用。
11. HTML 转义、格式化与稳定性
Marshal 默认把 <、>、& 等转义为 Unicode 序列,以降低 JSON 嵌入 HTML <script> 时的风险。纯 API 响应若不需要该行为,可对 Encoder 调用 SetEscapeHTML(false),但必须确认输出上下文;JSON 安全不等于 HTML 安全。
MarshalIndent 或 Encoder.SetIndent 适合配置和诊断输出,会增加体积与 CPU。日志中不要为美观重复 marshal 大对象,更不要输出凭据。map 键输出具有确定排序行为可便利测试,但协议消费者不应依赖 object 成员顺序;JSON object 在语义上无序。
对相同 Go 值得到稳定字节不等于通用规范化 JSON。数字表示、自定义 Marshaler、转义和其他语言实现可能不同。数字签名场景应采用明确的规范化标准或签名结构化字段,不能直接假设任意 marshal 结果跨实现一致。
12. 错误分类与对外响应
语法错误可通过 *json.SyntaxError 取得字节偏移;目标类型不匹配常是 *json.UnmarshalTypeError,也含偏移和字段信息。未知字段当前通常表现为文本错误,没有稳定导出专用类型。内部日志可记录分类、偏移和请求追踪 ID,对客户端只返回稳定、无敏感内容的错误码与可理解字段提示。
不要把完整请求体拼进错误,里面可能有令牌、个人信息或超大数据。偏移是字节位置,不是 Unicode 字符列;诊断界面若展示片段应限制长度并脱敏。对同一输入反复重试解析不会成功,语法和校验错误属于永久客户端错误;读取中断是否重试由传输层决定。
编码响应也会失败,例如遇到不支持的值或 writer 中途报错。一旦开始向网络 writer 写正文,状态码可能无法更改。工程上先构造并验证小响应,或使用框架提供的缓冲策略;大型流式响应则必须接受部分输出并用协议层结束方式处理。
13. 诊断、模糊测试与契约测试
关键 DTO 应有表驱动测试,覆盖最小合法值、缺失字段、null、零值、未知字段、错误类型、范围边界、多余顶层值和 round trip。round trip 不是万能证明:omitempty、nil/空集合、浮点和自定义正规化都可能让前后值不完全相同,应断言协议真正关心的性质。
Fuzz 很适合验证“任意输入不会 panic 或无限耗时”“成功解码后再编码仍是合法 JSON”“自定义 Unmarshal 不越界”。同时设置测试资源边界,避免把巨大随机输入变成测试基础设施问题。
线上指标可按语法错误、未知字段、业务校验失败、超限和内部编码失败分类。版本发布后未知字段错误突然升高,通常意味着客户端与服务端契约漂移;数值范围错误集中出现,则应检查上游类型或 ID 表示。
14. 工程实践清单
- 稳定边界使用显式 DTO,不直接暴露内部模型或普遍使用
map[string]any。 - 明确区分缺失、null、零值以及 nil/空集合,按 PATCH 语义选择表示。
- 动态数字调用
UseNumber并做范围解析;精确金额和跨端大 ID 不用float64。 - 单文档输入第一次 Decode 后再次 Decode 并要求 EOF,同时设置字节与结构限制。
- 受控 API 通常拒绝未知字段;高风险协议还要评估重复键歧义。
- 自定义编解码保持纯粹,避免接收者递归,完整测试 null 与错误分支。
- 内部保留可分类错误,对外不回显完整正文和底层敏感信息。
- 大数组可增量解析,但部分业务成功、事务与重试必须另行设计。
15. 可运行综合示例:严格解码并规范输出
下面程序限制输入大小,拒绝未知字段和第二个顶层值,用 json.Number 保留 ID 文本,并校验业务字段。这里直接用内存字符串演示;HTTP 层还应使用能明确报告超限的 body 限制器。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
)
type Request struct {
ID json.Number `json:"id"`
Title string `json:"title"`
}
func decodeRequest(r io.Reader) (Request, error) {
var req Request
data, err := io.ReadAll(io.LimitReader(r, 1025))
if err != nil {
return req, fmt.Errorf("read: %w", err)
}
if len(data) > 1024 {
return req, errors.New("JSON exceeds 1024 bytes")
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
return req, fmt.Errorf("decode: %w", err)
}
var extra any
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return req, errors.New("multiple JSON values")
}
return req, fmt.Errorf("trailing JSON: %w", err)
}
if _, err := req.ID.Int64(); err != nil {
return req, fmt.Errorf("id: %w", err)
}
if strings.TrimSpace(req.Title) == "" {
return req, errors.New("title is required")
}
return req, nil
}
func main() {
req, err := decodeRequest(strings.NewReader(`{"id":9007199254740993,"title":"Go JSON"}`))
if err != nil {
panic(err)
}
out, err := json.Marshal(req)
if err != nil {
panic(err)
}
fmt.Println(string(out))
}
运行:
gofmt -w main.go
go run main.go
预期输出保持精确 ID:{"id":9007199254740993,"title":"Go JSON"}。测试时再加入未知字段、空标题、超出 int64 的 ID、两个连续对象和尾随乱码,验证每种失败都在进入业务逻辑前被截获。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 文件与文件系统:os、io/fs、path 和 filepath
- 下一篇:Go 时间处理:time.Time、Duration、时区、Timer 与 Ticker
- 延伸:Go 结构体、标签与嵌入:用组合建立清晰的数据模型
- 延伸:Go 类型断言、类型 switch 与 any:安全处理动态值
- 延伸:Go net/http 基础:Server、Handler、Middleware 与 Client 超时
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论