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。
直接给可落地的方案:用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 分支后,允许范围一眼就能看清,也更容易写出准确错误信息。

这段实现刻意使用 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)
}
})
}
}

还可以按业务补充 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、用表驱动测试固定拒绝边界,再让输出只保留数字形式,兼容成本就会被限制在一个可维护的位置。
-
860 收藏
-
843 收藏
-
826 收藏
-
809 收藏
-
792 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习