{ "id": 9007199254740993 }在 JavaScript 中,JSON.parse 会得到 9007199254740992 (错误!)。在 Python 或 Java 中,则能正确解析。Go 的 encoding/json (v1) 在处理数字时,若反序列化到 interface{},会 默认将所有 JSON 数字解析为 float64,从而掉入和 JavaScript 同样的精度陷阱。
v1 陷阱: // demo1/main.go package main import ( "encoding/json" "fmt" ) func main() { jsonData := []byte(`{"id": 9007199254740993}`) var data map[string]interface{} json.Unmarshal(jsonData, &data) // id 被解析为 float64,精度丢失! fmt.Printf("v1 with interface{}: %.0f\n", data["id"]) // 输出: 9007199254740992 }v2 行为:Go 1.25版本引入的实验性的 encoding/json/v2 在此行为上与 v1 保持一致,反序列化到 any 时同样默认使用 float64。
var typed struct { ID int64 `json:"id"` } json.Unmarshal(jsonData, &typed) fmt.Println(typed.ID) // 输出: 9007199254740993
拥抱“数字即字符串”:对于所有需要跨语言传递的、可能会超过 2^53 - 1 的整数 ID(如数据库自增 ID),最佳实践是在 API 层面约定俗成地使用字符串类型。
{ "price": 0.1 }Go 的 encoding/json(包括 v1 和实验性的 v2)在反序列化 JSON 时,默认会将带小数点的数字解析为 float64 类型,这使得 Go 程序同样会遇到和 JavaScript 一样的精度问题:
// demo2/main.go package main import ( "encoding/json" "fmt" ) func main() { // 包含浮点数的 JSON jsonData := []byte(`{"price": 0.1}`) // 定义一个结构体,使用 float64 来接收 price 字段 var product struct { Price float64`json:"price"` } // 堆代码 duidaima.com // 反序列化 if err := json.Unmarshal(jsonData, &product); err != nil { panic(err) } // 单独打印时,浮点数通常会以最短、最精确的十进制形式显示 fmt.Println("Parsed price:", product.Price) // 当进行算术运算时,其底层的二进制不精确性就会暴露出来 result := product.Price + 0.2 fmt.Println("product.Price + 0.2 =", result) // 为了对比,直接在 Go 中进行浮点数运算 fmt.Println("0.1 + 0.2 directly in Go =", float64(0.1)+float64(0.2)) }输出:
Parsed price: 0.1 product.Price + 0.2 = 0.30000000000000004 0.1 + 0.2 directly in Go = 0.30000000000000004这个例子清晰地表明,问题不在于 JSON 解析本身,而在于使用 float64 进行后续计算。对于金融、科学计算等要求精确的场景,这种微小的误差累积起来可能是致命的。
使用整数单位:将金额转换为最小单位的整数(如“分”)进行传输和计算,例如用 1999 代表 19.99元。这是在工程实践中非常常见且高效的解决方案。
// demo3/main.go package main import ( "fmt" "golang.org/x/text/unicode/norm" ) func main() { name1 := "José" name2 := "Jose\u0301" fmt.Println(name1 == name2) // 输出: false // 使用 NFC 形式进行规范化后再比较 fmt.Println(norm.NFC.String(name1) == norm.NFC.String(name2)) // 输出: true }陷阱四:对象键序 —— 加密签名的噩梦
// demo4/main.go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" ) func main() { // --- 问题场景 --- // 假设两个不同的系统需要对相同的业务数据进行签名。 // 系统 1: 一个 JavaScript 服务,它保留了对象属性的插入顺序。 // 注意键的顺序: "currency" 在前, "amount" 在后。 jsONString := `{"currency":"USD","amount":100}` fmt.Printf("JSON from JS-like system: %s\n", jsONString) // 系统 2: 一个 Go 服务,它序列化一个 map。 data := map[string]interface{}{ "currency": "USD", "amount": 100, } // Go 的 json.Marshal 会对 map 的键按字母顺序排序。 // 因此 "amount" 会排在 "currency" 前面。 goJSONBytes, _ := json.Marshal(data) goJSONString := string(goJSONBytes) fmt.Printf("JSON from Go system (map): %s\n", goJSONString) // --- 导致的后果: 加密签名失败 --- secret := []byte("my-super-secret-key") // 为 JS 风格的 JSON 字符串计算 HMAC hmacJS := calculateHMAC(secret, []byte(jsONString)) fmt.Printf("HMAC for JS JSON: %s\n", hmacJS) // 为 Go 生成的 JSON 字符串计算 HMAC hmacGo := calculateHMAC(secret, goJSONBytes) fmt.Printf("HMAC for Go JSON: %s\n", hmacGo) // 比较两个签名 signaturesMatch := hmac.Equal([]byte(hmacJS), []byte(hmacGo)) fmt.Printf("\nDo the signatures match? %t\n", signaturesMatch) if !signaturesMatch { fmt.Println("Authentication Fails! The byte representations were different.") } } // calculateHMAC 是一个辅助函数,用于计算并编码 HMAC 值 func calculateHMAC(secret, data []byte) string { h := hmac.New(sha256.New, secret) h.Write(data) return hex.EncodeToString(h.Sum(nil)) }运行这个示例,得到下面输出:
JSON from JS-like system: {"currency":"USD","amount":100} JSON from Go system (map): {"amount":100,"currency":"USD"} HMAC for JS JSON: 6e79a600ccff47618144c12713f24af06b3278eef5b895f61bb6c74fde2d861e HMAC for Go JSON: fe2d3217a0ddcbe8a5879f42703124c31824b87747d017e61e3f7ce8a289e7f7 Do the signatures match? false Authentication Fails! The byte representations were different.这个例子清晰地表明,尽管两份 JSON 在语义上完全等价,但由于字节表示不同,最终导致了签名验证失败。
// demo5/main1.go package main import ( "encoding/json" "fmt" ) type UserUpdatePayload struct { Nickname string`json:"nickname"` Description *string`json:"description"`// 指针字段表示可选 } func main() { // 场景一:用户想将 description 更新为空字符串 "" jsonWithValue := []byte(`{"nickname":"Gopher", "description":""}`) var u1 UserUpdatePayload json.Unmarshal(jsonWithValue, &u1) fmt.Printf("Scenario 1 (Zero Value): Description is nil: %t, Value: '%s'\n", u1.Description == nil, *u1.Description) // 场景二:用户未提供 description 字段 (无论是显式 null 还是 missing) jsonWithoutValue := []byte(`{"nickname":"Gopher"}`) // or {"description":null} var u2 UserUpdatePayload json.Unmarshal(jsonWithoutValue, &u2) fmt.Printf("Scenario 2 (Absence): Description is nil: %t\n", u2.Description == nil) }输出:
Scenario 1 (Zero Value): Description is nil: false, Value: '' Scenario 2 (Absence): Description is nil: true这个例子清晰地表明,指针字 完美地区分了“零值” ("") 和“值的缺失” (nil)。对于大多数业务场景,将显式的 null 和 missing 都视为“值的缺失”,是一种合理且有效的简化。
// demo5/main2.go package main import ( "encoding/json" "fmt" ) func main() { // 场景一:description 显式为 null jsonWithNull := []byte(`{"name":"Gopher", "description":null}`) // 场景二:description 字段缺失 jsonMissing := []byte(`{"name":"Gopher"}`) distinguish(jsonWithNull) distinguish(jsonMissing) } func distinguish(jsonData []byte) { // 步骤 1: 解码到一个 map[string]json.RawMessage var raw map[string]json.RawMessage if err := json.Unmarshal(jsonData, &raw); err != nil { panic(err) } // 步骤 2: 检查 "description" 键是否存在 descData, ok := raw["description"] if !ok { fmt.Println("Result: 'description' key is MISSING.") return } // 步骤 3: 如果键存在,检查其内容是否为 "null" ifstring(descData) == "null" { fmt.Println("Result: 'description' key is explicitly NULL.") return } // 如果存在且不为 null,则可以进一步解码 var desc string json.Unmarshal(descData, &desc) fmt.Printf("Result: 'description' has value: %s\n", desc) }输出:
Result: 'description' key is explicitly NULL. Result: 'description' key is MISSING.这个模式虽然更复杂,但它提供了最精确的控制。它首先检查键是否存在于 map 中,如果存在,再检查其原始的 JSON 文本是否就是 null。
{ "iso_string": "2023-01-15T10:30:00.000Z", "unix_timestamp": 1673780200, "unix_milliseconds": 1673780200000, "date_only": "2023-01-15", "custom_format": "15/01/2023 10:30:00" }不同语言的库对这些格式的默认解析行为千差万别,极易出错。例如,JavaScript 的 new Date() 构造函数在处理 Unix 时间戳时,期望的是毫秒而非秒,这常常导致微妙的 bug。
//demo6/main.go package main import ( "encoding/json" "fmt" "strconv" "time" ) // CustomTime 是一个自定义类型,用于处理非标准的 "DD/MM/YYYY HH:MM:SS" 格式 type CustomTime struct { time.Time } // 为 CustomTime 实现 UnmarshalJSON 接口 func (ct *CustomTime) UnmarshalJSON(b []byte) error { // 首先去除字符串的引号 s, err := strconv.Unquote(string(b)) if err != nil { return err } // 定义我们期望的格式 const layout = "02/01/2006 15:04:05" t, err := time.Parse(layout, s) if err != nil { return err } ct.Time = t returnnil } // UnixTime 是一个自定义类型,用于处理以秒为单位的 Unix 时间戳 type UnixTime struct { time.Time } func (ut *UnixTime) UnmarshalJSON(b []byte) error { // 将 JSON 数字转换为 int64 unixSec, err := strconv.ParseInt(string(b), 10, 64) if err != nil { return err } ut.Time = time.Unix(unixSec, 0) returnnil } // Event 结构体包含了所有不同格式的时间字段 type Event struct { ISOString time.Time `json:"iso_string"` // 标准库直接支持 UnixTimestamp UnixTime `json:"unix_timestamp"` // 自定义类型处理 UnixMilliseconds int64 `json:"unix_milliseconds"`// 直接用 int64 接收 DateOnly string `json:"date_only"` // 简单情况用 string CustomFormat CustomTime `json:"custom_format"` // 自定义类型处理 } func main() { jsonData := []byte(`{ "iso_string": "2023-01-15T10:30:00.000Z", "unix_timestamp": 1673780200, "unix_milliseconds": 1673780200000, "date_only": "2023-01-15", "custom_format": "15/01/2023 10:30:00" }`) var event Event if err := json.Unmarshal(jsonData, &event); err != nil { panic(err) } fmt.Printf("ISO String: %s\n", event.ISOString.UTC()) fmt.Printf("Unix Timestamp: %s\n", event.UnixTimestamp.UTC()) // 从毫秒时间戳创建 time.Time msTime := time.UnixMilli(event.UnixMilliseconds) fmt.Printf("Unix Milliseconds: %s\n", msTime.UTC()) fmt.Printf("Date Only: %s\n", event.DateOnly) fmt.Printf("Custom Format: %s\n", event.CustomFormat.UTC()) // 假设 custom format 也是 UTC }运行这个示例输出:
ISO String: 2023-01-15 10:30:00 +0000 UTC Unix Timestamp: 2023-01-15 10:56:40 +0000 UTC Unix Milliseconds: 2023-01-15 10:56:40 +0000 UTC Date Only: 2023-01-15 Custom Format: 2023-01-15 10:30:00 +0000 UTC这个示例清晰地展示了 Go 在处理时间格式时的灵活性和健壮性。
// demo7/main.go package main import ( "encoding/json" // 在jsonv2时,改为"encoding/json/v2" "fmt" ) func main() { var data map[string]int // Duplicate keys - last value wins (no error) err := json.Unmarshal([]byte(`{"a": 1, "a": 2}`), &data) if err != nil { fmt.Println("Duplicate key error:", err) } else { fmt.Printf("Duplicate keys allowed, value: %d\n", data["a"]) // 2 } // Trailing commas - error err = json.Unmarshal([]byte(`{"a": 1,}`), &data) if err != nil { fmt.Println("Trailing comma error:", err) } // Leading zeros - error err = json.Unmarshal([]byte(`{"num": 007}`), &data) if err != nil { fmt.Println("Leading zeros error:", err) } // Single quotes - error err = json.Unmarshal([]byte(`{'a': 1}`), &data) if err != nil { fmt.Println("Single quotes error:", err) } }上述示例在json/v1下的运行结果:
Duplicate keys allowed, value: 2 Trailing comma error: invalid character '}' looking for beginning of object key string Leading zeros error: invalid character '0' after object key:value pair Single quotes error: invalid character '\'' looking for beginning of object key string而在Go 1.25.0 GOEXPERIMENT=jsonv2下的运行结果如下:
Duplicate key error: jsontext: duplicate object member name "a" Trailing comma error: jsontext: invalid character ',' at start of value after offset 7 Leading zeros error: jsontext: invalid character '0' after object value (expecting ',' or '}') after offset 9 Single quotes error: jsontext: invalid character '\'' at start of value after offset 1Go 防预指南:v1 的宽容与 v2 的严格
Trailing comma error: invalid character '}' looking for beginning of object key stringv1 正确地拒绝了尾随逗号,但其错误信息略显晦涩。它告诉你它在 } 字符处遇到了问题,因为它期望看到下一个键的开始,而不是直接暗示问题在于前一个多余的逗号。
Trailing comma error: jsontext: invalid character ',' at start of value after offset 7v2 同样拒绝了尾随逗号,但它的错误信息更加精确。它直接指出了问题字符是 ,,并给出了其在字节流中的确切偏移位置 (offset 7),极大地提升了调试效率。
Leading zeros error: invalid character '0' after object key:value pairv1 拒绝了不符合 JSON 规范的八进制风格数字 007,但错误信息同样不够直观。
Leading zeros error: jsontext: invalid character '0' after object value (expecting ',' or '}') after offset 9v2 的错误信息再次胜出,它明确指出了在数字值之后遇到的无效字符 0,并提示了此处期望的字符(, 或 }),让开发者能更快地定位问题。