闽公网安备 35020302035485号
{ "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,并提示了此处期望的字符(, 或 }),让开发者能更快地定位问题。