V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
BeautifulSoap
V2EX  ›  Go 编程语言

踩到 Go 的 json 解析坑了,如何才能严格解析 json?

  •  2
     
  •   BeautifulSoap · 2023-09-19 15:28:01 +08:00 · 13800 次点击
    这是一个创建于 435 天前的主题,其中的信息可能已经有所发展或是发生改变。

    精准踩中了 json 解析包的两个坑导致了生产环境出错

    假设有下面结构体定义

    type Data struct {
    	A   string `json:"a"`
    	B   int   `json:"b`
    	Obj struct {
    		AA string `json:"aa"`
    		BB int    `json:"bb"`
    	} `json:"obj"`
    }
    

    使用json.Unmarshal() 解析下列几种 json

    {"a":null, "b": null, "obj":null}
    {"obj": null}
    {"a": "a"}
    {"a": "a","z":"z"}
    {}
    {"obj": {}}
    

    问:解析哪个 json 会报错?

    答:全都不报错都正确解析

    都是不出事就注意不到的问题。尤其非指针类型字段,我下意识认为遇到 null 是会直接报错的,结果直接是当作不存在(undefined)来处理。。。

    so ,go 下怎么才能简单地进行严格 json 解析?要求

    1. 不允许出现未知字段,出现则报错(这个似乎倒是可以用 json 包的 DisallowUnknownFields 简单做到)
    2. 非指针字段不允许传入 null ,否则报错(似乎 json 包没法简单做到)
    第 1 条附言  ·  2023-09-19 20:34:51 +08:00
    不太理解为什么为什么把 null 解析为默认空值这么严重的问题大家工作似乎都没遇到过。经过这次生产环境事故我认为这是绝对不可接受的。

    随便举个例子,有个 price 字段,类型为 int ,API 接口定义中是非 null 字段。但是请求外部 api 获得返回值 or 前端发送的数据不知为什么获取到了 {"price" : null} 。因为 json 默认把 null 解析为空值,所以解析 json 的时候并不会报错,商品价格会以 0 元被解析(请注意,int 字段为 0 是业务中非常常见的,比如商品价格 0 元是允许的)。那么这就出现了一次非常严重的事故,可能顾客直接以 0 元购买了不应该被购买的商品

    将 null 解析到非指针字段的时候不报错我认为是很严重的问题
    第 2 条附言  ·  2023-09-20 00:20:08 +08:00
    重新整理下,可能是我说明不太好懂,并且很多人用 go 的 json 解析时也不在乎细节,导致没懂我到底想说什么

    比如我定义了一个帖子里说的结构体 Data ,然后我用如下方法解析 json `err := json.Unmarshal([]byte(jsonStr) &data)`

    1. 假设我解析的 json 文本是如下内容,请问:解析会不会报错?如果不报错的话解析结果是什么?
    `{"a":null, "b": null, "obj":null}`
    答案:解析不会报错,解析后的结果为 `{A:"" B:0 Obj:{AA: "" BB:0}}` 。直接将 null 解析成了各个类型的默认空值而不是报错。一般来说 go 里和 null 概念最接近的是 nil ,将 null 解析到非指针类型相当于将 nil 赋值给 A, B, Obj 字段,觉得报错是自然而然的事情。但实际上 go 的 json 解析不会报错。


    2. 再假设解析的 json 文本如下呢
    `{}`
    答案:解析不会报错,解析后的结果为 `{A:"" B:0 Obj:{AA: "" BB:0}}`

    3. 再假设如下呢
    `{"a":"jack"}`
    答案:解析不会报错,a 之外都赋予默认空值 `{A:"jack" B:0 Obj:{AA: BB:0}}`

    于是这里就有两个坑
    1. json 里的 null 解析到 非 指 针 字段并不会报错,而是直接解析成对应类型的空值。这会造成非常大的问题,因为 {"a": null } {"a": 0} 都会被解析成数字 0 。假设你跟前端/外部接口约定好字段某些字段不能传 null 值,但对方就是因为 bug 传了个 null 值过来,还成功解析成 0 了请问你该怎么办?(在实际业务中数字 0 是非常常见的正常值,如果将 null 解析为 0 后直接用到业务里会出现非常严重的问题,如价格为 0 ,下单数量为 0 之类的)。也许你会说字段全部都定义成指针不就行了?是的,指针可以判定是不是 null ,但我作为负责的后端就要以所有字段都可能被瞎传入 null 为前提考虑问题,所以对于一个复杂业务的复杂 DTO 就会出现下面这样的地狱情况,指针满天飞一不留神就出 BUG 了( PS:Calculate()已经有两个 BUG 了,data.Obj1.I 和 data.I 是可 null 字段,不能直接取值,必须先判空)
    2. json 里不存在的字段解析也不会报错。这点就是上面例子中 2 和 3 。目前业务中还没有迫切需要判别 2 和 3 的需求,但是如果将来遇到的话也将会是非常大的问题
    第 3 条附言  ·  2023-09-20 00:22:49 +08:00
    漏了上面说的指针满天飞的代码链接了 https://gist.github.com/WonderfulSoap/18a14da135f659d5350f36bdbe439b6a
    211 条回复    2023-10-11 17:21:42 +08:00
    1  2  3  
    thevita
        201
    thevita  
       2023-09-21 16:44:22 +08:00
    @thevita 是指 golang 的 sql 包
    wwwuser
        202
    wwwuser  
       2023-09-21 17:53:11 +08:00
    @zhs227
    @BeautifulSoap
    @ye4tar

    看了官方的文档避开那个坑就可以了,不需要改官方库,下面的代码可以校验原始 json 对象是否有 null 值,可以直接跑着测试一下,可能考虑不周全,可以一起完善下,献丑了


    package main

    import (
    "encoding/json"
    "fmt"
    )

    func main() {
    data := []byte(`{
    "id": 1,
    "name": "zhangSheng",
    "books": [{
    "id": 1,
    "Name": 2,
    "desc": "golang book",
    "err": ["a","b"],
    "array": [{"a":"hello","b": null}]
    }],
    "desc": "test",
    "test": {"a": "s"}
    }`)

    var jsonMap map[string]interface{}
    if err := json.Unmarshal(data, &jsonMap); err != nil {
    fmt.Println(err.Error())
    return
    }
    println(valueHasNil(jsonMap))
    }

    func valueHasNil(mp map[string]interface{}) bool {
    for key, value := range mp {
    if value == nil {
    fmt.Printf("key: %s, value: %+v\n", key, value)
    return true
    }
    switch val := value.(type) {
    case []interface{}:
    for _, m := range val {
    switch t := m.(type) {
    case map[string]interface{}:
    if valueHasNil(t) {
    return true
    }
    }
    }
    case map[string]interface{}:
    if valueHasNil(val) {
    return true
    }
    }
    }
    return false
    }
    waitan
        203
    waitan  
       2023-09-21 18:05:49 +08:00
    我是按这么理解:如果值为 null ,就代表这个字段没有值,那么就不做解析,使用结构体的零值,所以我做反序列化的时候,都会预防这一点,所以没踩过这一个坑。
    gamexg
        204
    gamexg  
       2023-09-22 03:34:57 +08:00
    理解这个问题,也碰到过这个情况.
    不过我是尽量让默认值就是符合默认情况
    对于楼主说的金额这种无法确定默认值和未提供的情况,我会看情况使用指针 或 者把默认值定义为异常值,验证时发现是默认值直接报错.
    当然对于金额这个允许 0 的情况,我会在 json 解析前就给结构提供一个正常情况下不允许的值,例如 -1 .这样如果客户端未提供金额,那么解析后金额值还会是 -1 , json 完成后验证下是否为负数完事,如果金额允许负数,那么我可能会将默认值设置为 0xFFFFFFFFFFFFFFFF 等业务上近乎不可能出现的值. 如果项目必须允许所有金额,那么只能设置为指针类型了.




    其实这个问题出现的原因是,json 标准库没提供一个必须选项.
    大概就是 golang 的大道至简?或者希望标准库只提供基本功能.
    这种需求其实楼主可以自己自定义 json 来搞.
    gamexg
        205
    gamexg  
       2023-09-22 03:47:12 +08:00
    @gamexg 其实这个并不是简单的 null 解析不报错的问题.
    如果楼主怕对外接口传递进来非法的 null 值被当作默认值是个问题, null 需要报错.
    那么楼主考虑到如果外部根本没有传递这个字段会是什么情况? 一样是会被当作默认值. 对于 json 来讲却少字段是一个常见现象,会当作默认值也是常见现象,一样会造成楼主的问题.

    其实说到底,这个问题就是 go 的 json 标准库没提供验证功能.
    解决办法也只能要么换 json 库,要么自己自定义一个实现,或者用指针,或者提供一个会被标记为错误的默认值,验证时不允许这个值.
    gamexg
        206
    gamexg  
       2023-09-22 04:01:25 +08:00
    @snylonue

    其实楼主真的纠结错地方了.
    一个很好玩的地方,
    楼主说无法控制调用者提交的内容需要解决外部输入的各种意外情况,所以必须检查可能为 null 的情况.
    但是楼主看起来忘记了另外一个情况,即调用者未提供金额字段的情况. json 字段不齐全很正常,至少我知道的语言的 json 库默认都是允许的,不会报错.这时候金额还是会被设置为 0 . 会出现和 null 一样的问题.

    当然有的 json 允许加上验证功能,这个字段必须提供,可以解决这个问题.不过 go 标准库的 json 没有提供,换库、自定义 json 、json 前提供一个错误的可被验证到的默认值、字段类型设置为指针、api 处一个结构内部一个结构 等办法都可以解决这个问题,就是需要增加工作量.
    不过想要完整验证所有情况,必定会增加工作量.考虑仔细的代码很大一部分带阿妹都是在处理各种意外.
    harrozze
        207
    harrozze  
       2023-09-22 09:24:01 +08:00
    @ysc3839 #27

    这个确实是。如果 go 的 json 标签可以支持类似 `notnull` 这种描述,对 null 报错,或者 `nullable`,允许解析成一个类似 `{ int value; bool isNull; }` 的结构体,用于区分 null 的 0 值还是真实 0 值,也能解决这个问题。

    不然就只能是用指针类型了。反正……不改 go 的实现,就得改自己的代码(改用指针)
    zoharSoul
        208
    zoharSoul  
       2023-09-22 10:26:40 +08:00
    这个确实是个坑, 应该报错才合理的
    snylonue
        209
    snylonue  
       2023-09-23 23:01:19 +08:00
    @gamexg
    > json 字段不齐全很正常,至少我知道的语言的 json 库默认都是允许的,

    我觉得这个按 null 处理比变成默认值更合理。另外 rust 的 `serde_json` 在这种情况下会报错(如果类型不是 `Option<T>`)
    Atsushi
        210
    Atsushi  
       2023-09-25 10:41:02 +08:00
    @rekulas #50
    我觉得这个问题换一个方向,不用传入值来匹配给定结构,用给定的结构来要要求传入值就说得通了。
    我就是要求传入值和我给定的结构完全匹配,不能匹配抛异常。这应该是基本要求。
    如果按 OP 所说只能给一个默认初始化值就太说不过去了。
    EchoUtopia
        211
    EchoUtopia  
       2023-10-11 17:21:42 +08:00
    确实是个问题,以前我写业务代码的时候对于需要区分 null 和零值的字段都是用的自己定义的 NullableInt 、NullableString 这类的类型。
    1  2  3  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3116 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 13:36 · PVG 21:36 · LAX 05:36 · JFK 08:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.