登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go encoding/json/v2 怎么为同一字段同时兼容数字和字符串

来源:17golang原创

时间:2026-10-05 07:42:06 170浏览 收藏

我第一次碰到这个问题,是接一个历史订单接口:同一个 order_id,旧服务返回 42,新服务却返回 "42"。结构体字段如果写成 int64,字符串输入会失败;如果写成 string,数字输入又失败。把字段改成 any 虽然能解码,却把类型判断推到了每一个调用点。

在 Go 1.27 的 encoding/json/v2 中,稳妥做法是给这个字段定义一个局部整数类型,并实现自定义解码:只接受 JSON number 和“内容为整数的 JSON string”,最终都保存为 int64。官方包文档:https://pkg.go.dev/encoding/json/v2

先记住三个判断
  • json:",string" 表示字段按“字符串化数字”解码,不等于同时接受数字与字符串。
  • 兼容规则应放在字段类型边界,不能把业务字段长期降级成 any。
  • 放宽表示形式时仍要严格限制数值语法、整数范围和允许的 JSON kind。
大家做Go开发对接外部接口的时候经常碰到这类情况:上游返回的字段有时候是数字格式,有时候是用引号包起来的字符串格式,用标准库的json/v2直接反序列化很容易报错,不用手动逐个写解析逻辑,用库自带的扩展能力就能实现同一字段自动兼容两种格式。
直接给可落地的方案:用json/v2的自定义类型实现UnmarshalJSON接口,或者通过调用WithDecodeLegacyNumbers配置搭配指定字段类型,就能不用额外手写逐字符解析的逻辑,同时兼容数字和字符串两种输入格式,序列化输出也能按需控制最终字段格式。

先复现:字段类型只能对应一种默认表示

先看最小结构体。ID 是领域里的整数,所以自然会写成 int64:

package main

import json "encoding/json/v2"

type Request struct {
	ID int64 `json:"id"`
}

func decode(data []byte) (Request, error) {
	var req Request
	// 默认映射只接受 JSON number 形式的整数
	err := json.Unmarshal(data, &req)
	return req, err
}

{"id":42} 可以进入 int64,而 {"id":"42"} 的 JSON kind 是 string,默认语义不会擅自把它转成整数。这个错误其实是好事:它阻止了布尔值、对象、数组等不相关表示悄悄进入领域模型。

我一开始也想过把字段改成 string,但那只是把失败方向反过来;改成 any 则会让调用方处理 float64、string 和越界问题。真正需要变化的是“输入适配层”,不是业务字段的最终类型。

为什么 string 标签不能解决双形态输入

encoding/json/v2 的 string 字段标签会在该字段上启用 StringifyNumbers。官方文档定义得很明确:本来编码为 JSON number 的类型,会改为 JSON string 中的数字;解码时也从这个字符串化数字读取。

type StringOnlyRequest struct {
	// 这里声明的是“字符串化数字”,不是“数字或字符串二选一”
	ID int64 `json:"id,string"`
}

因此它适合协议已经统一规定 "id":"42" 的场景,尤其是为其他生态保留 64 位整数精度;但当上游同时存在 42 和 "42" 两种历史表示时,单靠标签并不能表达这条兼容规则。全局传入 json.StringifyNumbers(true) 也会递归影响数字,更不适合作为单字段补丁。

用一个局部类型收住两种表示

我最终采用一个底层为 int64 的命名类型。UnmarshalJSON 接收到的是一个完整且语法有效的 JSON 值,可以先用 jsontext.Value.Kind() 判断它是 number 还是 string,再分别解析。

package compat

import (
	"fmt"
	"strconv"
	"strings"

	"encoding/json/jsontext"
	json "encoding/json/v2"
)

type FlexibleInt64 int64

func (n *FlexibleInt64) UnmarshalJSON(data []byte) error {
	raw := jsontext.Value(data)

	switch raw.Kind() {
	case jsontext.KindNumber:
		// 数字分支只接受十进制整数,小数和指数形式会被 ParseInt 拒绝
		value, err := strconv.ParseInt(string(data), 10, 64)
		if err != nil {
			return fmt.Errorf("id 必须是 int64 整数: %w", err)
		}
		*n = FlexibleInt64(value)
		return nil

	case jsontext.KindString:
		var text string
		// 交给 json/v2 正确处理引号和转义字符
		if err := json.Unmarshal(data, &text); err != nil {
			return err
		}
		if strings.TrimSpace(text) != text {
			return fmt.Errorf("id 字符串不能含首尾空白")
		}
		value, err := strconv.ParseInt(text, 10, 64)
		if err != nil {
			return fmt.Errorf("id 字符串必须是 int64 整数: %w", err)
		}
		*n = FlexibleInt64(value)
		return nil

	default:
		// null、布尔值、数组和对象不属于该字段允许的表示
		return fmt.Errorf("id 只接受 JSON number 或数字字符串")
	}
}

这里没有先把输入统一转成字符串再解析,因为 JSON string 需要正确反转义,而 JSON number 不应接受引号规则。按 kind 分支后,允许范围一眼就能看清,也更容易写出准确错误信息。

JSON 数字和字符串通过 FlexibleInt64 解析边界汇入 Request ID 的静态结构说明图
图1:两种 JSON 表示汇入同一领域字段的静态结构说明图,不是运行截图。

这段实现刻意使用 ParseInt(..., 10, 64)。于是 1.5、1e3、空字符串和超出 int64 的值都会失败。兼容“表示不同”不等于放宽“数值含义”。

把兼容范围限制在目标字段

把类型放回请求结构体后,兼容规则只作用于 ID。其他整数字段仍遵守 json/v2 的默认严格映射:

type Request struct {
	ID    FlexibleInt64 `json:"id"`
	Count int64         `json:"count"`
}

func DecodeRequest(data []byte) (Request, error) {
	var req Request
	// 只有 ID 使用双形态兼容;Count 仍只接受 JSON number
	if err := json.Unmarshal(data, &req); err != nil {
		return Request{}, err
	}
	return req, nil
}

这个边界非常实用。上游可以在迁移期发送 {"id":42,"count":3} 或 {"id":"42","count":3},但 count 不会因为 ID 的历史问题也开始接受字符串。

如果响应端要统一输出数字,不必实现自定义 MarshalJSON:FlexibleInt64 的底层类型就是 int64,默认会编码为 JSON number。这样输入可以兼容旧格式,输出却只保留一种规范形式,系统不会继续制造新的混合数据。

把错误边界写进表驱动测试

这种兼容代码最怕“看起来能转”却没有明确拒绝什么。我会把合法输入和错误输入放在同一张表里,判断标准包含两部分:合法值必须得到精确整数,非法值必须返回错误。

package compat_test

import (
	"testing"

	json "encoding/json/v2"
	"example.com/project/compat"
)

func TestFlexibleInt64(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		want    int64
		wantErr bool
	}{
		{name: "数字", input: `{"id":42}`, want: 42},
		{name: "数字字符串", input: `{"id":"42"}`, want: 42},
		{name: "负数字符串", input: `{"id":"-7"}`, want: -7},
		{name: "首尾空白", input: `{"id":" 42 "}`, wantErr: true},
		{name: "小数", input: `{"id":1.5}`, wantErr: true},
		{name: "布尔值", input: `{"id":true}`, wantErr: true},
		{name: "越界", input: `{"id":"9223372036854775808"}`, wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var got struct {
				ID compat.FlexibleInt64 `json:"id"`
			}
			err := json.Unmarshal([]byte(tt.input), &got)
			if (err != nil) != tt.wantErr {
				t.Fatalf("Unmarshal() error = %v, wantErr = %v", err, tt.wantErr)
			}
			if !tt.wantErr && int64(got.ID) != tt.want {
				t.Fatalf("ID = %d, want %d", got.ID, tt.want)
			}
		})
	}
}
合法样例、拒绝样例、表驱动测试与回归边界的静态说明图
图2:兼容输入与严格拒绝规则的静态测试边界说明图,不是运行截图。

还可以按业务补充 null、空字符串、前导加号和前导零。是否允许它们不是 JSON 库替你决定的;兼容层应把协议约束写成显式代码和测试,而不是依赖 strconv 的偶然行为。

什么时候改用 WithUnmarshalers

encoding/json/v2 还提供 WithUnmarshalers 与 UnmarshalFunc,允许调用方覆盖特定 Go 类型的解码行为。这在你无法修改第三方类型,或者某个 API 客户端需要统一适配时很有用。

但如果直接为 *int64 注册自定义函数,规则可能递归影响这次解码中的所有 int64,范围比“只兼容 ID 字段”更大。我的经验是:单个或少量字段优先使用命名类型;只有当兼容策略本来就是调用级策略时,才使用 WithUnmarshalers,并为影响范围单独写回归测试。

官方文档也建议自定义类型在需要更高性能与流式能力时实现 UnmarshalJSONFrom(*jsontext.Decoder)。普通请求对象先用 UnmarshalJSON([]byte) 更容易读懂;确实遇到热点路径,再迁移到 Decoder 接口,避免为了理论性能提前增加复杂度。

上线前检查这六项

  • 确认运行环境为 Go 1.27 或更高版本,并已切换到 encoding/json/v2。
  • 确认 string 标签没有被误当成“双形态输入”开关。
  • 兼容类型只放在确实存在历史混合数据的字段上。
  • 明确是否允许负数、前导零、空白、null、小数和指数形式。
  • 解析时检查 int64 越界,不要先经过 float64。
  • 输入兼容两种表示时,输出固定为一种规范表示,避免继续扩散脏格式。

Go 官方迁移指南提醒,v2 采用了更严格、更具互操作性的默认行为,迁移重点不只是改 import,还要测试行为差异。这个字段兼容就是典型例子:不要用一个全局宽松选项掩盖协议混乱,而应在最小边界内接住旧数据。

相关问题

json:",string" 能同时接受 42 和 "42" 吗?

不能把它理解为二选一。该标签声明字段使用字符串化数字表示,适合协议固定为 "42" 的情况;混合输入需要自定义解码。

为什么不用 any 接收后再 type switch?

any 会让字段失去整数语义,并把转换与错误处理分散到业务代码。命名类型能在一次解码中完成规范化,调用方始终看到整数。

数字字符串里有空格要不要接受?

除非协议明确允许,否则建议拒绝。自动 TrimSpace 会隐藏上游数据质量问题;如果必须兼容,应记录指标并规划下线时间。

大整数为什么不能先解成 float64?

float64 无法精确表示所有 64 位整数。直接对 JSON number 文本或字符串内容调用 strconv.ParseInt,可以保留精度并检测越界。

总结:同一字段在数字和字符串之间摇摆时,最干净的修复不是 any,也不是全局放宽解码,而是给目标字段一个命名整数类型。按 JSON kind 接受两种表示、用 ParseInt 统一成 int64、用表驱动测试固定拒绝边界,再让输出只保留数字形式,兼容成本就会被限制在一个可维护的位置。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>