返回归档
🧩Golang

Go 扩展字段最佳实践

未知字段默认被丢。自己写 Marshal/Unmarshal,delete 已知键,Extra 才干净。会进 checkpoint 的状态再加版本号和 Legacy。

文章目录

标准库默认丢掉未知 JSON 字段。要透传,就自己 round-trip,并且 delete 已知键。

读完可以做两件事:给结构体加上 Extra 和一对 Marshal/Unmarshal;如果这份状态还会写进 checkpoint,再加 SchemaVersion 和 Legacy。

拿一份 JSON:

{"id":"u001","name":"Alice","age":28,"vip":true,"tags":["new"]}

结构体只有 IDNameencoding/json 解完,ageviptags 没了。再 Marshal 也回不来。这不是实现 bug——文档写得很清楚:未知 object key 默认忽略

服务端先加了 vip,旧客户端还不认识;网关要把整包转发出去。这两种场景都需要「结构体是 schema 的子集,其余原样留下」。Go 默认不提供这条路径。

标准库怎么处理未知字段

忽略是一端。DisallowUnknownFields 是另一端:遇到未知 key 报错。两端都不会把 age 留下来。

默认 Unmarshal 只写下 id / name,age vip tags 进丢弃

直接 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 是进程里的桶,不是线上那个键。

线协议上是平铺对象;进程里才拆成 Core 和 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。先删就取不到。

整包进 map,提取 id / name,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"},
}

不 delete 时 Extra 含 id;delete 之后 Extra 只剩 age vip tags

后面几件事会一起坏:

  • for k := range u.Extra 做审计或透传时,会把 id 当扩展字段。
  • 业务改了 u.ID = "u002",忘了改 u.Extra["id"],下次合并听哪边取决于 Marshal 的 if _, exists
  • Extra 里还能读到 id,容易以为扩展字段也可以叫这个名字;ID 改成 UserID 时,拷贝那份不会跟着改。

这是职责问题。Extra 用来收扩展;不 delete,它只是全部字段的拷贝。

Marshal

默认优先级:契约字段赢,扩展字段让。这是设计选择,不是标准库规定。

Core 先写 id / name;Extra 只补缺;name=Bob 丢弃

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 已知键就够。状态会跨版本恢复时,再加四块:

未知 JSON 进 Extra;废弃搬到 Legacy;稳定后提升 Core;Version 触发迁移

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 版本。

标准库不承诺保留未知字段。承诺是自己写的。