备注:【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中发送不同的值,直到所有的校验都按预期工作为止。