原创

Go JSON 大整数变了一位:UseNumber 保留的是什么

日志里收到的编号是9007199254740993,经过一次JSON解码再编码,最后一位却成了2。请求没有报错,字段也没有缺失;丢失发生在动态对象把JSON数字默认放进float64的时候。

Go结构体中的int64字段与map[string]any不是同一种接收合同。前者让解码器直接检查整数类型及范围;后者需要决定数字该以什么Go类型保存。UseNumber改变的是这项动态解码选择,不是为整个系统安装任意精度数学。

先让错误出现在一份没有网络的最小程序里,再把编号的接受规则写出来。这样不会把HTTP校验、字段白名单与数值表示混在一次失败里。

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "regexp"
    "strings"
)

var positiveInteger = regexp.MustCompile(`^[1-9][0-9]{0,18}$`)

func businessID(value any) (int64, error) {
    number, ok := value.(json.Number)
    if !ok || !positiveInteger.MatchString(number.String()) {
        return 0, errors.New("positive plain integer ID required")
    }
    id, err := number.Int64()
    if err != nil {
        return 0, fmt.Errorf("ID outside int64: %w", err)
    }
    return id, nil
}

func main() {
    const raw = `{"id":9007199254740993,"amount":1.25,"large":1e1000}`
    var ordinary map[string]any
    if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &ordinary); err != nil {
        panic(err)
    }
    fmt.Printf("default: %.0f\n", ordinary["id"].(float64))
    decoder := json.NewDecoder(strings.NewReader(raw))
    decoder.UseNumber()
    var document map[string]any
    if err := decoder.Decode(&document); err != nil { panic(err) }
    id, err := businessID(document["id"])
    if err != nil { panic(err) }
    if id != 9007199254740993 { panic("ID changed") }
    fmt.Println("exact ID:", id)
    fmt.Println("amount token:", document["amount"].(json.Number).String())
    _, err = document["large"].(json.Number).Float64()
    fmt.Println("large Float64 rejected:", err != nil)
    for _, token := range []json.Number{"12", "1.0", "1e2", "0", "9223372036854775808"} {
        _, err := businessID(token)
        fmt.Printf("ID token %s accepted: %t\n", token, err == nil)
    }
    encoded, err := json.Marshal(document)
    if err != nil { panic(err) }
    fmt.Println("round trip:", string(encoded))
    var fixed struct { ID int64 `json:"id"` }
    if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &fixed); err != nil { panic(err) }
    fmt.Println("typed ID:", fixed.ID)
}

使用Go 1.24.1,保存为main.go,在同目录创建模块后运行:

go mod init example.com/number-contract
go run .

默认路径打印9007199254740992,UseNumber路径与int64结构体都保留9007199254740993。大指数令Float64转换返回错误;编号样本里只有普通正整数12被接受,1.0、1e2、0和超过int64范围的值都失败。

词法保留,不等于数值已经验证

json.Number保留数字文本,可以把1.25暂时留到字段处理阶段再决定精度。它的Int64与Float64是两种明确转换,仍然可能失败。接收成功却忽略转换错误,往往只是把损坏移动到了后面。

1.0与1e2在数学上可以对应整数值,但本例编号协议不接受小数或指数写法,因此先做普通正整数语法检查,再做范围转换。若产品允许这些写法,需要采用支持相应精确语义的解析方式,不能偷偷转float64再截成int64。

示例将编号限定为1到int64最大值,不包含0。业务若允许0、负数或更大编号,应修改规则与返回类型;尤其不要因为某个正则暂时能匹配20位数字,就以为存储范围也同步扩大了。

从合法JSON解出的Number已经经过数字语法处理,但手工构造 json.Number("abc") 只是转换成这个Go字符串类型,不会当场验证。把它再交给Marshal时可能失败。Number类型本身不能被当成“已经由可信边界审查”的标记。

不要在中途又还原成 float64

最常见的退步发生在通用工具层:外面开启UseNumber,里面为了比较大小却统一调用Float64,然后把结果放回map。只要编号经过这条路径,前面的保留就失去了意义。

另一个入口是日志或转发组件。它们通常不理解字段业务,只想接收再输出一份对象;如果先按float64解码,精度也可能在看似无害的格式整理中丢失。对需要透明转发的内容,应明确是否允许重写数字词法,必要时保留原始JSON片段或采用约定的数据模型。

这份程序重新编码后仍保留大整数和大指数的数字文本,但不承诺原JSON逐字节不变。空白、对象键顺序和转义形式可能改变;若涉及原始报文签名,应保留签名对应的原字节,不能拿解析再编码作为透明副本。

类型已知时,直接表达类型通常更清楚

最后一段结构体没有调用UseNumber,ID仍然正确。UseNumber面向的是解码到接口类型的数字选择,不会把结构体里已经声明的int64字段自动换成Number。

接口字段固定时,明确的结构体能让整数范围错误尽早暴露。动态扩展字段、通用JSON编辑器和协议中转需要延迟判断时,UseNumber才有更直接的价值;它同时要求调用方对数字转换承担责任。

若编号会进入JavaScript客户端,也要考虑客户端的数值模型。Go服务正确保存int64,并不能保证浏览器把JSON数字解出来后仍然精确。把长编号统一作为字符串传输是一种常用合同,但需要接口两端明确采用,不能只在发生精度错误的那一条链路临时补转换。

金额不要借用编号的转换规则

样本把amount也保留成Number,但没有把它转成int64。编号要求普通整数,金额可能允许固定小数位,科学计算又可能需要指数,三个字段不能因为底层都是JSON number就共用一份转换函数。按字段语义决定是否接收小数、范围多大、如何处理精度损失,比统一转成一个“通用数字”更可维护。

大指数同样提醒我们,词法有效不等于后续操作成本可接受。Number暂存1e1000很短,后续若把它展开成普通十进制字符串,输出规模可能增长。接收层仍应限制请求字节、字段数量和业务数值范围;UseNumber只改变表示,不给这些操作自动增加预算。

如果只是判断编号是否相同,应在受验证的整数或规范字符串上比较。9007199254740993和同长度附近的另一个编号,经过不精确浮点转换后可能丧失区分能力。把它们用于缓存键、去重或权限对象定位时,这就不再只是日志显示难看,而是业务身份可能被混淆。

测试里也应保留不能转换的样本。每条成功断言旁边有一条范围或语法失败断言,才能防止后续工具函数为了“兼容更多输入”偷偷放宽合同。异常文本可以变化,接受与拒绝的边界应该稳定。

检查时可以从一份已知会越过二进制浮点精确整数边界的样本开始,逐层核对解码后类型、转换结果与重新输出。只验证123这样的短编号,再多次往返也很难发现真正的问题。

正文到此结束
评论插件初始化中...
Loading...
本文目录