R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

Go 如何优雅的验证前端请求体

背景

从开始写 Go 到现在也过了两个月了。我现在负责的是两个新系统的后端项目,很多功能还没有搭起来,例如对 Request Body 的合法性检验。目前的校验规则都是手动写在业务代码中的,既复杂又不容易扩展。我觉得,在我用过的前端的框架中,Element UI 的验证规则是最优雅的,于是就想在后端也实现这么一套验证规则。

调研

Element UI 的表单验证使用了 async-validator,随便举几个例子:

{
    name: [
        { required: true, message: 'Forget your name, uh?' },
        { min: 2, max: 5, message: 'I have never seen such name!' }
    ],
    email: [{ type: 'email' }]
}

首先秉承着身为“代码搬运工”的职责,我去 GitHub 上面搜了一下,发现了两个项目:asaskevich/govalidatorthedevsaddam/govalidator。经过对比,虽然前者的 Star 更多,但我还是决定选择后者,理由大概有:

  1. 前者需要修改 struct 中的信息(例如 Email string `valid:"email"` ),但我们 API 用到的 struct 结构是用 Protobuf 编译出来的,并不能在 Tag 里面添加什么信息。
  2. 前者没法真正做到验证 required 规则,因为 Go 语言零值的问题,所以如果用 struct 来接收数据,我们就没法区分某个 int 字段的 0 是前端没有传过来,还是前端传了个 0 过来。(我能想到的解决方法就是用 map 来接收数据,但前者在 2017 年有人提了个 Issue 请求添加对 map 的验证功能,截至目前还没有 Close。)
  3. 我不带任何导向性(甚至没有提供任何选项)的问了一下之前写过 Go 的朋友:有什么好用的、用于验证的库。对方直接推荐了后者。

既然有现成的轮子(下文中的 govalidator 均指代后者),那只需要考虑如何将其整合到我的项目中了。我一开始想写一个 Gin 的中间件,但发现不太好处理“无法重复读取 Request Body”的问题。后来想到可以直接写一个函数来替换掉 ShouldBindJSON。既然有了思路,就开始吧!

实战

替换掉 ShouldBindJSON

项目中的 Route Handler 基本长这样:

func (r *Registry) UpdateXXX(c *gin.Context) {
    var xxx api.XXX
    if err := c.ShouldBindJSON(&xxx); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    // do sth with `xxx`
    // and return ok
    c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

因此我的函数可以这么写:

func validateAndBind(c *gin.Context, data interface{}, rules govalidator.MapData) error {
    // my validate function
}

这样就可以只将 Route Handler 中的 err := c.ShouldBindJSON(&xxx) 修改为 err := validateAndBind(c, &xxx, xxxRule) 了,完全不用做任何其它的修改。

使用 govalidator 来验证

接下来需要完成 validateAndBind 函数。由于 Go 语言零值的问题,我需要先用 map 来接收数据,然后使用 govalidator 来验证,通过验证则将其转为 struct,否则返回错误。看起来 govalidator 已经帮我们做了转换了(代码摘抄自 官方文档,有修改):

rules := govalidator.MapData{
    "username": []string{"required", "between:3,8"},
    "email":    []string{"required", "min:4", "max:20", "email"},
}

// mapResult is used for store unmarshalled json data
mapResult := make(map[string]interface{})

opts := govalidator.Options{
    Request: r,
    Rules:   rules,
    Data:    &mapResult,
}
validator := govalidator.New(opts)

// type of errsMap is map[string][]string from each error field to its error messages
// we only return the first error text
errsMap := validator.ValidateJSON()
for _, errText := range errsMap {
    return errors.New(errText[0])
}

// use mapstructure to convert mapResult to variable `data`
// set TagName to help decoder binding "role_id" key in JSON to "RoleID" field in struct
// use `mapstructure:"role_id"` works as well, but we've already had a `json:"role_id"` tag
// so no need to write tags for mapstructure again
decoderConfig := &mapstructure.DecoderConfig{
    Metadata:         nil,
    Result:           data,
    WeaklyTypedInput: true,
    TagName:          "json",
}
decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
    return err
}
return decoder.Decode(mapResult)

大概思路是:先新建一个 map 变量用于存放 govalidator 的结果,然后使用 mapstructure 将其转换为 struct。由于我们传进来的是一个 struct 的地址,因此数据会直接存进去。此外如果没有错误则返回 nil,有错误则及时返回。至于 errsMap 附近的那个循环,是因为我觉得给前端太多错误信息不好,只返回其中一个字段即可,这里最好看业务的要求。

补充真正的 required 规则

由于 govalidator 对于 required 的验证方式比较奇怪,不管是 map 中不存在某个 Key,还是对应的 Value 为零值,都会无法通过。虽然它提供了自己的类型用于替代 intfloat 之类的,但我们的 API struct 结构是用 Protobuf 编译的,因此也无法使用这些类型。

我本来想自己添加一个规则——对于已经存在的 Key,只有对应的 Value 是空数组 / 字符串 / JS Object 时才通不过,但 govalidator 禁止我们覆盖已有的规则,判断“不存在某个 Key”也用到了 "required" 这个字符串,因此我无法额外添加规则。

想了一下,我决定自行实现这条规则,于是 validatorAndBind 函数的大概思路就变成了:

  1. 记录 rules 里面需要判断 required 的字段,并将 required 从规则中删除;
  2. 保留之前对 govalidator 的调用代码;
  3. 手写 required 的校验;
  4. 保留之前将 map 转换为 struct 的代码。

第三步的校验需要做两件事情:一个是看 mapResult 里面有没有对应的字段,一个是这个字段值是不是空数组 / 字符串 / JS Object。整个函数大概的代码如下(省略了之前写过的内容):

// record and remove "required" rule due to govalidator's
// stupid strategy for "required" when passing zero value
requiredFields := make([]string, 0)
actualRules := govalidator.MapData{}
for key, value := range rules {
    for i, item := range value {
        if item == "required" {
            requiredFields = append(requiredFields, key)
        } else {
            actualRules[key] = append(actualRules[key], value[i])
        }
    }
}

// use govalidator to get request body and validate...
// use `actualRules` here instead of `rules`

// now validate "required" rule by myself
for _, field := range requiredFields {
    notExistErr := fmt.Errorf("The %s field is required", field)
    value, exists := mapResult[field]
    if !exists || value == nil {
        return notExistErr
    }
    rv := reflect.ValueOf(value)
    switch rv.Kind() {
    case reflect.String, reflect.Array, reflect.Slice, reflect.Map:
        if rv.Len() == 0 {
            return notExistErr
        }
    }
}

// convert map to struct...

大功告成!

踩坑

当存在 null 字段时的 panic

当前端 JSON 的某个字段为 null 时,govalidator 会直接 panic。究其原因,是因为 Go 的 JSON 库在 Unmarshal 的时候会将 null 转换为 Go 的 nil,加上 govalidator 在 Switch reflect.TypeOf(v) 的时候,没有判断 v 是否是 nil,如果是的话,Go 会直接 panic,因为这需要对 nil 尝试 dereference。

有个很简单的解决方法,就是在前端的 Axios Request Interceptor 中自行删除所有的 null 字段,但这样并不优雅,于是我给 govalidator 提了个 Pull Request,现在已经被作者合并到 dev 分支了(作者似乎想通过 dev 来自己管理版本,毕竟 Go 的包管理器默认拉取的是 master),等下一个版本就可以用了。


Update 2019.01.10

修复了最后一个代码块的 Bug,之前的代码由于 actualRules 里面的元素都是 rules 内元素的切片,因此 append 会修改 rules,导致只有第一次可以成功验证 required 规则。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。

这是我们共同度过的

第 1157 天