Gin + Gorm Best Practice (一)如何区分请求体中的零值(Zero-value)与未设置(Not-Set)字段

431 阅读2分钟

这个系列会更新在公司编写代码的时候遇到的一些 Golang 上的问题,涉及到 Gin + Gorm + Fizz 框架,敬请期待。

什么是零值与未设置值

在使用 Gin 框架写 RESTful API 的时候经常会有区分请求体中的零值和未设置字段的需求,因为这样两种 request body 的含义是不同的。以 RESTful 中的 Patch 请求(部分更新)为例:

  • 零值: json 字段的值为对应的 Golang 结构体字段的 zero-value,比如整型的 0,bool 类型的 false,string 类型的 "" 等。零值表示我希望更新这个字段为 0 值,也就是对于这个字段进行更改。
  • 未设置字段:json 字段中不存在这个字段,不设置这个字段证明我不希望更改这个字段。

举个例子,有Golang 结构体如下

type NIC struct {
	Name   string  `json:"name,omitempty"`
        IP     string  `json:"ip,omitempty"`
}

发送两个如下 request body 的 Patch 请求:

  1. 零值:更新 name 为 "test",更新 ip 为 "" (空字符串)

    {
        "name": "test",
        "ip": ""
    }
    
  2. 未设置字段:只更新 name 为 "test",不更新 ip

    {
        "name": "test",
    }
    

令人感到困惑的是,这两个 reqeust body 被 Gin json 解析之后得到的结果是相同的

package main

import (
	"encoding/json"
	"fmt"
)

type NIC struct {
	ID   uint   `json:"id,omitempty" binding:"-"`
	Name string `json:"name,omitempty"`
	IP   string `json:"ip,omitempty"`
}

func parse(b []byte) {
	var n NIC
	if err := json.Unmarshal(b, &n); err != nil {
		fmt.Println(err)
	}
	fmt.Println(n.Name, len(n.IP))
	return
}

func main() {
	zeroValue := []byte(`{"name": "test","ip": ""}`)
	notSet := []byte(`{"name": "test"}`)

	parse(zeroValue)
	parse(notSet)

	return
}

输出:

0 test 0
0 test 0

因此,在 Gin 场景下,我们无法区分出 request 是希望修改 ip 为空值还是不更改 ip

解决方法 —— 使用指针

这个解决方法的原理是当 golang 的 json 库在 parse json 到指针类型的时候,会创建一个新的对象并将其地址赋值到这个指针中。而如果没有这个字段,指针值会是 nil。因此,零值和未设置字段区分如下:

  • 零值:指针指向一个存放零值的区域
  • 未设置字段:为 nil

在 json 源码中有这样一个函数,看起来像是给所有结构体的指针类型分配内存的,具体没戏看了

// indirect walks down v allocating pointers as needed,
// until it gets to a non-pointer.
// If it encounters an Unmarshaler, indirect stops and returns that.
// If decodingNull is true, indirect stops at the first settable pointer so it
// can be set to nil.
func indirect(v reflect.Value, decodingNull bool) (Unmarshaler, encoding.TextUnmarshaler, reflect.Value) {
    ……
}

这样子解析出来的结果就是不同的:

……

type NIC struct {
	Name *string `json:"name,omitempty"`
	IP   *string `json:"ip,omitempty"`
}

func parse(b []byte) {
	var n NIC
	if err := json.Unmarshal(b, &n); err != nil {
		fmt.Println(err)
	}
	fmt.Println(*n.Name, n.IP)
	return
}

func main() {
    ……
}

输出结果:

test 0xc000014380 => 指向一个空字符串 ""
test <nil>

需要注意的是,在编写业务逻辑代码的时候需要时刻注意结构体的指针特性,避免出现 *n.IP 这种明显的会导致空指针解引用的问题出现。