【Go Web开发】校验JSON内容

备注:【Go Web开发】是一个从零开始创建关于电影管理的Web项目。

在许多情况下,您需要对来自客户端的数据执行额外的验证或检查,以确保它在处理之前满足特定的业务规则。在本文中,我们将通过更新createMovieHandler来演示如何在JSON API的上下文中做到这一点:

  • 客户端提供的movie标题不为空,长度不超过500字节。
  • movie的year字段不能是空的,而且是在1888年到今年之间。
  • runtime字段不能空,而且是一个正数。
  • genres字段包含1-5个不同的电影类型。

如果其中任何一个检查失败,我们希望向客户端发送一个422 Unprocessable Entity响应,以及清楚地描述验证失败的错误消息。

创建validator包

为了在整个项目中帮助我们进行验证,将创建一个internal/validator包,其中包含一些简单的可重用的帮助类型和函数。如果您正在跟随本文操作,请在您的机器上创建以下目录和文件:

$ mkdir internal/validator
$ touch internal/validator/validator.go

然后在文件internal/validator/validator.go中添加如下代码:

package validator

import "regexp"

var (
        //申明一个正则表达式用于检查email的格式,如果你有兴趣该正则表达式来自于“https://html.spec.whatwg.org/#valid-e-mail-address”网站。
    EmailRx = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\. [a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
)

//定义一个新的Validator类型,其中包含验证错误的map。
type Validator struct {
    Errors map[string]string
}

//New是一个构造函数,用于创建Validator实例
func New() *Validator {
    return &Validator{Errors: make(map[string]string)}
}

//valid 返回true如果map中没有错误
func (v *Validator) Valid() bool {
    return len(v.Errors) == 0
}

//AddError 向map中添加错误(map中不存在对应key的错误)
func (v *Validator) AddError(key, message string) {
    if _, exists := v.Errors[key]; !exists {
        v.Errors[key] = message
    }
}

//Check 向map中添加错误消息,如果校验失败即ok为false
func (v *Validator) Check(ok bool, key, message string) {
    if !ok {
        v.AddError(key, message)
    }
}

//In 如果list切片中存在value字符串返回true
func In(value string, list ...string) bool {
    for i := range list {
        if value == list[i] {
            return true
        }
    }
    return false
}

//Match 如果字符串满足正则表达式就返回true
func Matches(value string, rx *regexp.Regexp) bool {
    return rx.MatchString(value)
}

//如果切片中的字符串都不同返回true
func Unique(values []string) bool {
    uniqueValues := make(map[string]bool)
    for _, value := range values {
        uniqueValues[value] = true
    }
    return len(values) == len(uniqueValues)
}

总结:

在上面的代码中定义了Validator类型,包含一个存储错误信息map字段。Validator提供了Check()方法,根据校验结果向map中添加错误信息,而Valid()方法返回map是否包含错误信息。还添加了In(), Matches()和Unique()方法来帮助我们执行特定字段的检查。

从概念上讲,这个Validator类型是非常简单的,但这并不是一件坏事。正如我们将在其他地方看到的,它在开发中功能强大,为我们提供了很多灵活性的字段检查。

执行字段检查

下面我们把validator类型使用起来。

我们需要做的第一件事是更新cmd/api/errors.go文件,添加一个新的failedValidationResponse()帮助函数,它将写入一个422 Unprocessable Entity错误码,并将来自新Validator类型的错误内容映射为JSON响应体。

File: cmd/api/errors.go


package main

...

//注意errors参数是一个map类型,和validator类型包含map一致
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
    app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
}

完成之后,返回到createMovieHandler并更新它,以对input结构体各个字段进行必要的检查。像这样:

File:cmd/api/movies.go


package main

import (
    "fmt"
    "net/http"
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator"
)

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string       `json:"title"`
        Year    int32        `json:"year"`
        Runtime data.Runtime `json:"runtime"`
        Genres  []string     `json:"genres"`
    }
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    movie := &data.Movie{
        Title:   input.Title,
        Year:    input.Year,
        Runtime: input.Runtime,
        Genres:  input.Genres,
    }
        //初始化一个新的Validator实例
    v := validator.New()
  
    //使用Check()方法执行字段校验。如果校验失败就会向map中添加错误信息。例如下面第一行检查title不能为空,然后再检查长度不能超过500字节等等。
    v.Check(movie.Title != "", "title", "must be provide")
    v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long")

    v.Check(movie.Year != 0, "year", "must be provided")
    v.Check(movie.Year >= 1888, "year", "must be greater than 1888")
    v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not be in the future")

    v.Check(movie.Runtime != 0, "runtime", "must be provided")
    v.Check(movie.Runtime > 0, "runtime", "must be a positive integer")

    v.Check(movie != nil, "genres", "must be provided")
    v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genres")
    v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres")
    //使用Unique()方法,检查input.Genres每个字段是否唯一。
    v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values")

    //使用Valid()方法确认检查是否通过。如果有错误就使用failedValidationResponse()帮助函数返回错误信息给客户端。
   if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }
    fmt.Fprintf(w, "%+v\n", input)
}

做完这个之后,我们就可以试一下了。重新启动服务,然后向post /v1/movie接口发送请求,其中包含一些不合法的字段信息,类似下面:

$ BODY='{"title":"","year":1000,"runtime":"-123 mins","genres":["sci-fi","sci-fi"]}' $ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Wed, 07 Apr 2021 10:33:57 GMT 
Content-Length: 180

{
    "error":
    {
        "genres": "must not contain duplicate values",
        "runtime": "must be a positive integer",
        "title": "must be provided",
        "year": "must be greater than 1888"
    }
}

看起来不错。我们的检查功能生效了,并阻止请求被执行—甚至更好的是,向客户端返回一个格式良好的JSON响应,其中包含针对每个检验错误的详细信息。

你也可以发送正常的请求体,你会发现请求被正常处理,input内容在响应中返回给客户端:

$ BODY='{"title":"Moana","year":2016,"runtime":"107 mins","genres":["animation","adventure"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 23 Nov 2021 12:33:45 GMT
Content-Length: 65
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:2016 Runtime:107 Genres:[animation adventure]}

使校验规则可重用

在大型项目中,很多个接口需要重复这种校验的过程,因此将上面的校验规则抽象成方法供其他地方使用。比如客户端要更新movie也会传一些新的字段内容,也需要校验。

避免重复,我们可以将movie的校验整合到一个单独的ValidateMovie()函数中去。理论上,这个函数可以放在任意位置。但就个人而言,我喜欢将验证检查放在internal/data包中的相关领域类型附近。

如果按照下面的步骤操作,请重新打开internal/data/movies.go然后添加一个ValidateMovie()函数,其中包含如下检查:

File: internal/data/movies.go


package data

import (
    "encoding/json"
    "fmt"
    "greenlight.alexedwards.net/internal/validator"
    "time"
)

type Movie struct {
    ID       int64     `json:"id"`
    CreateAt time.Time `json:"-"`
    Title    string    `json:"title"`
    Year     int32     `json:"year,omitempty"`
    Runtime  Runtime   `json:"runtime,omitempty,string"`
    Genres   []string  `json:"genres,omitempty"`
    Version  int32     `json:"version"`
}

func ValidateMovie(v *validator.Validator, movie *Movie) {
    v.Check(movie.Title != "", "title", "must be provide")
    v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long")

    v.Check(movie.Year != 0, "year", "must be provided")
    v.Check(movie.Year >= 1888, "year", "must be greater than 1888")
    v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not be in the future")

    v.Check(movie.Runtime != 0, "runtime", "must be provided")
    v.Check(movie.Runtime > 0, "runtime", "must be a positive integer")

    v.Check(movie != nil, "genres", "must be provided")
    v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genres")
    v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres")
    v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values")
}

重要提示:现在检查是对一个movie结构体实例各个字段进行的,而不是对input结构体。

完成上面的改造之后,我们需要返回createMovieHandler并更新代码,通过初始化一个新的Movie结构体,从input结构体复制数据到movie结构体中,然后调用这个新的验证函数。像这样:

File:cmd/api/movies.go


package main

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string       `json:"title"`
        Year    int32        `json:"year"`
        Runtime data.Runtime `json:"runtime"`
        Genres  []string     `json:"genres"`
    }
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    movie := &data.Movie{
        Title:   input.Title,
        Year:    input.Year,
        Runtime: input.Runtime,
        Genres:  input.Genres,
    }

        //初始化Validator实例
    v := validator.New()

    //调用ValidateMovie()函数,如果有错误就返回给客户端。
    if data.ValidateMovie(v, movie); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }
    fmt.Fprintf(w, "%+v\n", input)
}

当您查看这些代码时,您的脑海中可能会有几个问题。

首先,您可能想知道为什么我们在处理程序中初始化Validator实例并将其传递给ValidateMovie()函数——而不是在ValidateMovie()中初始化它并将其作为返回值传递回来。

这是因为随着应用程序变得越来越复杂,我们将需要从处理程序调用多个校验帮助函数,而不是像上面所示的就一个。因此,在处理程序中初始化Validator,然后传递给帮助函数,这给了我们更多的灵活性。

您可能还想知道,为什么我们要将JSON请求解码为input结构体类型,然后复制数据,而不是直接解码为Movie结构体实例。

因为movie里面有些字段例如ID和Version是不需要客户端提供的,如果使用movie的话,客户端提供ID和Verison字段也会被解码到movie结构体中,这就需要多余的检查工作。

但是将客户端的请求内容解析到一个临时的结构体中,会更灵活,简洁而且代码更健壮。

有了这些解释,您应该能够再次启动应用程序,并且从客户端的角度来看,效果应该与之前的一样。如果你发起一个无效的请求,你应该会得到一个包含类似这样的错误消息的响应:

$ BODY='{"title":"","year":1000,"runtime":"-123 mins","genres":["sci-fi","sci-fi"]}' 
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Wed, 07 Apr 2021 10:33:57 GMT 
Content-Length: 180

{
    "error":
    {
        "genres": "must not contain duplicate values",
        "runtime": "must be a positive integer",
        "title": "must be provided",
        "year": "must be greater than 1888"
    }
}

您可以随意测试,并尝试在JSON中发送不同的值,直到所有的校验都按预期工作为止。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容