FunTester Go JSON:从 v1 到 v2

FunTester · 2026年08月28日 · 201 次阅读

Go 1.25 即将带来 json 包的第二个版本。这个版本并非小修小补:它新增了能力,修正了 API 与行为层面的缺陷,也改善了性能。不过,它同时包含不少不兼容变更。

本文按原文梳理 encoding/json/v2 与 v1 的关键差异,重点关注迁移时最容易踩到的接口、默认行为和性能变化。文中的示例可配合原文的交互式环境练习。

基础用法保持不变

最常见的 MarshalUnmarshal 用法在两个版本中保持一致:把结构体编码为 JSON 字节,再把 JSON 解码回结构体。

type Person struct {
    Name string
    Age  int
}

alice := Person{Name: "Alice", Age: 25}

b, err := json.Marshal(alice)
fmt.Println(string(b), err)

err = json.Unmarshal(b, &alice)
fmt.Println(alice, err)

这段代码在 v1 与 v2 中都可以工作。真正需要注意的是,它之外的大部分边缘行为和扩展接口都已经变化。

直接读写 io.Writerio.Reader

在 v1 中,向 io.Writer 编码或从 io.Reader 解码通常要经过 EncoderDecoder

alice := Person{Name: "Alice", Age: 25}

out := new(strings.Builder)
enc := json.NewEncoder(out)
enc.Encode(alice)
fmt.Println(out.String())

in := strings.NewReader(`{"Name":"Bob","Age":30}`)
dec := json.NewDecoder(in)
var bob Person
dec.Decode(&bob)
fmt.Println(bob)

在 v2 中,可以直接使用 MarshalWriteUnmarshalRead,中间对象不再是必需的:

alice := Person{Name: "Alice", Age: 25}

out := new(strings.Builder)
json.MarshalWrite(out, alice)
fmt.Println(out.String())

in := strings.NewReader(`{"Name":"Bob","Age":30}`)
var bob Person
json.UnmarshalRead(in, &bob)
fmt.Println(bob)

这两组接口不能机械替换。MarshalWrite 不会像旧的 Encoder.Encode 一样自动追加换行。相应地,UnmarshalRead 会一直读取到 io.EOF,而旧的 Decoder.Decode 每次只读取下一个 JSON 值。如果调用方依赖逐值处理流式输入,这个差异会直接影响行为。

使用 jsontext 处理流

v2 将 EncoderDecoder 移到新的 jsontext 包,并调整了接口,以支持更底层的流式编解码操作。原来的映射关系如下:

  • v1 的 Encoder.Encode 对应 v2 的 json.MarshalEncodejsontext.Encoder
  • v1 的 Decoder.Decode 对应 v2 的 json.UnmarshalDecodejsontext.Decoder

下面是持续写入多个 JSON 值的示例:

people := []Person{
    {Name: "Alice", Age: 25},
    {Name: "Bob", Age: 30},
    {Name: "Cindy", Age: 15},
}

out := new(strings.Builder)
enc := jsontext.NewEncoder(out)

for _, p := range people {
    json.MarshalEncode(enc, p)
}

fmt.Print(out.String())

逐个读取 JSON 值时,UnmarshalDecode 才是对应接口:

in := strings.NewReader(`
  {"Name":"Alice","Age":25}
  {"Name":"Bob","Age":30}
  {"Name":"Cindy","Age":15}
`)

dec := jsontext.NewDecoder(in)
for {
    var p Person
    err := json.UnmarshalDecode(dec, &p)
    if err == io.EOF {
        break
    }
    fmt.Println(p)
}

UnmarshalDecode 是真正的逐值流式解码:每次调用只解出一个值,不会为了读完输入而等待 io.EOF。 当数据来自长连接、连续记录流或大型输入时,应明确区分它与 UnmarshalRead

用选项控制编解码

v2 将许多可选行为统一为函数参数。常用选项包括:

  • FormatNilMapAsNullFormatNilSliceAsNull:控制 nil map、nil slice 的编码结果
  • MatchCaseInsensitiveNames:允许 Namename 这类字段名忽略大小写匹配
  • Multiline:把 JSON 对象展开成多行
  • OmitZeroStructFields:省略零值结构体字段
  • SpaceAfterColonSpaceAfterComma:在 :, 后加入空格
  • StringifyNumbers:把数值类型编码为字符串
  • WithIndentWithIndentPrefix:控制嵌套属性的缩进

原文特别指出,MarshalIndent 已被移除,缩进通过选项完成:

alice := Person{Name: "Alice", Age: 25}

b, _ := json.Marshal(
    alice,
    json.OmitZeroStructFields(true),
    json.StringifyNumbers(true),
    jsontext.WithIndent("  "),
)

fmt.Println(string(b))

若需要组合多个设置,可以使用 JoinOptions

opts := json.JoinOptions(
    jsontext.SpaceAfterColon(true),
    jsontext.SpaceAfterComma(true),
)

b, _ := json.Marshal(alice, opts)
fmt.Println(string(b))

选项并不都在同一个包中。jsonjsontext 都提供了各自的选项集合,迁移时应按功能查阅对应包的文档。

标签能力扩展

v2 继续支持 v1 的字段标签:omitzeroomitemptystring-。同时新增了几类标签:

  • case:ignorecase:strict:指定字段名大小写差异的处理方式
  • format:template:按模板格式化字段值
  • inline:把嵌套对象的字段提升到父对象
  • unknown:收集未知字段

inlineformat 可以一起使用。例如,日期以 DateOnly 输出,同时把地址字段扁平化到 Person

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,format:DateOnly"`
    Address   `json:",inline"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
}

unknown 则适合演进中的协议。它让结构体接住未声明的字段,而不是把这些数据悄悄丢掉:

type Person struct {
    Name string         `json:"name"`
    Data map[string]any `json:",unknown"`
}

当输入含有 hobbyfriends 等未知字段时,这些内容会进入 Dataunknown 为前向兼容的 JSON 模型提供了一个明确的兜底位置。

自定义编解码

基于 MarshalerUnmarshaler 的基本自定义方式仍然可用。比如,可以把自定义布尔类型编码为

type Success bool

func (s Success) MarshalJSON() ([]byte, error) {
    if s {
        return []byte(`"✓"`), nil
    }
    return []byte(`"✗"`), nil
}

func (s *Success) UnmarshalJSON(data []byte) error {
    *s = string(data) == `"✓"`
    return nil
}

不过,标准库文档建议优先使用流式的 MarshalerToUnmarshalerFrom。对应方法直接面向 jsontext.Encoderjsontext.Decoder

func (s Success) MarshalJSONTo(enc *jsontext.Encoder) error {
    if s {
        return enc.WriteToken(jsontext.String("✓"))
    }
    return enc.WriteToken(jsontext.String("✗"))
}

func (s *Success) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
    tok, err := dec.ReadToken()
    *s = tok.String() == `✓`
    return err
}

如果只想在某个调用点改变类型的编码方式,不必为此定义新类型。 v2 通过泛型 MarshalFuncUnmarshalFunc 提供可组合的自定义处理器:

boolMarshaler := json.MarshalFunc(
    func(val bool) ([]byte, error) {
        if val {
            return []byte(`"✓"`), nil
        }
        return []byte(`"✗"`), nil
    },
)

data, err := json.Marshal(true, json.WithMarshalers(boolMarshaler))
fmt.Println(string(data), err)

与之对应,UnmarshalFunc 可以在解码时把 映射回 bool。对于流式场景,还可以使用 MarshalToFuncUnmarshalFromFunc

多个处理器可以借助 JoinMarshalersJoinUnmarshalers 组合。一个处理器若返回 json.SkipFunc,框架会跳过它并继续尝试下一个处理器,最后仍可回退到默认处理逻辑。

默认行为不再相同

v2 的变化不只发生在 API 上,默认编码和解码行为也与 v1 不同。下面这些差异最值得在迁移测试中显式覆盖。

编码方面:

  • v1 把 nil slice 编码为 null,v2 编码为 [];需要旧行为时使用 FormatNilSliceAsNull
  • v1 把 nil map 编码为 null,v2 编码为 {};需要旧行为时使用 FormatNilMapAsNull
  • v1 把字节数组编码为数字数组,v2 编码为 Base64 字符串;可用 format:arrayformat:base64 标签调整
  • v1 允许字符串中含无效 UTF-8,v2 默认不允许;需要兼容时可使用 AllowInvalidUTF8
type Person struct {
    Name    string
    Hobbies []string
    Skills  map[string]int
    Secret  [5]byte
}

alice := Person{
    Name:   "Alice",
    Secret: [5]byte{1, 2, 3, 4, 5},
}

b, _ := json.Marshal(alice, jsontext.Multiline(true))
fmt.Println(string(b))

上例在 v2 中会把未初始化的 slice、map 输出为 []{},字节数组则会输出为 Base64 字符串。若业务协议仍要求 v1 格式,可以组合 FormatNilMapAsNullFormatNilSliceAsNull,并给数组字段加 json:",format:array"

解码方面,v1 默认忽略字段名大小写,而 v2 默认精确、区分大小写地匹配字段名。例如把 firstname 解码进 FirstName,v2 默认不会匹配;需要旧行为时可传入 json.MatchCaseInsensitiveNames(true),或通过 case 标签逐字段声明。v1 也允许同一个对象中出现重复字段,v2 默认不允许;需要兼容时可使用 AllowDuplicateNames

性能与迁移边界

编码性能方面,v2 与 v1 大体相当:有些数据集更快,有些更慢。解码提升更明显,原文给出的范围是 v2 比 v1 快约 2.7~10.2 倍。

把常规的 MarshalJSONUnmarshalJSON 改为流式 MarshalJSONToUnmarshalJSONFrom 还可能带来额外收益。原文引用 Go 团队的说明:在特定场景中,这类改造可把 O(n²) 的运行时问题降为 O(n);Kubernetes OpenAPI 规范曾出现约 40 倍的提升案例。

性能数据不能替代迁移验证。对已有服务,至少应覆盖:协议中 nil 容器的形态、字节数组表示、字段名匹配规则、重复字段输入,以及一次读取一个值还是读取完整输入的语义。v2 的价值不只是更快,而是让编码规则和兼容策略从隐式默认值变成可显式声明的选择。

使用前的提醒

截至原文所述的 Go 1.25,json/v2 仍是实验性能力,需要在构建时设置 GOEXPERIMENT=jsonv2。其 API 仍可能在后续版本变化。

启用该实验还会让 v1 的 json 包采用新的 JSON 实现。这样既能获得更快的实现,也能使用部分选项来更好地兼容旧的编码与解码行为。

迁移时可以先从边界最清晰的调用开始:明确区分整段读取与流式读取,为外部协议补足兼容选项,再以真实载荷做回归测试。这样才能既利用 v2 的新能力,也避免默认值变化在上线后变成难以定位的协议问题。


收录于 FunTester 原创专题:我的语言迁徙:Java & Groovy & Go

相关阅读:JSON 基础 · JSON 必知必会【PDF+ 视频教程】 · 复杂 JSON 结构创建语法 · 使用 jq 处理 JSON 数据(一) · 有了 Groovy,我们还需要 JsonPath 吗?

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册