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
的值就是零值,即 0
,ShouldBindXXX
并不判断这个 0
到底是零值还是前端真的传了 0
这个问题解决起来倒也简单,两个方法
一是将结构体内的字段改成指针类型
type User struct {
Age *int `json:"age"`
NickName *string `json:"nick_name"`
}
复制代码
指针的零值 nil
,ShouldBindXXX
之后,字段值为 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
可以分辨出请求到底携带了哪些参数/数据,但可能存在一些额外不需要的数据,例如当希望更新用户的 age
和 nick_name
属性的时候,操作的数据表是 db_user
,而这个数据表中除了 age
、nick_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)
复制代码
这里只有 age
、nick_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})
})
复制代码
可以看到,因为存在更多的计算过程,所以处理请求零值的情况,会带来更高的资源消耗,所以应该尽可能避免这种情况的出现,相比于在后端额外处理,让客户端携带完整的所需参数才是更优解