jinhailang / blog

技术博客:知其然,知其所以然
https://github.com/jinhailang/blog/issues
60 stars 6 forks source link

json.Unmarshal 奇怪的坑 #50

Open jinhailang opened 5 years ago

jinhailang commented 5 years ago

encoding/json 是 Go 代码经常使用的包,但是,可能很多人都会忽略下面这段说明:

To unmarshal JSON into an interface value, Unmarshal stores one of these in the interface value:

bool, for JSON booleans
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null

当 json 解码到 interface 类型的变量值时,会将 JSON numbers(实质是 string 类型,表示整数或浮点数数字字符串)都当作类型 float64 存储。

试想以下代码输出?

package main

import (
    "fmt"
    "encoding/json"
    "reflect"
)

func main() {
    s := `{"name":"test","it":1021,"timestamp":1557822591000,"mmp":{"a":"ax","b":999999}}`
    type st struct{
        Name string
        Timestamp interface{}
        Mmp interface{}
        It interface{}
    }
    var tmp st

    err := json.Unmarshal([]byte(s),&tmp)
    fmt.Printf("tmp: %+v, err: %v\r\n",tmp, err)

    fmt.Printf("%v, %v\r\n",reflect.TypeOf(tmp.Timestamp), tmp.Timestamp)
}

tmp: {Name:test Timestamp:1.557822591e+12 Mmp:map[a:ax b:999999] It:1021}, err: <nil>
float64, 1.557822591e+12

完成 Json 解码后,Timestamp 类型为 float64。这显然是无法让人接受的,就这里来说,时间戳应该是 int64 才对。目前,有两个解决办法:

  1. 显示声明类型

避免使用 interface,而是直接静态类型指定,在大多数情况下,Json 字符串结构都是已知的,静态的。 上面的场景,就可以将时间戳属性定义为 Timestamp int64

  1. 使用函数 UseNumber()

func (*Decoder) UseNumber() 使解码器将数字作为 json.Number 类型, 而不 float64 解码到 interface 变量。

    ...

    ds := json.NewDecoder(strings.NewReader(s))
    ds.UseNumber()
    err := ds.Decode(&tmp)

    fmt.Printf("tmp: %+v, err: %v\r\n",tmp, err)

    //rf, _ := strconv.ParseFloat("123.90",64)
    fmt.Printf("%v, %v\r\n",reflect.TypeOf(tmp.Timestamp), tmp.Timestamp)

tmp: {Name:test Timestamp:1557822591000 Mmp:map[a:ax b:999999] It:1021}, err: <nil>
json.Number, 1557822591000

可以看到,json.Number 其实就是字符串类型:

type Number string

因此,这里其实就是保留原始字符串,延迟解析。在需要的时候,使用提供的函数 Float64(), Int64() 等转化成对应的类型,其实,这些函数的实现就是使用 strconv 包将字符串转化成整型或浮点型。

但是,这里引入了一个新的类型 json.Number,会侵入到别的无关的代码中,也就是说,可能会导致,在其它模块,不得不在类型判断时,加入 json.Number case。这种耦合是比较让人难受的。

遗憾的是,目前看来,只有这两种方式了,虽然都不够优雅。

这是个很奇怪的问题,因为技术上来说,将数字字符串分别解析为整型或浮点型并不难实现,Go 编译器就很好的实现了(想想 x:=100x:=100.0 的区别); 而且,如果 Json 数字的含义是整型,默认却解析成 float64 就会有精度丢失的问题,因为 int64 比 float64 表示的范围更大。

Go issues 找了下,也并没有看到合理的解释,难道只是为了实现方便,偷了个懒?真是个奇怪的坑!

相关 issues:

补充

感谢Go 论坛网友 @h12 的指正:

float64 的表示范围显然远大于int64/uint64 (ref. math. MaxFloat64, math. MaxInt64),只是在表示整数时有可能有精度损失。 JSON (json.org)并没有规定number的精度和大小范围,所以即使用uint64或int64,在解析整数时仍然存在溢出的可能。这时如果用float64来解析,因为表示范围大于int64,溢出的可能性更小,所以更安全(精度损失总比溢出强)。如果追求完全不溢出,可以用 type Number string。

Go 中的 float64 其实等同于 double 类型:

1bit(符号位) 11bits(指数位) 52bits(尾数位)

范围是 -2^1024 ~ +2^1024,也即 -1.79E+308 ~ +1.79E+308。精度是由尾数的位数来决定的。浮点数在内存中是按科学计数法来存储的,其整数部分始终是一个隐含着的“1”,由于它是不变的,故不能对精度造成影响:2^52 = 4503599627370496,一共16位,因此,double的精度为15~16位。而 int64 等同于 long, 占8个字节,表示范围: -9223372036854775808 ~ 9223372036854775807

因此,出现这个坑的原因,是设计上的取舍,为了保证 Json 数字解析安全(不溢出),只能牺牲精度。

michaelkidpro commented 3 years ago

可以把时间戳 都转成string,再json序列化。 json转map后也时间戳也是string