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 · 13879 次点击
    这是一个创建于 451 天前的主题,其中的信息可能已经有所发展或是发生改变。

    精准踩中了 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  
    zhs227
        1
    zhs227  
       2023-09-19 15:30:28 +08:00
    我看过结构体成员用指针判断是否空串的,不清楚是否满足你的要求
    BeautifulSoap
        2
    BeautifulSoap  
    OP
       2023-09-19 15:37:14 +08:00   ❤️ 1
    @zhs227 可能不太现实,因为通过定义成指针来满足第二点需求的话,意味着结构体全部字段都必须定义成指针。如果字段非常多(几十上上百个)一个个判断代码量和工作量非常大增减字段容易出纰漏(最终要上反射)。而且所有字段定义成指针的话,使用起来会相当难受
    BeautifulSoap
        4
    BeautifulSoap  
    OP
       2023-09-19 16:19:26 +08:00   ❤️ 1
    @fgwmlhdkkkw json.Unmarshal() 会把 null 解析成对应类型的空值,比如 int 的话就是 0 。validation 只能判断是不是 0 不能判断是不是 null ,派不上用场
    LLaMA2
        5
    LLaMA2  
       2023-09-19 17:45:24 +08:00
    声明的时候这样
    A string `json:"a,notnull"`
    B int `json:"b,required"`

    试试看
    BeautifulSoap
        6
    BeautifulSoap  
    OP
       2023-09-19 18:01:24 +08:00   ❤️ 1
    @ye4tar 试了下没效果。json 包其实不不支持 notnull, required 标签的
    LLaMA2
        7
    LLaMA2  
       2023-09-19 18:11:33 +08:00
    tuxz
        8
    tuxz  
       2023-09-19 18:18:45 +08:00   ❤️ 1
    对这个结构体实现自定义的 UnmarshalJSON 方法就行了
    RedisMasterNode
        9
    RedisMasterNode  
       2023-09-19 18:19:39 +08:00   ❤️ 2
    我出两个小建议,但是可能都需要对全局代码进行查找和替换。不过改动量还算可控的。

    1. 如果只是针对 json 格式和 struct 定义不完全匹配,可以用 jsoniter 库,通过这个配置玩一下:jsoniter.Config{DisallowUnknownFields: true}
    2. 如果需要在 struct 上自定义 tag ,例如:required ,那可以提供一套自定义的 json 方法,里面先使用 github.com/go-playground/validator/v10 进行检查(支持的 tag 应该足够用了),再执行原生的 json 方法,方法签名保持不变

    对于方案 2 ,全局的代码应该可以通过修改 import ,把所有的 json 包都替换成自行实现的 json 包,提供 marshal / unmarshal 方法就可以了
    pkoukk
        10
    pkoukk  
       2023-09-19 18:23:35 +08:00   ❤️ 7
    你要的是数据验证,而不是 json 反序列化,应该用这个
    https://github.com/go-playground/validator
    lshang
        11
    lshang  
       2023-09-19 18:24:48 +08:00 via Android
    试一下先解析成 map[string]any,然后用 mapstructure 解析?
    haoxue
        12
    haoxue  
       2023-09-19 18:31:42 +08:00 via Android
    Maboroshii
        13
    Maboroshii  
       2023-09-19 18:34:29 +08:00 via Android
    如果已知字段会为 null ,直接用指针吧。
    RedisMasterNode
        14
    RedisMasterNode  
       2023-09-19 18:37:44 +08:00
    https://go.dev/play/p/8_2sAiXdrQ7

    9L 的小 demo ,试了一下感觉应该挺好用的

    type User struct {
    Name string `json:"name" validate:"required"`
    Age int64 `json:"age" validate:"required"`
    }

    testCase: {"name": "john", "age": 10}
    result: <nil>

    testCase: {"name": "john", "age": 10, "is_v2ex": false}
    result: main.User.ReadObject: found unknown field: is_v2ex, error found in #10 byte of ...| "is_v2ex": false}|..., bigger context ...|{"name": "john", "age": 10, "is_v2ex": false}|...

    testCase: {"name": "john"}
    result: Key: 'User.Age' Error:Field validation for 'Age' failed on the 'required' tag
    dobelee
        15
    dobelee  
       2023-09-19 18:58:57 +08:00
    你搞错了,这是数据检验需求。
    knightdf
        16
    knightdf  
       2023-09-19 19:02:08 +08:00   ❤️ 1
    这就是 json 解析的正常结果啊,你的需求是 validation
    rekulas
        17
    rekulas  
       2023-09-19 20:47:47 +08:00   ❤️ 1
    "因为 json 默认把 null 解析为空值,所以解析 json 的时候并不会报错,商品价格会以 0 元被解析"
    作为一个合格的程序员会告诉你,并不会,null 本来就是合格的 json 值格式你为何非要别人报错

    当某个问题别人从没遇到过自己经常遇到的时候,好好思考下,会不会是自己的思维方式或使用方式不当
    BeautifulSoap
        18
    BeautifulSoap  
    OP
       2023-09-19 20:51:58 +08:00
    @knightdf
    @dobelee
    @pkoukk
    关于 validation 这件事,你们真的应该亲自试试,就会发现这根本不是 validation 能解决的问题。
    null 会被解析为默认空值,如 int 字段传入 null 会被解析为 0 ,即便用 validator 这个包做 validation 检测也只能检测字段是不是 0 。但在实际业务中 int 值字段为 0 基本都是正常值,不应该被报错


    @RedisMasterNode
    你理解错 9L 的意思了,你提供的这段代码其实是有问题的。比如你尝试解析下 `{"name": "john", "age": 0}` 是会报错的(有的地区是有 0 岁这个概念的哦),单纯在解析后用 validator 是没法区分传入的到底是 0 岁还是 null 的
    BeautifulSoap
        19
    BeautifulSoap  
    OP
       2023-09-19 21:22:45 +08:00
    @rekulas 如果你是个合格的程序员并且经验丰富接触的语言也不只有一种的话,那么你会清楚一般来说语言解析遇到 null 的话,都会尽量将其解析为对应语言中的空指针/null/None 一类。比如 python 把 null 解析为 None, js 、php 、java (大部分 json 包)会把 null 解析为 null 。而对于 kotlin 这种明确区分了空值非空值的语言,将 null 解析为非空类型的字段会直接报错。

    话题绕回来,对于 json 来说 null 存在是合理的,但是将 null 解析为非指针的时候不报错解析为默认空值是不合理的。请问你如何区分 {"a":null} {"a":0} ?而且落地到实际项目中,假设一个 api 接口所有字段都是禁止传 null 的,但是前端/外部接口就硬是因为 bug 之类的给你传了个 null ,请问你认为是直接报错拒绝请求比较好呢,还是直接解析成对应类型空值去处理业务从而引发严重事故好?
    tairan2006
        20
    tairan2006  
       2023-09-19 21:27:59 +08:00 via Android
    你可以你可以用代码判断啊…匿名类那里改成指针,然后代码判断…
    BeautifulSoap
        21
    BeautifulSoap  
    OP
       2023-09-19 21:31:08 +08:00
    @haoxue 多谢我试试
    @tuxz 正常来说一个实际的项目中,要从 json 解析出 struct 的数量是几十上百个的。。。一个个实现不太现实,写通用方法塞进去倒也是个办法,但是也容易出纰漏


    @ye4tar
    @RedisMasterNode
    是的,我思前想后可能也就只能自己魔改官方 json 包了。不过倒也用不着 validator ,直接在解析 json 的时候,如果目标字段为非指针类型,遇到 null 就直接报错可能会更直接的。一直不太想自己魔改主要是总感觉自己写的东西是重复造轮子,,,,
    Maboroshii
        22
    Maboroshii  
       2023-09-19 21:36:19 +08:00 via Android   ❤️ 3
    @BeautifulSoap 你也知道,你列举的这些语言,对象是可为 null 的。那你知不知道,golang 里面,struct 只有 0 值没有 null 值呢。
    kumoocat
        23
    kumoocat  
       2023-09-19 21:36:30 +08:00 via iPad
    @BeautifulSoap Java 直接用基础 int 类型也是 0 啊,Go 不用指针就代表不能为 null
    BeautifulSoap
        24
    BeautifulSoap  
    OP
       2023-09-19 21:40:04 +08:00   ❤️ 1
    @tairan2006 要改成指针的可不止匿名类,匿名类里的 AA 、BB ,外面的 A 和 B 也都得要改成指针哦。然后就出现了我 2L 说的问题,为了解决 null 判定这一个问题,整个 struct 全部字段都定义成指针,实在过于得不偿失了。

    @Maboroshii 其实问题不在“已知字段会为 null”,而是 API 文档已经明确约定了些字段全都不能为 null ,但外部接口/前端就硬是给你传了 null (不要问为什么,后端永远不能相信前端传给你的数据是什么牛鬼蛇神,实际上这次出事就是因为约定了非 null 的字段外部接口给传了 null 。)
    houshuu
        25
    houshuu  
       2023-09-19 21:41:39 +08:00
    前端返回和设计文档上不一样的内容那应该归属于前端导致的问题, 而不是力求后端在脱离设计初衷的情况下按照自己预想的工作方式工作.
    如果设计上允许前端有可能传入 null, 那你在后端设计上就应该使用指针, 使用 int 就是个错误的选择.

    最后, 这都涉及实际订单处理和付款了, 就算设计不完备, 这个问题也理应在发布前的测试过程中被发现才对.
    xiangyuecn
        26
    xiangyuecn  
       2023-09-19 21:42:26 +08:00
    任何语言,json 到 map 一把梭
    ysc3839
        27
    ysc3839  
       2023-09-19 21:47:01 +08:00 via Android
    @Maboroshii C++对象也是不可为 null 的,但是许多 C++的 JSON 库懂得把 null 对应到 std::optional 或者其他的 optional 实现。Golang 这个问题实际上反映了缺乏类似 std::optional 这样的表达 null 的工具。
    BeautifulSoap
        28
    BeautifulSoap  
    OP
       2023-09-19 21:48:46 +08:00
    @Maboroshii
    @kumoocat
    “Go 不用指针就代表不能为 null”
    “那你知不知道,golang 里面,struct 只有 0 值没有 null 值呢”

    你们说的对啊,Go 里一个类型不是指针代表这个类型不能赋值 nil ,json 解析的时候遇到 null 意思就是要把 nil 赋值给非指针,直接报错不是再正常不过的想法吗?


    “你也知道,你列举的这些语言,对象是可为 null 的”
    这里面和 go 最相近的是 Go ,因为 kotlin 和 go 一样类型分可空/非可空(对应到 go 近似看成指针/非指针)。当尝试将 null 解析到非可空字段时,kotlin 是可以报错的
    gogogo1203
        29
    gogogo1203  
       2023-09-19 21:51:38 +08:00
    // Decode reads the body of an HTTP request looking for a JSON document. The
    // body is decoded into the provided value.
    //
    // If the provided value is a struct then it is checked for validation tags.
    func Decode(r *http.Request, val interface{}) error {
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()
    if err := decoder.Decode(val); err != nil {
    return err
    }

    return nil
    }



    type NewComment struct {
    Body string `json:"body" validate:"required"`
    ParentId string `json:"parentId"`
    }




    ....
    var nc comment.NewComment
    if err := web.Decode(r, &nc); err != nil {
    return fmt.Errorf("unable to decode payload: %w", err)
    }

    ....

    很久没有写 go 了,go 不至于连个 json 不能处理吧
    delacey
        30
    delacey  
       2023-09-19 22:07:03 +08:00
    @BeautifulSoap 后端肯定需要做校验的,因为调用你接口的不仅仅只有前端,还可能是不怀好意的脚本小子
    BeautifulSoap
        31
    BeautifulSoap  
    OP
       2023-09-19 22:11:31 +08:00
    @houshuu 你可能都没看懂我这贴的意思到底是什么。我只是举例,出的事故不是订单相关。

    后端不应该信任前端/外部端口传入的数据,所以后端需要对传入的数据做最低限度的确认,如果不符合预期和要求就要报错。请问这点你同不同意?
    这次问题就出在“对传入数据做最低限度的确认”上,API 接口约定不能传 null ,但是外部接口传了 null ,在 json 解析的时候自然而然会认为要给非指针赋值 nil ,json 包肯定会报错,但实际上它解析成空值不报错,而空值 0 是业务允许的,从而引发了生产事故

    自然,传了 null 的确是前端/外部接口的锅,但除了生产事故加班 log 调查、数据查找、确立回复数据库数据都是后端/运维的工作。到时候如果来一句“虽然 xxx ,但后端连 null 都不检查的吗”就问你怎么应对。
    BeautifulSoap
        32
    BeautifulSoap  
    OP
       2023-09-19 22:14:33 +08:00
    @delacey 我顶楼也说了,直到踩到坑之前我都一直以为 json 包替我完成了对 null 的校验啊。你要想,json 包做解析的时候吧 string 解析到 int 类型字段会报错,那么谁会想到把 null 解析到非指针字段就不会报错了呢?

    > 都是不出事就注意不到的问题。尤其非指针类型字段,我下意识认为遇到 null 是会直接报错的,结果直接是当作不存在(undefined)来处理。。。
    gogogo1203
        33
    gogogo1203  
       2023-09-19 22:20:47 +08:00
    @delacey 我看过比较好的方式是 前端来的 json 用一个 struct, 校验完转成 db 专用的另一个 struct, db 查询返回的又用另外一个新的 struct. 前端更新某个 field , 又用一个专属的 struct. 用 go 写 curd 后台,做得最多的就是各个 struct 之间转来转去。
    BeautifulSoap
        34
    BeautifulSoap  
    OP
       2023-09-19 22:25:25 +08:00
    @gogogo1203 这方法使用 DisallowUnknownFields 能解决问题 1 ,但问题 2 没办法解决
    gogogo1203
        35
    gogogo1203  
       2023-09-19 22:29:17 +08:00
    @BeautifulSoap

    ```
    // Create adds a Comment to the database. It returns the created Comment with
    // fields like ID and DateCreated populated.
    func (c Core) Create(ctx context.Context, nc NewComment, now time.Time, usrId string, tutId string) (Comment, error) {
    if err := validate.Check(nc); err != nil {
    return Comment{}, fmt.Errorf("validating data: %w", err)
    }
    parentId := &dbschema.NullString{}
    if nc.ParentId == "" {
    parentId.Valid = false
    } else {
    parentId.Valid = true
    parentId.String = nc.ParentId
    }
    dbCm := db.Comment{
    ID: validate.GenerateID(),
    CommenterId: usrId,
    TutorialId: tutId,
    ParentId: parentId,
    Body: nc.Body,
    DateCreated: now,
    DateUpdated: now,
    }
    if err := c.store.Create(ctx, dbCm); err != nil {
    return Comment{}, fmt.Errorf("create: %w", err)
    }

    return toComment(dbCm), nil
    }

    ```

    web 到业务逻辑 然后再到 db 层,json tag 校验是必备的。sql.NullString sql.NullInt 都不是简单的 struct ,理论上不存在 go 后端不查 null 的,因为必须确认. 你们是不是用了什么 orm 一把梭了.
    BeautifulSoap
        36
    BeautifulSoap  
    OP
       2023-09-19 22:30:10 +08:00
    @gogogo1203 #33 “前端来的 json 用一个 struct, 校验完” 其实问题就出现在怎么校验这一步上啊。null 会被解析成对应字段类型的空值,也意味着根本没有手段去校验 json 传入的是不是 null 。问题就出现在这一步了。我顶楼给的那几个涉及到 null 的例子就是最好写照
    Yourshell
        37
    Yourshell  
       2023-09-19 22:32:29 +08:00
    rekulas
        38
    rekulas  
       2023-09-19 22:37:28 +08:00
    @BeautifulSoap 拿 php python 这种动态脚本语言来类比 go,很让你怀疑你的基础水平
    kotlin 还差不多
    但是, 你说的 kotlin 报错并不代表所有语言都要按照你喜好的语言来走, 严格来说,你的需求就是 2 步: 1 解析 2 验证非法值
    如果 kotlin 直接在 json 解析阶段做了校验,我认为没有问题, 但是因为它做了就 diss 别的语言就显得太幼稚了, 如果你要这样来的话,麻烦你先把 c 和 rust 先 diss 一遍,按我经验,一般流行的库在解析阶段也不会报错,除非加了数值校验
    再举个例子,以后再出一款高级语言,在 json 解析的时候支持自动反序列化,数值严重,enum 验证,time 类型验证...那新语言的开发者是不是又继续来鄙视这些老前辈呢?

    如果你非要区分 0 和 null,按楼上说的用指针才是正道,你用了错误的方法去处理问题还一直想不通,感觉有点钻牛角尖了
    lshang
        39
    lshang  
       2023-09-19 22:41:13 +08:00 via Android
    多说一句,除了 null 以外,如果某个字段没填,json unmarshal 到非指针字段给的也是零值,也是不好区分的。
    可能的一种做法是你封装一层 json unmarshal ,先通过反射把结构里必填的字段获取个列表,然后把数据先 unmarshal 到 map ,做是否填写的校验。
    通过之后再 unmarshal 到对应结构上。
    deorth
        40
    deorth  
       2023-09-19 22:47:09 +08:00 via Android   ❤️ 11
    op 说得对,golang 的官方 json 就是一坨
    aababc
        41
    aababc  
       2023-09-19 22:50:45 +08:00
    @Yourshell 这个提案也解决不了这个问题
    BeautifulSoap
        42
    BeautifulSoap  
    OP
       2023-09-19 22:57:40 +08:00
    @rekulas 我不知道是自己说明能力太差还是你没法理解,我举例 python 、php 、js 只是想说他们都会将 json 中的 null 解析成对应语言中的 None/null 。这点你能明白吗?然后在 Go 语言里,与 json 的 null 最接近的概念也就只有 nil 了,你同不同意?所以将 null 的解析为 nil → 而非指针类型无法赋值 nil 从而会引发解析错误难道不是非常自然的想法?为什么你会认为这是个校验的问题呢?

    而且如果要较真的话,我们再来深入思考一个问题,什么才叫校验?你说 null 解析成 nil 赋值给非指针字段是校验问题,那么解析如下 json {"a":"hello"}到 这个结构体 struct { A int} 是不是也应该归类为校验问题? 在这里 A 是 int 类型,但是传入了字符串,两者的类型不对的检测是不是也应该交给校验?按照你的说法,解析 { "a": "hello"} 的结果就应该和{"a": null} 一样都解析成 0 ,然后交给校验来处理是不是?
    houshuu
        43
    houshuu  
       2023-09-19 23:00:17 +08:00
    @BeautifulSoap #31

    1. 我还以为你是不想把事故说很细, 换了个类似讲法规避一下风险. 但话说回来, 如果连测试都不需要, 那么这个业务 SLA 估计也没啥要求, 这种问题就算遇上也就是个慢慢修的过程.

    2. 如果"需要对传入的数据做最低限度的确认", 那么就应该用指针. 因为这句话的背后已经说明了有可能为 null.

    3. 不是自己的锅别接, 别人再怎么说也改变不了这个不是后端的问题. 我不知道是不是只有我司这样, 数据处理时要不要检查 null 或是其他异常值永远都是白纸黑字写的清清楚楚的. 不同语言对于异常值的处理差别太大了, 有的在 parse 时报错, 有的要用的时候才报错, 有的像 go 是在内存申请后自动 0 值的, 有的是不初始化的. 不写清楚太容易出问题了.

    你现在就是一定要用 int 去完成指针的功能, 那除了自己魔改或者你自己做点 optional 的类型, 确实没啥办法了. 还是要用合适的工具去做合适的事情.
    BeautifulSoap
        44
    BeautifulSoap  
    OP
       2023-09-19 23:00:53 +08:00
    @lshang 你说的非常对,你说的这个情况其实就是我顶楼给的几个例子中之一

    解析下面的 json 都会成功,解析结果就是所有没有填的字段全为空值

    {"a": "a"}
    {}
    {"obj": {}}
    debuggerx
        45
    debuggerx  
       2023-09-19 23:06:05 +08:00 via Android   ❤️ 1
    支持 op ,go 的包括 json 这块在内的很多设计其实都很扯,错的离谱还扯什么大道至简,一边标榜自己新,一边又只敢跟传统语言对比,对其他新语言的优秀特性视而不见听而不闻,可以说是非常双标了
    Goooooos
        46
    Goooooos  
       2023-09-19 23:11:06 +08:00 via Android
    @kumoocat 这种场景用 Java 来说 go 没问题是不对的,因为 Java 可以定义包装类型 Integer
    seers
        47
    seers  
       2023-09-19 23:14:18 +08:00
    这个设计确实比较离谱
    BeautifulSoap
        48
    BeautifulSoap  
    OP
       2023-09-19 23:15:49 +08:00
    @houshuu
    > 如果"需要对传入的数据做最低限度的确认", 那么就应该用指针. 因为这句话的背后已经说明了有可能为 null

    你好,你这已经是我针对不同人提出的同一个问题的第三次回复了。定义成指针是没有可行性的做法。理由很简单,作为后端我必须不相信前端,所以我要考虑 DTO 的所有字段都有可能被前端瞎传入 null 的情况。
    如果采用指针的话,这意味着我一切 struct 中每一个字段都必须定义成指针。这根本不现实对吧?整个项目 DTO 中所有字段全是指针用起来一定很酸爽对吧。再一个,实际项目中 API 并不是所有字段都禁 null ,个别字段是允许 null 的,所有一切字段都定义成指针的话,请问你能轻松分辨出这一堆指针类型的字段中,哪些是允许 null 哪些是禁止 null 的?更要命的是,使用字段值的时候到底哪个字段要加*,哪个字段要判 nil ?

    > 如果连测试都不需要

    不好意思,不是没有测试而是测试没有覆盖到。你如果认为所有项目只存在完全没测试和所有测试条件都覆盖到的两种极端的话那就太理想了

    > 不是自己的锅别接, 别人再怎么说也改变不了这个不是后端的问题

    不是的,锅的确不是自己的我也不会接,但是因此产生的生产事故和因为事故调查、恢复加的班是实打实(你不可能叫前端过来给你查后端 l 日志,抽取数据回复被破坏的生产环境)
    lance6716
        49
    lance6716  
       2023-09-19 23:20:01 +08:00 via Android   ❤️ 6
    离谱,你说 often 就 often

    https://pkg.go.dev/encoding/json#Unmarshal

    Because null is often used in JSON to mean “not present,” unmarshaling a JSON null into any other Go type has no effect on the value and produces no error.
    rekulas
        50
    rekulas  
       2023-09-19 23:22:31 +08:00   ❤️ 4
    先说答案: 我认为解析成 0 没问题, 错就错在你自己定义类型不对

    go 本身提供了非常好的解决方案 *指针, 稍微有点基础的都知道灵活运用来判断空(默认)值与 nil 值的区别
    比如接收参数的时候,如果预判参数可能超过范围或可能存在空,直接指针介入即可
    或者读取数据库的时候,也可以用来判断

    通过这种方式来区分 nil 与空值,我认为非常合理,不应该在解析阶段处理,如果在解析阶段处理,我才觉得会引起大麻烦:
    我目前接手的一些第三方类库一些结构体参数就几十个,很多值不需要额外处理直接默认留空即可,如果按你的想法,那漏掉一个 kv 对不传输过来都会报错

    另外 你如何看待 c 和 rust 对于这个的处理
    leonshaw
        51
    leonshaw  
       2023-09-19 23:24:03 +08:00   ❤️ 1
    op 说的有一定道理,但是可以尝试从另一个角度考虑。对于许多语言来说 {"a": null} 和 {} 是等价的,类比到 Go, 不传这个字段就是 0 值是合理的。所以实际上缺少的是一个 "required" tag, 当有 required 时,要求不能是 null.
    rekulas
        52
    rekulas  
       2023-09-19 23:32:23 +08:00   ❤️ 1
    我能理解你在判断 nil 与空值的模糊感受,但仍然不会赞同你的想法,在我看来,语言框架基础方法就应该尽可能简洁低耦合,要实现的功能完全可以通过三方库而且性能还不低

    比如 go-playground/validator
    如果我要验证必须有值 加 required
    如果我要求某项值小于 30 加 lte=30
    如果我要求 enum 验证 加 oneof=xiaoming huahua
    如果我要求字符串以 V2EX 开头 加 startswith=v2ex



    如果各种方便的方法都糅杂在 go 自身,那画面简直不敢想象
    gogogo1203
        53
    gogogo1203  
       2023-09-19 23:43:26 +08:00
    是我迷糊了吗? 零值不是 创建 struct 的时候就有了吗??“The JSON null value unmarshals into an interface, map, pointer, or slice by setting that Go value to nil. Because null is often used in JSON to mean “not present,” unmarshaling a JSON null into any other Go type has no effect on the value and produces no error.” 自己读一读 doc 呗,unmarshaler 只是没有去改原先的零值而已。unmarshaler 要快,而不是提前做很多 validation.这些后续你可以慢慢搞。
    nuk
        54
    nuk  
       2023-09-19 23:52:19 +08:00
    可以在 Unmarshal 检查一下 json ,给 struct 加 tag ,然后自己写个 scaner 。老实说 golang 的 json 我也感觉不太好用,看似简单实则臃肿。
    BeautifulSoap
        55
    BeautifulSoap  
    OP
       2023-09-19 23:54:26 +08:00
    @rekulas 典型站着说话不腰疼。我写了个例子给你展示所有字段都为指针是什么地狱情况
    https://gist.github.com/WonderfulSoap/18a14da135f659d5350f36bdbe439b6a
    真的太优雅了,是吧。main2.go 用来模拟在其他包里调用 Data2 数据的情况。悄悄跟你说 Calculate() 里已经塞入了两个 bug 了哦? data.I 和 data.Obj1.I 是可 null 字段段,所以不能直接取值。那么问题来了,如此高的心智负担这么多指针倒来倒去,你怎么确保你每次取值都取得万无一失?
    rekulas
        56
    rekulas  
       2023-09-20 00:00:38 +08:00   ❤️ 1
    @BeautifulSoap 为了证明自己是对的 刻意写了个极端情况 真是辛苦你了
    可是我发现你逻辑思维可能有些问题,具体表现在:
    非要想靠语言自身 hold 所有情况
    对别人提出的问题视而不见, 只想强行解释自己的想法
    BeautifulSoap
        57
    BeautifulSoap  
    OP
       2023-09-20 00:12:05 +08:00
    @rekulas 我觉得你反倒很令人费解,请问你真的有业务经验吗,我说的都是实实在在经手的项目中遇到的问题。实际复杂业务中,前端/外部接口根据需要传过来的 json 包含这么多字段不是很正常的?其中个别字段为可 null 难道不正常?我给你的这个 Data2 就是我目前一个项目的 DTO 定义(当然类型并非全都是 int ,数量规模差不多这种感觉)

    作为后端的基本要求就是不信任外部输入,我就只能考虑以这些字段都可能被传入 null 。你说全定义成指针可以解决问题,那么我就定义成指针了,结果就是我给的例子中的样子为什么反倒说我极端。。。。而且你要知道一旦涉及到业务,也就不是例子里简简单单的取值相乘了,根据 nil 不 nil ,计算方法不同等等等等一大堆复杂计算,全都是要对这些指针倒腾来倒腾去的,所以我才说不现实
    twl007
        58
    twl007  
       2023-09-20 00:27:02 +08:00 via iPhone
    @BeautifulSoap 可以参考下 GRPC 的做法
    https://zhuanlan.zhihu.com/p/46603988?utm_id=0

    额外给你认为一定要区分的变量配置一个是否为 null 的 bool 的字段

    文章里也给了一些讨论的原因 为什么这么设计 感觉跟 json 这块的设计还有些共通的地方吧

    https://stackoverflow.com/questions/31801257/why-required-and-optional-is-removed-in-protocol-buffers-3
    BeautifulSoap
        59
    BeautifulSoap  
    OP
       2023-09-20 00:29:32 +08:00
    @gogogo1203 这就涉及到 49L 吐槽的的核心内容了。这 often 真的是 often 吗
    leoleoasd
        60
    leoleoasd  
       2023-09-20 00:34:36 +08:00   ❤️ 2
    感觉 json 库这么搞的问题是没法区分 json 里写了 0 还是写的 null 还是 undefined
    gogogo1203
        61
    gogogo1203  
       2023-09-20 01:01:52 +08:00
    @BeautifulSoap 2023 年了,喜欢就用,不喜欢就换个语言,没必要抓个标准库输出。多一次运算累计起来的数量都是以亿计算的,我宁可相信 go team 是考虑过这些事的。
    gogogo1203
        62
    gogogo1203  
       2023-09-20 01:07:23 +08:00
    @leoleoasd 这有什么区别吗? struct 创建的时候就是默认零值,unmarshal 只是选择性宽容。你后续按自己业务需求去校验。一群人在喷一些什么东西。又不是所有场景都需要判断。
    leoleoasd
        63
    leoleoasd  
       2023-09-20 04:29:33 +08:00
    @gogogo1203 #62 你觉得校验库怎么区分 {"a": 0} 和 {"a": null } ?
    Nasei
        64
    Nasei  
       2023-09-20 07:44:32 +08:00
    在默认情况下,把 json 的 null 对应的 struct 的零值,我觉得没问题

    只不过官方库可能没提供更全的选项让你选择,这时候用第三库就可以了
    rekulas
        65
    rekulas  
       2023-09-20 08:08:12 +08:00
    @BeautifulSoap 10 多年开发经验了 确实没遇到过你的问题
    就以你上面说的 php 为例, 加入 php 做后端,计算前端传入{"a": null}给你,你又能如何呢, 解析同样正常, 你仍然需要在业务层做数据校验,你要解析时报错,除非使用支持的第三方库

    当我们需要判断一个值的时候当然可以 if a> 0 ...
    当我们需要判断 100 个值得时候, 正常人都会选择复用方法来实现, 你还要按照一个值的方法来使用还指责语言不够完善就不合理了,而且我上面也给你提到了好用的三方库,也忽视不见
    要按这样说,c 语言官方连 json 支持几乎为 0 呢,那不是更应该吊起来打
    k9982874
        66
    k9982874  
       2023-09-20 08:20:04 +08:00
    典型的我不要你觉的,我要我觉得。50 楼已经把答案贴上来了。
    lovelylain
        67
    lovelylain  
       2023-09-20 08:20:42 +08:00 via Android
    @BeautifulSoap “要改成指针的可不止匿名类,匿名类里的 AA 、BB ,外面的 A 和 B 也都得要改成指针哦。”
    兄弟看来是没用过 proto3 ,这个缺省零值的设定习惯了还是挺好用的,首先大部分零值在业务上本身就对应无效值,或者可以通过取值范围设计对应为无效值,例如 string 如果按你的想法传 null 直接解析时报错,但你程序里往往还要检查非空,所以直接解析时多一步 null 检查就有点多余了,枚举类型的 0 值同理;对于极少数 0 值合法,且你要区分是没传还是传入了 0 值的情况,可以用指针,这种情况显然是非常少的。
    tramm
        68
    tramm  
       2023-09-20 08:26:50 +08:00
    RPC 调用 Java 哈哈哈
    kiwi95
        69
    kiwi95  
       2023-09-20 08:30:38 +08:00
    这只能说是标准库的一种取舍,对你可能不方便,但是对大部分人可能是一种更可接受的行为。并且标准库文档是有明确说明这种行为的 `// By convention, to approximate the behavior of [Unmarshal] itself, // Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.`

    https://cs.opensource.google/go/go/+/master:src/encoding/json/decode.go;l=117-121;drc=dac9b9ddbd5160c5f4552410f5f8281bd5eed38c

    对于 LZ 这种场景,比较好的方案是自己定义一个类型别名,然后给这个类型实现自己的 Unmarshal 接口,实现很简单。如果 LZ 的场景有很多的类型都要考虑 null 要报错,那我觉得可能是设计上有点问题了。

    ```
    type Int int

    func (i *Int) UnmarshalJSON(bs []byte) error {
    if len(bs) == 0 || bytes.Equal(bs, []byte("null")) {
    return fmt.Errorf("need a value")
    }
    val, err := strconv.ParseInt(string(bs), 10, 64)
    if err != nil {
    return err
    }
    *i = Int(val)
    return nil
    }

    type Req struct {
    ID Int `json:"id"`
    }

    var _ json.Unmarshaler = (*Int)(nil)

    func TestNULLJSON(t *testing.T) {
    var r Req
    var args = []struct {
    payload []byte
    err bool
    }{
    {
    []byte(`{"id": null}`),
    true,
    },
    {
    []byte(`{"id": 0}`),
    false,
    },
    }
    for idx, arg := range args {
    if err := json.Unmarshal(arg.payload, &r); (err == nil) == arg.err {
    t.Fatalf("%d: want err: %v, but got: %+v", idx, arg.err, err)
    }
    }
    }

    ```
    liuidetmks
        70
    liuidetmks  
       2023-09-20 08:37:52 +08:00
    你声明的是 Int 这种基本类型,只是一段 32 比特的数据,不能表示 Null
    既然你这么声明了,那么你就应该处理好零值,或者你换一个可以表示 null 的类型

    结构体的 size 是固定的,必须初始化对应的成员。
    lmw2616
        71
    lmw2616  
       2023-09-20 08:42:59 +08:00
    json 中的 null 对应 go 中的零值
    crackidz
        72
    crackidz  
       2023-09-20 08:46:55 +08:00
    因为大家有看文档,不会遇到你说的“严重问题”...
    aloxaf
        73
    aloxaf  
       2023-09-20 08:59:29 +08:00   ❤️ 1
    @rekulas #50
    C 语言的反序列化库不了解,而且有必要和上世纪八十年代的语言比烂么……
    至于 Rust ,那不就是 OP 想要的效果吗
    tsanie
        74
    tsanie  
       2023-09-20 09:07:41 +08:00
    我说个对比你们就明白 op 的疑问了,.net 中的 System.Text.Json ,碰到这种情况会在反序列化 json 的时候抛出异常 "Cannot get the value of a token type 'Null' as a number."

    record JsonTest(int price);

    JsonSerializer.Deserialize<JsonTest>("{\"price\":null}");

    ==============

    System.Text.Json.JsonException: 'The JSON value could not be converted to Test.JsonTest. Path: $.price | LineNumber: 0 | BytePositionInLine: 13.'

    InvalidOperationException: Cannot get the value of a token type 'Null' as a number.
    tsanie
        75
    tsanie  
       2023-09-20 09:09:32 +08:00
    而如果允许这个 price 传入 null 的话可以定义为 record JsonTest(int? price);
    tsanie
        76
    tsanie  
       2023-09-20 09:13:23 +08:00
    如果必须前端传回 price ,且值可以为 int 或 null 的话就定义成这样,未传入 price 会抛出异常 'JSON deserialization for type 'Test.JsonTest' was missing required properties, including the following: price'

    record JsonTest
    {
    [JsonPropertyName("price")]
    public required int? Price { get; set; }
    }
    tianxin8431
        77
    tianxin8431  
       2023-09-20 09:14:41 +08:00   ❤️ 2
    v2 上大伙都这么学院派吗,对于业务来说这确实是个很令人难受的问题啊。类比 Java 的话,会有人在定义 vo 的时候用 int 而不用 Integer ,然后去判断 int 的值 != 0 吗?正常情况下肯定是判断是否为 null 啊。当然用指针不是不可以,额外多出的工作量不还是得自己来吗?
    skiy
        78
    skiy  
       2023-09-20 09:16:09 +08:00   ❤️ 1
    OP 搞错了。并不是将 null 解释为空值,而是你类型不对,它就会解析成默认值。因为这个 Data 实例的每个值都肯定得有值。

    你就算
    {"a":123, "b": "abc", "obj":999}

    它照样会解析成
    {"a":"", "b": 0, "obj":...}
    jonsmith
        79
    jonsmith  
       2023-09-20 09:28:59 +08:00 via Android
    前端能直接传价格?不考虑安全性?
    aloxaf
        80
    aloxaf  
       2023-09-20 09:29:17 +08:00
    @skiy
    没有吧,我试了下类型不匹配还是会报错的
    gogogo1203
        81
    gogogo1203  
       2023-09-20 09:30:00 +08:00
    @leoleoasd
    json required tag 可以校验 not present
    tsanie
        82
    tsanie  
       2023-09-20 09:30:33 +08:00
    不过回到例子中来,op 这里应该把 B 与 Obj.BB 设置为 nullable int
    gogogo1203
        83
    gogogo1203  
       2023-09-20 09:34:09 +08:00   ❤️ 1
    @tianxin8431 哈哈哈。 “只要我不同意的观点”===“学院派”。 业务上根本不难受,写一次就够。非要“公牛挤牛奶”,自己给自己找难受罢了。
    zliea
        84
    zliea  
       2023-09-20 09:36:31 +08:00
    go 的 json 反序列化 null 值或者空值不报错或者赋默认值这是一个特性,既然你用了别人的库就要接受相关特性,因为这是一个特性所以你的需求需要自行实现校验。

    现在来看解决方案有两种,第一种通过指针,第二种自定义 type + 实现 UnmarshalJSON 。
    skiy
        85
    skiy  
       2023-09-20 09:38:41 +08:00
    @aloxaf

    {A: B:0 Obj:{AA: BB:0}}

    我亲测的没报错啊:
    ![]( https://file.uhsea.com/2309/81654763b2762f4e551d7c06c6f388c3XL.png)

    ```
    package main

    import (
    "encoding/json"
    "fmt"
    )

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

    func main() {
    var json_str = `{"a":123, "b": "abc", "obj":999}`
    var test Data
    json.Unmarshal([]byte(json_str), &test)
    fmt.Printf("%+v", test)
    }
    ```
    aloxaf
        86
    aloxaf  
       2023-09-20 09:39:35 +08:00
    @tianxin8431 没懂,学院派都是追求正确性的。学院派连 null 都唾弃,现在还把 null 转换到正常类型的空值上去,学院派简直震怒好吗……
    skiy
        87
    skiy  
       2023-09-20 09:40:12 +08:00
    kumoocat
        88
    kumoocat  
       2023-09-20 09:43:58 +08:00
    @skiy Unmarshal 的异常是有的,没有 panic
    SingeeKing
        89
    SingeeKing  
       2023-09-20 09:44:18 +08:00
    目前看用标准库的话,确实没办法对 null 报错,传 null 行为和不传是一样的

    不过想要区分不传(或传 null )和传值的方法很明确:使用 `*int`
    skiy
        90
    skiy  
       2023-09-20 09:45:54 +08:00
    @aloxaf 好吧。我没加判断。确实 err 了,不过解析的结果确实如上。
    wzy44944
        91
    wzy44944  
       2023-09-20 09:48:39 +08:00
    看到第一个 json 第一反应是这个不应该是非法的吗,null 都没有""包裹,然后查了下才发现默认 json 是支持 null 的,https://www.json.org/json-zh.html ,因为 json 就是 javascript 来的。。。
    不过我还真没遇到这种问题,写 go 习惯了,都是用下面这种代码检查默认值,可能我用 json 不多,不嫌麻烦吧,主要是有的时候可以免去检查 err 了
    var obj = &Obj{ val: -1}
    _ = json.Unmarshal(data, obj)
    if obj.val == -1 {
    panic("haha")
    }
    goool
        92
    goool  
       2023-09-20 09:49:21 +08:00
    似乎是数据检查的事情,可以考虑 json schema https://json-schema.org/
    skiy
        93
    skiy  
       2023-09-20 09:51:49 +08:00
    @kumoocat 我理解 OP 的意思了。他说的是用 null ,没报错。

    所以我站在 OP 这边,哪怕你下面有人说官方有解释。但 null 本来就不是 go 的关键字。
    tool2d
        94
    tool2d  
       2023-09-20 09:54:00 +08:00
    null 还是很重要的基础类型之一,你看别的语言解析,一般都会保留一个变体字段结构,来识别原始 JSON 字段的具体类型。

    如果强制映射到结构体,那么 null 就会消失,变成 int 或者 string 默认值,也是能理解的。

    指针 nil 是个解决方案,也没有更好的办法了。
    picone
        95
    picone  
       2023-09-20 09:54:17 +08:00
    @ye4tar #5 go 的 validator 是在 json Unmarshal 之后进行的。Unmarshal 已经丢掉了是否 null 的信息了
    Leviathann
        96
    Leviathann  
       2023-09-20 09:58:05 +08:00   ❤️ 1
    @tianxin8431 说 go 是学院派你是不是搞错了什么,学院派在 21 世纪做出没有泛型的静态类型语言那真是要被钉上耻辱柱
    ForkNMB
        97
    ForkNMB  
       2023-09-20 10:02:36 +08:00   ❤️ 1
    传输协议用的 protobuf go 后端同样遇到这样的问题 解析出来 int 的值是 0 无法确认客户端传的是 null 还是就是业务上的 0 ,所以全改成指针了,然后全局替换判断,取值的时候也用公共方法统一替换。这样客户端不用改,就后端加了一堆工作量,md 好想写回 java ,加个注解就完事了🤔
    gam2046
        98
    gam2046  
       2023-09-20 10:03:06 +08:00   ❤️ 1
    其实问题在于 JSON 并没有指针的概念,null 是一个合法的值,而 Golang 中在进行反序列化时,没有与 null 相对于的值用于表示这个状态,同样的,也没有对应于 undefined 状态。

    一种凑合的解决方案呢,就是业务中不使用默认值作为合法状态,例如 0 元的价格 price ,当值为-1 时代表 0 元,而 0 代表未传值。类似的,空字符串等都不作为有效的数据输入,用其他值作为默认值的替代。

    但是多数场景下,op 的问题可能都不大,0/""这些默认值都不具有实际意义。
    picone
        99
    picone  
       2023-09-20 10:07:10 +08:00
    我觉得其实和团队风格有关。我以前的公司 toC 高并发,比较抠性能不抠细节。现在的公司 toB ,反正业务维护得下去就行性能无所谓。然后我才接触到,json 里还区分 not assign 和 null 有区别的。谷歌说的 often 其实在大部分情况是成立的,比如 10 个字段 9 个不区分这样。 作为一种语言,把自己的理念强行安排给所有人这事 Go 干的不少。
    说回现在我们是怎么解决的,就是多包一个类似 Optional 的实现,并且实现对应的自己的 validator 。完全可以实现楼主所说的功能。在泛型没出来之前是每个基础类型包一下,幸好现在出来了,但是历史代码还是有点不雅。
    Jammar
        100
    Jammar  
       2023-09-20 10:08:16 +08:00   ❤️ 1
    哪里坑了?这么多年不一直是这么解析?有时候多找找自己的原因好吧,有没有认真学习,技能有没有提升
    1  2  3  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2225 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 37ms · UTC 05:04 · PVG 13:04 · LAX 21:04 · JFK 00:04
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.