一例Go语言解析JSON引发的BUG

2023-04-26 ⏳3.3分钟(1.3千字) g

今天有同事反馈说某接口的的签名计算有误。而且比较签名的代码也写错了,结果负负得正,系统居然带病运转了达五年之久。作为代码提交者我深感愧疚。痛定思痛,发现该问题跟 Go 语言的 JSON 解析行为有关。今天就把相关经验分享给大家。

业务接口是给其他部门调用的。他们会发来一个 JSON 字符串。这个 JSON 是一个 map,键的类型为 string,值可能是任意类型。一个典型的数据如下:

{
  "c": "c",
  "b": 0.5,
  "a": 1,
  "sign": "..."
}

签名规则如下:

假设给定的 token 为 foo,则上面的 JSON 签名如下:

MD5("a=1&b=0.5&c=c&token=foo")

为了验证签名,我在 Go 代码中声明如下 map:

var d map[string]interface{}

值的类型为interface{},所以才能保存数字、字符串等各种类型的数据。JSON 解析之后,构建需要的字符串:

sig1 := d["sign"]
delete(d, "sign")

ks := []string{}
for k, v := range d {
  ks = append(ks, k+"="+fmt.Sprint(v))
}
sort.Strings(ks)

str := strings.Join(ks, "&") + "&token=foo"
s := md5.Sum([]byte(str))
sig2 := hex.EncodeToString(s[:])

这样通过比较sig1和sig2就能判断签名是否正确。So far so good!

然而现实情况比较特别。调用方发过来的 JSON 中包含一个整数,超过了 float64 所能精确表示的范围,比如3031879057314906112。

如果我们用上面的代码解析,实际参与签名的字符串为3.031879057314906e+18😂

var n float64
json.Unmarshal([]byte("3031879057314906112"), &n)
fmt.Println(n) // 3.031879057314906e+18

计算出来的签名也就不可能对了。

问题的根源在于如果不显式指定值的类型,Go语言默认使用 float64 来保存 JSON 中的数字。其实这也不能怪 Go 语言,因为在 JavaScript 中根本没有整数,所有数字都是浮点数,整数的最大的最大值为2532^53。超过这个值就只能近似表示了。

这种限制通常仅限于浏览器环境。但 Go 为了兼容浏览器,也默认遵守这一约定。

但在服务端有很多场景希望使用 JSON 传输完整的Int64范围,所以 jgold.bg 在 2012 年给 Go 添加了UseNumberAPI1,用来指定使用Int64保存整数2。

kv := map[string]any{} 
d := json.NewDecoder(...)
d.UseNumber()
d.Decode(&kv)

这样操作就不会损失大整数精度了。

很好,准备上线。但上线之前我把调用接口的情况完整梳理了一遍,发现有四个场景。于是从线上日志系统找到四种对应的 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 的用武之地。

kv := map[string]json.RawMessage{}

json.Marshal(buf, &kv)

解析出来的结果如下:

{
  "a": []byte(`1`),
  "d": []byte(`[{"b":1,"a":2}]`),
  "c": []byte(`"c"`),
  "b": []byte(`0.5`),
}

这里唯一需要注意的就是c项。因为它的类型是字符串,所以对应的值两边加了引号。我们在计算签名的时候不需要这对引号,需要去除。这可以使用strconv.Unquote函数实现。

经过以上两番操作,最终解决了签名计算问题。有问题的代码写自 2018 年。那个时候我刚转 Go 语言,对各方面的理解还不够,犯下不可饶恕的错误😂希望大家不要走我的老路。