在 Go 中安全地 json.Marshal 序列化 int64 等数值类型

陪她去流浪 桃子 2020年08月23日 阅读次数:9879

Javascript 的基础类型(POD)和 JSON 里面其实就只有一种数值类型:Number。 Number 在主流浏览器实现上通常采用 IEEE-754 中的 64-bit 浮点数标准表示(即双精度浮点数),其表示的有效数字的数值的范围$-(2^{53} - 1)$ ~ $2^{53} - 1$ 之间。 而在 Go 语言中经常使用 64-bit 数据类型,比如:int64/uint64,这样的数值是不能安全地在 Javascript 中使用的。

如果查看 JSON 的规范文档的话,数值是没有限制位数的,任意大的数值都可以放在 JSON 中。

Go 语言中 json 大数的行为

实际用 Go 语言的 json 包来测试,它也确实会直接输出非常大的数:

package main

import (
	"encoding/json"
	"fmt"
	"math"
	"math/big"
)

func b2s(b []byte, e error) (string, error) {
	return string(b), e
}

type S struct {
	N *big.Int
}

func main() {
	var i int64 = math.MaxInt64
	fmt.Println(b2s(json.Marshal(i)))

	bi := (&big.Int{}).Mul(big.NewInt(math.MaxInt64), big.NewInt(math.MaxInt64))
	fmt.Println(b2s(json.Marshal(S{N: bi})))
}

输出结果:

9223372036854775807 <nil>
{"N":85070591730234615847396907784232501249} <nil>

这几个数明显大于了 $2^{53}$,Go 的 json 包依然把它们直接编码在了 JSON 中,这是符合规范的。

Javascript 中的安全的数

上面的数值,在 Javascript 中,它们并不是安全的数:

>> Number.isSafeInteger(9223372036854775807)
<< false

>> Number.isSafeInteger(85070591730234615847396907784232501249)
<< false

>> JSON.parse('9223372036854775807')
<< 9223372036854776000

可以看到,前两句输出为 false;第三句 parse 出来的结果直接就不等于原本的数了。

一个数在 Javascript 中不安全即意味着数据错误、精度丢失、运算错误。

再比如下面这个 MDN 官方的示例

const x = Number.MAX_SAFE_INTEGER + 1;
const y = Number.MAX_SAFE_INTEGER + 2;

console.log(Number.MAX_SAFE_INTEGER);
// expected output: 9007199254740991

console.log(x);
// expected output: 9007199254740992

console.log(y);
// expected output: 9007199254740992

console.log(x === y);
// expected output: true

在 Go 中使用字符串序列化大数

虽然 JSON 本身支持任意大小的数,但是 JSON 的实现却未必支持。比如浏览器中的 JSON 对象就不支持。 所以,为了跨语言、跨平台、安全地使用 JSON 作为数据交换格式,像是 Go 语言中的 int64 这种类型的数据,应该使用字符串来编码。

很显然,Go 语言肯定早就考虑到了这一点:我们只需要要在结构体字段的 JSON TAG 中加上一个 string 即可:

package main

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

type S struct {
	A int64 `json:"a,string"`
}

func main() {
	s1 := S{A: math.MaxInt64}
	b, _ := json.Marshal(s1)
	fmt.Println(string(b))

	s2 := S{}
	json.Unmarshal(b, &s2)
	fmt.Println(s2)
}

输出结果:

{"a":"9223372036854775807"}
{9223372036854775807}

可以看到,数值被编码成了字符串,并且在反序列化的时候也正确。

有一点比较遗憾的是,这个 string TAG 只针对以下几种类型:字符串、浮点数、整数、布尔。

所以像是以下类型中的N是不会被字符串化的,需要自己做(本例未做):

package main

import (
	"encoding/json"
	"fmt"
	"math"
	"math/big"
)

type S struct {
	A int64    `json:"a,string"`
	N *big.Int `json:"n,string"`
}

func main() {
	s1 := S{
		A: math.MaxInt64,
		N: (&big.Int{}).Mul(big.NewInt(math.MaxInt64), big.NewInt(math.MaxInt64)),
	}
	b, _ := json.Marshal(s1)
	fmt.Println(string(b))

	s2 := S{}
	json.Unmarshal(b, &s2)
	fmt.Println(s2)
}

输出:

{"a":"9223372036854775807","n":85070591730234615847396907784232501249}
{9223372036854775807 85070591730234615847396907784232501249}

反序列化到 interface{}

另外还有一个反序列化的问题值得一提:当使用 interface{} 作为要反序列化的目标存储时,数值类型是 float64。 这就像是浏览器的行为,同样可能会导致数据丢失(float64 不足以容纳 int64):

package main

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

func main() {
	var j = `1234567890777888999`
	var i interface{}

	if err := json.Unmarshal([]byte(j), &i); err != nil {
		panic(err)
	}

	fmt.Println(j, reflect.TypeOf(i), i, int64(i.(float64)))
}

输出结果:

1234567890777888999 float64 1.234567890777889e+18 1234567890777889024

很明显地看到有数据丢失了(原数字有 19 位有效数字,其浮点数只有 16 位有效数字),但是却没有报错。

一种可能的办法是让 json 使用其 json.Number 类型来存储数值:

package main

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

func main() {
	var j = `1234567890777888999`
	var i interface{}

	d := json.NewDecoder(strings.NewReader(j))
	d.UseNumber()

	if err := d.Decode(&i); err != nil {
		panic(err)
	}

	fmt.Println(reflect.TypeOf(i))

	n := i.(json.Number)
	fmt.Println(j)
	fmt.Println(n.Float64())
	fmt.Println(n.Int64())
	fmt.Println(n.String())
}

输出结果:

json.Number
1234567890777888999
1.234567890777889e+18 <nil>
1234567890777888999 <nil>
1234567890777888999

如果这个这个数值太大的话,Int64() 是会报错的,Float64() 不会报错(有点怪异的行为):

package main

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

func main() {
	var j = `123456789012345678901234567890`
	var i interface{}

	d := json.NewDecoder(strings.NewReader(j))
	d.UseNumber()

	if err := d.Decode(&i); err != nil {
		panic(err)
	}

	fmt.Println(reflect.TypeOf(i))

	n := i.(json.Number)
	fmt.Println(j)
	fmt.Println(n.Float64())
	fmt.Println(n.Int64())
	fmt.Println(n.String())
}

输出结果:

json.Number
123456789012345678901234567890
1.2345678901234568e+29 <nil>
9223372036854775807 strconv.ParseInt: parsing "123456789012345678901234567890": value out of range
123456789012345678901234567890

或者,可以简单地判断 Float64() == String()

Protocol Buffers 对 int64 的处理

golang/protobuf 可以看到,Protocol Buffers 对所有的 int64/uint64 默认序列化为字符串:

case int64, uint64:
	w.write(fmt.Sprintf(`"%d"`, v.Interface()))
	return nil

性能问题?

可能有人觉得把数值作为字符串来传输是不是会较大的性能影响?

其实我觉得不然,因为我们在讨论的是 JSON。JSON 中的字符串和数值在什么区别?不过是字符串多两个引号而已。 用字符串表示数值是在内存中占用的内存较多,而在传输层面上来说,仅仅是多两个字节。理论上对性能影响较小。

这篇文章的内容已被作者标记为“过时”/“需要更新”/“不具参考意义”。

标签:JSON · Go · ProtocolBuffer