标准库默认丢掉未知 JSON 字段。要透传,就自己 round-trip,并且 delete 已知键。
读完可以做两件事:给结构体加上 Extra 和一对 Marshal/Unmarshal;如果这份状态还会写进 checkpoint,再加 SchemaVersion 和 Legacy。
拿一份 JSON:
{"id":"u001","name":"Alice","age":28,"vip":true,"tags":["new"]}
结构体只有 ID 和 Name。encoding/json 解完,age、vip、tags 没了。再 Marshal 也回不来。这不是实现 bug——文档写得很清楚:未知 object key 默认忽略。
服务端先加了 vip,旧客户端还不认识;网关要把整包转发出去。这两种场景都需要「结构体是 schema 的子集,其余原样留下」。Go 默认不提供这条路径。
标准库怎么处理未知字段
忽略是一端。DisallowUnknownFields 是另一端:遇到未知 key 报错。两端都不会把 age 留下来。
直接 Unmarshal 之后,手里只剩契约字段:
User{ID: "u001", Name: "Alice"}
嵌套 "extra": {...} 把问题推给调用方:对方得改 payload 形状。OpenAPI / JSON Schema 默认开放,additionalProperties 为 true 时,未声明的键合法出现在对象顶层,不进一个叫 extra 的子对象。线协议继续平铺,未知字段进内存里的桶,写回去再摊开。
Extra 放在哪
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Extra map[string]any `json:"-"`
}
json:"-":标准库不碰这个 map。进出都走自定义的 UnmarshalJSON / MarshalJSON。Extra 是进程里的桶,不是线上那个键。
平铺是线协议;嵌套强迫调用方改 payload:
{"id":"u001","name":"Alice","age":28,"vip":true,"tags":["new"]}
{"id":"u001","name":"Alice","extra":{"age":28,"vip":true,"tags":["new"]}}
Marshal 时 Extra 摊回去,线上看不到 extra 这个键。
Unmarshal
先从 map 取出契约字段,再 delete,剩下的才进 Extra。先删就取不到。
func (u *User) UnmarshalJSON(data []byte) error {
raw := map[string]any{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if v, ok := raw["id"]; ok {
if s, ok := v.(string); ok {
u.ID = s
}
delete(raw, "id")
}
if v, ok := raw["name"]; ok {
if s, ok := v.(string); ok {
u.Name = s
}
delete(raw, "name")
}
u.Extra = raw
return nil
}
这段证明了:已知字段进结构体,剩下的 map 原样留给 Extra。对上面那份样本,走完是:
u.ID = "u001"
u.Name = "Alice"
u.Extra = map[string]any{"age": 28, "vip": true, "tags": []any{"new"}}
类型断言失败也要 delete。"id": 123 赋不进 string,更不该漏进 Extra——那是脏数据,不是扩展。vip 该不该是 bool,这一步先不判,留给使用方。
UnmarshalJSON 用指针接收者。值接收者改的是副本,解完外面的 u 还是零值。若要调标准库解嵌套结构体,用 type Alias User 挡住递归。上面这份走 map,没有这个问题。
不 delete 会怎样
社区从 2015 年就在用「解成 map,删掉已知键」(SO: known + unknown fields)。Prashant V 的 round-trip 是同一模式的 overlay 变体:raw map 留下,Marshal 时用结构体字段盖回去。
同一份输入,不 delete 时 Extra 变成全量拷贝:
u.ID = "u001"
u.Name = "Alice"
u.Extra = map[string]any{
"id": "u001", "name": "Alice",
"age": 28, "vip": true, "tags": []any{"new"},
}
后面几件事会一起坏:
for k := range u.Extra做审计或透传时,会把id当扩展字段。- 业务改了
u.ID = "u002",忘了改u.Extra["id"],下次合并听哪边取决于 Marshal 的if _, exists。 - Extra 里还能读到
id,容易以为扩展字段也可以叫这个名字;ID改成UserID时,拷贝那份不会跟着改。
这是职责问题。Extra 用来收扩展;不 delete,它只是全部字段的拷贝。
Marshal
默认优先级:契约字段赢,扩展字段让。这是设计选择,不是标准库规定。
func (u User) MarshalJSON() ([]byte, error) {
m := map[string]any{
"id": u.ID,
"name": u.Name,
}
for k, v := range u.Extra {
if _, exists := m[k]; !exists {
m[k] = v
}
}
return json.Marshal(m)
}
这段证明了:先写入 Core,Extra 只补缺。冲突样本 u.Extra["name"] = "Bob",输出仍然是 Alice。Extra 优先会盖掉契约字段,我不当默认。
对样本 round-trip 一次:
{"age":28,"id":"u001","name":"Alice","tags":["new"],"vip":true}
encoding/json 编 map 时按 key 字母序输出。往返测试比的是语义,不是字节相等。age 出现在 id 前面是正常的。要保顺序、保原始字节,别走 map[string]any。网关只透传时,剩余键收成 json.RawMessage,读之前再解一次。
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw)
json.Unmarshal(raw["id"], &u.ID)
delete(raw, "id")
u.Extra, _ = json.Marshal(raw)
json/v2 的 json:",unknown" 会少写一遍 delete(Go 1.25 实验)。样板少了,废弃和迁移还在。mapstructure ",remain" 只管 map→struct,不管往返。整类型当 map 再写 GetID():编译期类型没了,断言失败要到线上才看见。
数字精度和 null
解进 interface{} 的 JSON number 是 float64(官方文档)。整数精确范围到 2^53。雪花 ID、int64 订单号过线就会变。{"uid": 1234567890123456789} 进 Extra 再出来,已经不是原来那个数。透传用 json.Decoder.UseNumber(),或直接 RawMessage 别碰数值。
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
dec.Decode(&raw) // 数字是 json.Number,仍是字符串形态
另外两处:
"vip": null进 Extra 是nil;字段根本没有,Extra 里就没有这个 key。选一个约定:保留null当显式清空,或当没这个字段。两边混读会歧义。- Extra 里的对象是
map[string]any,数组是[]any。访问再断言,不要在 Unmarshal 里提前猜业务类型。
测试至少覆盖这些,缺一条都不算 round-trip 过了:只有已知字段、只有未知字段、混合(这份 u001)、嵌套对象 / 数组、null、空对象 {}、错误类型的已知字段("id": 123)、Marshal → Unmarshal → Marshal 语义一致。
请求级和 checkpoint
CRUD 字段一年改几次。Agent 状态每次加工具、换记忆格式、塞一个 tmp.debug,checkpoint 里就会多一个键。过两周没人读了,Redis 里还在。一次运行的中间态要能恢复,恢复时跑的已经是下一版代码。LangGraph 也画过兼容矩阵:加带默认值的字段相对安全,改名等于丢数据(schema change matrix)。
单纯 Extra 在这里容易变成垃圾场:谁加的、还用不用、能不能删,map 回答不了。没有版本号,迁移脚本也不知道从哪一版迁到哪一版。
| 普通业务 | 会进 checkpoint 的状态 | |
|---|---|---|
| 变化频率 | 低~中 | 高 |
| 废弃 | 偶尔 | 实验字段用完就废 |
| 持久化寿命 | 请求级 | checkpoint / Redis / 跨版本恢复 |
| 兼容 | 主要向后 | 向前 + 向后 + 跨版本互通 |
别的系统也碰到过同一件事。Protobuf 二进制默认留 unknown fields;ProtoJSON 不留(protobuf JSON),走 JSON 就得自己做 Extra。Google AIP-180 同大版本只许加性演进。K8s CRD 的 x-kubernetes-preserve-unknown-fields 是平台侧的同一问题。
请求级透传:自定义 Marshal/Unmarshal + delete 已知键就够。状态会跨版本恢复时,再加四块:
type AgentState struct {
SchemaVersion int `json:"schema_version"`
AgentID string `json:"agent_id"`
Status string `json:"status"`
Extra map[string]any `json:"-"`
Legacy map[string]any `json:"-"`
}
访问走封装,少直接 state.Extra["x"]:
func (s *AgentState) Get(key string) (any, bool) {
if v, ok := s.Extra[key]; ok {
return v, true
}
v, ok := s.Legacy[key]
return v, ok
}
func (s *AgentState) Set(key string, v any) {
if s.Extra == nil {
s.Extra = map[string]any{}
}
s.Extra[key] = v
delete(s.Legacy, key) // 重新启用的字段离开废弃桶
}
Getter 先 Extra 再 Legacy。Setter 只写 Extra,并让 Legacy 让路——一个字段不宜同时「在用」和「已废弃」。
同一套 round-trip,换成会进 checkpoint 的形状:
{
"schema_version": 1,
"agent_id": "agt_01",
"status": "running",
"old_memory_format": {"notes": ["..."]},
"tmp_debug": true,
"tool.web.search": {"hits": 3}
}
agent_id/status:Core,长期稳定tool.web.search:Extra,还在用,未稳到改结构体old_memory_format:Legacy,不该再写,读路径还要兼容tmp_debug:排障残留SchemaVersion:迁移开关;没有它,migrateIfNeeded不知道这份状态是哪一版的产物
字段淘汰
新增 → 使用 →(可选)提升 Core → 停写进 Legacy → 升版本删除。软废弃先于物理删除。
Unmarshal 后两步钩子:先把已知废弃键从 Extra 搬到 Legacy,再按版本号迁移。
func (s *AgentState) UnmarshalJSON(data []byte) error {
raw := map[string]any{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
s.SchemaVersion = asInt(raw["schema_version"], 1)
s.AgentID = asString(raw["agent_id"])
s.Status = asString(raw["status"])
delete(raw, "schema_version")
delete(raw, "agent_id")
delete(raw, "status")
s.Extra = raw
s.normalizeDeprecated()
return s.migrateIfNeeded()
}
func (s *AgentState) normalizeDeprecated() {
for _, k := range []string{"old_memory_format", "tmp_debug"} {
if v, ok := s.Extra[k]; ok {
if s.Legacy == nil {
s.Legacy = map[string]any{}
}
s.Legacy[k] = v
delete(s.Extra, k)
}
}
}
func (s *AgentState) migrateIfNeeded() error {
switch s.SchemaVersion {
case 1:
if old, ok := s.Get("old_memory_format"); ok {
s.Extra["memory"] = old
delete(s.Legacy, "old_memory_format")
}
s.SchemaVersion = 2
}
return nil
}
v1 样本解完:SchemaVersion 变成 2,memory 在 Extra,old_memory_format 不再作为当前扩展出现。tmp_debug 还在 Legacy——还没到删除窗口。
真正删除前看四条:线上已无该字段的写入;历史数据已迁移或确认可丢;观察窗口过了(按大版本计);再 bump SchemaVersion,迁移函数里物理删掉。
Marshal 仍输出 Core + 当前 Extra + 仍需兼容的 Legacy。窗口过了,输出里就不再出现。Status 若一开始在 Extra,观察两个版本都稳定、多个模块都在读,再发版升进 Core。旧 checkpoint 没有这个字段时,新代码给默认值。
命名可以先分开:tool.* 当前能力,tmp.* 临时,x_* 实验。定期扫 tmp.* 和 Legacy。某个 key 连续两个版本读次数为零,再进删除候选。Extra 字节数也要看——一条状态 2MB,多半是某次把工具原始响应整包塞进去了。
跨版本测试单独一条:用 v1 JSON 喂 v2 代码,断言 memory 在、old_memory_format 不在 Extra。只测当前形状不够。
存储选文档库或 JSONB。关系库少把 Extra 拆列——列名会变成第二种 schema。
适用边界
- 请求级对象:自定义 Marshal/Unmarshal,delete 已知字段。不删,
id会进 Extra,审计把契约字段当扩展。 - 状态会进 checkpoint:再加
SchemaVersion+ Extra + Legacy。没有版本号,v1 的old_memory_format喂给 v2 只能丢或脏读。json/v2 的unknown少写样板,替代不了 bump 版本。
标准库不承诺保留未知字段。承诺是自己写的。