阅读 391

Gin框架中处理请求参数的零值问题

Gin 框架处理前端请求的时候,使用 ShouldBindXXX 绑定参数/数据到结构体上是一种比较常用的取数据手段,但在一些情况下,可能会出现问题

例如,现在有一个 /user/update 接口,用于更新用户的 年龄昵称,即接收两个字段:age(int)nick_name(string),并且这两个字段并不要求必须同时传递,可以两个都传,也可以只传其中一个,后端从请求中解析这两个参数,取到哪个字段就对哪个字段进行更新

type User struct {
	Age      int    `json:"age"`
	NickName string `json:"nick_name"`
}
复制代码

两个字段都传递那还好说,但如果只传其中一个字段,并且后端用 ShouldBindXXX 来绑定数据到结构体,就可能会出现问题了

func HandlerUpdate(c *gin.Context) {
	var user User
	if err := c.ShouldBind(&user); err != nil {
		// ...
	}
}
复制代码

如果前端只传了一个 nick_name 字段,没传 age 字段,那么user.Age 的值就是零值,即 0ShouldBindXXX 并不判断这个 0 到底是零值还是前端真的传了 0

这个问题解决起来倒也简单,两个方法

一是将结构体内的字段改成指针类型

type User struct {
	Age      *int    `json:"age"`
	NickName *string `json:"nick_name"`
}
复制代码

指针的零值 nilShouldBindXXX 之后,字段值为 nil 的自然就是没传值的

但将结构体所有的字段都定义为指针类型未免有些不符合习惯,并且操作指针也不方便,也更容易出错(例如空指针问题)

第二个是办法是借助 map

ShouldBindXXX 有问题的话那我大不了不用了,直接将参数(GET)/数据(POST)映射到 map 就行

但这样的话就会引出另外一个问题,ShouldBindXXX 方法一个显著的好处是可以根据结构体里定义的 tag规则来对字段进行校验,如果你直接读到 map 中就要自己实现字段校验逻辑了,字段少点还好,要是多了得写一大串的 if...else 或者是干脆要实现一个通用校验方法了,未免繁琐

所以想到用 ShouldBindXXX 来做校验,再借助 map 用于区分零值,即对请求传递的数据读了两次

GET请求为例:

func HandlerUpdate(c *gin.Context) {
	var user User
	// 用 ShouldBind 作校验
	if err := c.ShouldBind(&user); err != nil {
		fmt.Printf("genGetMap ShouldBind error: %v\n", err)
		return
	}
	// 请求真正传递的参数映射到 map 中
	allMap := map[string]interface{}{}
	urlvalues := c.Request.URL.Query()
	for k, urls := range urlvalues {
		fmt.Printf("\n genGetMap k, urls, %v, %v\n", k, urls)
		// 重复值则取最后一个
		allMap[k] = urls[len(urls)-1]
	}
}
复制代码

截至目前,只是校验并获取到了请求的数据,下一步还要进行更新数据库的操作,这里以 gorm 为例

因为 user 只是用于校验请求的数据是否合法,无法判断零值,所以不能直接以 user 为基础操作数据库

// 可能因零值问题导致出现不符合预期的结果
db.Save(&user)
复制代码

allMap 可以分辨出请求到底携带了哪些参数/数据,但可能存在一些额外不需要的数据,例如当希望更新用户的 agenick_name 属性的时候,操作的数据表是 db_user,而这个数据表中除了 agenick_name两列外,还存在用于标识用户是否注销了的 is_del 列,那么按照如下更新方式也是会出问题的:

db.Model(&user).Updates(allMap)
复制代码

如果 allMap 中存在 is_del 属性,那么也会更新数据表中的 is_del 字段,并不是预期的结果,所以需要将 allMap 中不需要的属性去掉,可以复制出一份只包含所需更新属性的 map,也可以直接删除掉 allMap 上额外的属性只保留所需的,这里以前一种为例

allMap := make(map[string]interface{})
realMap := make(map[string]interface{})
if v, ok := allMap["age"]; ok {
	realMap["age"] = v
}
if v, ok := allMap["nick_name"]; ok {
	realMap["nick_name"] = v
}
db.Model(&user).Updates(realMap)
复制代码

这里只有 agenick_name 两个字段所以还好,但如果所需更新的字段最多在 5个以上就要写最多 5个条件语句了,未免繁琐,可以借助 reflect 处理,无论存在多少个需要更新的字段,代码量都是一样的

realMap := make(map[string]interface{})
typ := reflect.TypeOf(user).Elem()
for i := 0; i < typ.NumField(); i++ {
	tagName := typ.Field(i).Tag.Get("json")
	if v, isOK := allMap[tagName]; isOK {
		realMap[tagName] = v
	}
}
db.Model(&user).Updates(realMap)
复制代码

完整代码:

// 将请求的参数映射到 m 中, 如果是 GET,返回 query 参数组成的 map;如果是 POST,返回请求体里的数据
//
// instance: 指向具体结构体实例的指针, 作用是获取结构体中每个字段名为 `json` 的 tag,以映射 map
func GenMapByStruct(c *gin.Context, instance interface{}, m *map[string]interface{}) error {
	if c.ContentType() != gin.MIMEJSON {
		return errors.New("content-type must be " + gin.MIMEJSON)
	}
	if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodPost {
		return errors.New("method must be GET or POST")
	}
	allMap := map[string]interface{}{}
	if c.Request.Method == http.MethodGet {
		if err := genGetMap(c, instance, &allMap); err != nil {
			return err
		}
	} else {
		if err := genPostMap(c, instance, &allMap); err != nil {
			return err
		}
	}
	typ := reflect.TypeOf(instance).Elem()
	for i := 0; i < typ.NumField(); i++ {
		tagName := typ.Field(i).Tag.Get("json")
		if v, isOK := allMap[tagName]; isOK {
			(*m)[tagName] = v
		}
	}
	return nil
}

// 从 get 请求中获取query,并将 query 处理成 map 映射到 allMap 中
func genGetMap(c *gin.Context, instance interface{}, allMap *map[string]interface{}) error {
	if err := c.ShouldBind(instance); err != nil {
		fmt.Printf("genGetMap ShouldBind error: %v\n", err)
		return err
	}
	urlvalues := c.Request.URL.Query()
	for k, urls := range urlvalues {
		fmt.Printf("\n genGetMap k, urls, %v, %v\n", k, urls)
		// 重复值则取最后一个
		(*allMap)[k] = urls[len(urls)-1]
	}
	return nil
}

// 从 post 请求中获取 body,并将 body 反序列化到 allMap 中
func genPostMap(c *gin.Context, instance interface{}, allMap *map[string]interface{}) error {
	// shouldBind 会导致 body 无法再次读取,方便起见这里使用了 ShouldBindBodyWith
	if err := c.ShouldBindBodyWith(instance, binding.JSON); err != nil {
		fmt.Printf("genPostMap ShouldBind error: %v\n", err)
		return err
	}
	body, _ := c.Get(gin.BodyBytesKey)
	var bodyByte []byte
	var ok bool
	if bodyByte, ok = body.([]byte); !ok {
		return errors.New("body is invalid")
	}
	if len(bodyByte) == 0 {
		return nil
	}
	if err := json.Unmarshal(bodyByte, allMap); err != nil {
		return err
	}
	return nil
}
复制代码

使用示例:

type User struct {
	Age      int    `json:"age"`
	NickName string `json:"nick_name"`
}
r.Any("/update", func(c *gin.Context) {
	m := map[string]interface{}{}
	var user User
	if err := GenMapByStruct(c, &s, &m); err != nil {
		c.JSON(http.StatusOK, gin.H{"code": -1, "message": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"code": 0, "data": &m})
})
复制代码

可以看到,因为存在更多的计算过程,所以处理请求零值的情况,会带来更高的资源消耗,所以应该尽可能避免这种情况的出现,相比于在后端额外处理,让客户端携带完整的所需参数才是更优解

文章分类
后端
文章标签