一例Go语言解析JSON引发的BUG
涛叔今天有同事反馈说某接口的的签名计算有误。而且比较签名的代码也写错了,结果负负得正,系统居然带病运转了达五年之久。作为代码提交者我深感愧疚。痛定思痛,发现该问题跟 Go 语言的 JSON 解析行为有关。今天就把相关经验分享给大家。
业务接口是给其他部门调用的。他们会发来一个 JSON 字符串。这个 JSON 是一个 map,键的类型为 string,值可能是任意类型。一个典型的数据如下:
{
"c": "c",
"b": 0.5,
"a": 1,
"sign": "..."
}
签名规则如下:
- 将所有 k-v 转换成 k=v 形式的字符串列表
- 将字符串列表按 k 升序排列
- 用
&
将字符串列表连接起来 - 在最后追加
&token=$token
- 最后计算整个字符串的 MD5 值,填充到 sign 字段
假设给定的 token 为 foo
,则上面的 JSON 签名如下:
MD5("a=1&b=0.5&c=c&token=foo")
为了验证签名,我在 Go 代码中声明如下 map:
var d map[string]interface{}
值的类型为interface{}
,所以才能保存数字、字符串等各种类型的数据。JSON 解析之后,构建需要的字符串:
:= d["sign"]
sig1 delete(d, "sign")
:= []string{}
ks for k, v := range d {
= append(ks, k+"="+fmt.Sprint(v))
ks }
.Strings(ks)
sort
:= strings.Join(ks, "&") + "&token=foo"
str := md5.Sum([]byte(str))
s := hex.EncodeToString(s[:]) sig2
这样通过比较sig1
和sig2
就能判断签名是否正确。So far so good!
然而现实情况比较特别。调用方发过来的 JSON 中包含一个整数,超过了 float64 所能精确表示的范围,比如3031879057314906112
。
如果我们用上面的代码解析,实际参与签名的字符串为3.031879057314906e+18
😂
var n float64
.Unmarshal([]byte("3031879057314906112"), &n)
json.Println(n) // 3.031879057314906e+18 fmt
计算出来的签名也就不可能对了。
问题的根源在于如果不显式指定值的类型,Go语言默认使用 float64 来保存 JSON 中的数字。其实这也不能怪 Go 语言,因为在 JavaScript 中根本没有整数,所有数字都是浮点数,整数的最大的最大值为。超过这个值就只能近似表示了。
这种限制通常仅限于浏览器环境。但 Go 为了兼容浏览器,也默认遵守这一约定。
但在服务端有很多场景希望使用 JSON 传输完整的Int64
范围,所以 jgold.bg 在 2012 年给 Go 添加了UseNumber
API1,用来指定使用Int64
保存整数2。
:= map[string]any{}
kv := json.NewDecoder(...)
d .UseNumber()
d.Decode(&kv) d
这样操作就不会损失大整数精度了。
很好,准备上线。但上线之前我把调用接口的情况完整梳理了一遍,发现有四个场景。于是从线上日志系统找到四种对应的 JSON 数据,写成单元测试。果然测试不通过🤦
仔细研究发现,只有一种没通过测试,它对应的 JSON 结构更加复杂:
{
"c": "c",
"b": 0.5,
"a": 1,
"d": [{"b":1,"a":2}],
"sign": "..."
}
大家注意看上例中的d
项,它是一个数组。数组的每个元素又是一个字典。Go 语言解析 JSON 时会把d
的类型指定为[]map[string]int64
。虽然吃上了UseNumber
,整数部分不会再出问题。但是 Go 语言的 map 没有顺序。为了让输出结果保持稳定不变,Go 在做 JSON 序列化的时候会先对 key 做排序。也就是说 Go 语言输出的 JSON 结果中 map 的 key 始终是排好序的。
从语义上讲,map 的 key 本来就不应该有顺序。但我们计算签名必须按照某种顺序,不然对方无法验证。因为某个业务调用的时候 map 的 key 没有排序,而 Go 在编码的时候又做了排序,这就再次导致签名计算错误🙅
怎么解决这个问题呢?首先想到的办法是定义一个struct
,每个字段一个 key,并显式指定每一个字段的类型。对于 map 这一类的数据使用特殊的序列化方法,保持原有的顺序。
我们先不考虑如何实现顺序保持逻辑(其实也不容易)。这种方案最大的弊端就是无法方便地增加字段。后续如果调用方加了新字段,验签就会失败。这绝不能接受。
思忖再三,最终还是祭出json.RawMessage
大招。
对于签名算法而言,我们关心的只是外层 key 的顺序。至于 value 是什么类型还是什么取值不太重要。最好是在拼装k=v
时直接使用原始的 value 值。这正是 json.RawMessage
的用武之地。
:= map[string]json.RawMessage{}
kv
.Marshal(buf, &kv) json
解析出来的结果如下:
{
"a": []byte(`1`),
"d": []byte(`[{"b":1,"a":2}]`),
"c": []byte(`"c"`),
"b": []byte(`0.5`),
}
这里唯一需要注意的就是c
项。因为它的类型是字符串,所以对应的值两边加了引号。我们在计算签名的时候不需要这对引号,需要去除。这可以使用strconv.Unquote
函数实现。
经过以上两番操作,最终解决了签名计算问题。有问题的代码写自 2018 年。那个时候我刚转 Go 语言,对各方面的理解还不够,犯下不可饶恕的错误😂希望大家不要走我的老路。