備注:【Go Web開(kāi)發(fā)】是一個(gè)從零開(kāi)始創(chuàng)建關(guān)于電影管理的Web項(xiàng)目。
在許多情況下谷誓,您需要對(duì)來(lái)自客戶端的數(shù)據(jù)執(zhí)行額外的驗(yàn)證或檢查绒障,以確保它在處理之前滿足特定的業(yè)務(wù)規(guī)則。在本文中捍歪,我們將通過(guò)更新createMovieHandler來(lái)演示如何在JSON API的上下文中做到這一點(diǎn):
- 客戶端提供的movie標(biāo)題不為空户辱,長(zhǎng)度不超過(guò)500字節(jié)。
- movie的year字段不能是空的糙臼,而且是在1888年到今年之間庐镐。
- runtime字段不能空,而且是一個(gè)正數(shù)变逃。
- genres字段包含1-5個(gè)不同的電影類型必逆。
如果其中任何一個(gè)檢查失敗,我們希望向客戶端發(fā)送一個(gè)422 Unprocessable Entity響應(yīng)揽乱,以及清楚地描述驗(yàn)證失敗的錯(cuò)誤消息名眉。
創(chuàng)建validator包
為了在整個(gè)項(xiàng)目中幫助我們進(jìn)行驗(yàn)證,將創(chuàng)建一個(gè)internal/validator包凰棉,其中包含一些簡(jiǎn)單的可重用的幫助類型和函數(shù)损拢。如果您正在跟隨本文操作,請(qǐng)?jiān)谀臋C(jī)器上創(chuàng)建以下目錄和文件:
$ mkdir internal/validator
$ touch internal/validator/validator.go
然后在文件internal/validator/validator.go中添加如下代碼:
package validator
import "regexp"
var (
//申明一個(gè)正則表達(dá)式用于檢查email的格式撒犀,如果你有興趣該正則表達(dá)式來(lái)自于“https://html.spec.whatwg.org/#valid-e-mail-address”網(wǎng)站福压。
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])?)*$")
)
//定義一個(gè)新的Validator類型,其中包含驗(yàn)證錯(cuò)誤的map或舞。
type Validator struct {
Errors map[string]string
}
//New是一個(gè)構(gòu)造函數(shù)荆姆,用于創(chuàng)建Validator實(shí)例
func New() *Validator {
return &Validator{Errors: make(map[string]string)}
}
//valid 返回true如果map中沒(méi)有錯(cuò)誤
func (v *Validator) Valid() bool {
return len(v.Errors) == 0
}
//AddError 向map中添加錯(cuò)誤(map中不存在對(duì)應(yīng)key的錯(cuò)誤)
func (v *Validator) AddError(key, message string) {
if _, exists := v.Errors[key]; !exists {
v.Errors[key] = message
}
}
//Check 向map中添加錯(cuò)誤消息,如果校驗(yàn)失敗即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 如果字符串滿足正則表達(dá)式就返回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)
}
總結(jié):
在上面的代碼中定義了Validator類型映凳,包含一個(gè)存儲(chǔ)錯(cuò)誤信息map字段胆筒。Validator提供了Check()方法,根據(jù)校驗(yàn)結(jié)果向map中添加錯(cuò)誤信息魏宽,而Valid()方法返回map是否包含錯(cuò)誤信息腐泻。還添加了In(), Matches()和Unique()方法來(lái)幫助我們執(zhí)行特定字段的檢查。
從概念上講队询,這個(gè)Validator類型是非常簡(jiǎn)單的派桩,但這并不是一件壞事。正如我們將在其他地方看到的蚌斩,它在開(kāi)發(fā)中功能強(qiáng)大铆惑,為我們提供了很多靈活性的字段檢查。
執(zhí)行字段檢查
下面我們把validator類型使用起來(lái)。
我們需要做的第一件事是更新cmd/api/errors.go文件员魏,添加一個(gè)新的failedValidationResponse()幫助函數(shù)丑蛤,它將寫入一個(gè)422 Unprocessable Entity錯(cuò)誤碼,并將來(lái)自新Validator類型的錯(cuò)誤內(nèi)容映射為JSON響應(yīng)體撕阎。
File: cmd/api/errors.go
package main
...
//注意errors參數(shù)是一個(gè)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并更新它虏束,以對(duì)input結(jié)構(gòu)體各個(gè)字段進(jìn)行必要的檢查棉饶。像這樣:
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,
}
//初始化一個(gè)新的Validator實(shí)例
v := validator.New()
//使用Check()方法執(zhí)行字段校驗(yàn)。如果校驗(yàn)失敗就會(huì)向map中添加錯(cuò)誤信息镇匀。例如下面第一行檢查title不能為空照藻,然后再檢查長(zhǎng)度不能超過(guò)500字節(jié)等等。
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每個(gè)字段是否唯一幸缕。
v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values")
//使用Valid()方法確認(rèn)檢查是否通過(guò)。如果有錯(cuò)誤就使用failedValidationResponse()幫助函數(shù)返回錯(cuò)誤信息給客戶端晰韵。
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
fmt.Fprintf(w, "%+v\n", input)
}
做完這個(gè)之后发乔,我們就可以試一下了。重新啟動(dòng)服務(wù)宫屠,然后向post /v1/movie接口發(fā)送請(qǐng)求列疗,其中包含一些不合法的字段信息滑蚯,類似下面:
$ 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"
}
}
看起來(lái)不錯(cuò)浪蹂。我們的檢查功能生效了,并阻止請(qǐng)求被執(zhí)行—甚至更好的是告材,向客戶端返回一個(gè)格式良好的JSON響應(yīng)坤次,其中包含針對(duì)每個(gè)檢驗(yàn)錯(cuò)誤的詳細(xì)信息。
你也可以發(fā)送正常的請(qǐng)求體斥赋,你會(huì)發(fā)現(xiàn)請(qǐng)求被正常處理缰猴,input內(nèi)容在響應(yīng)中返回給客戶端:
$ 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]}
使校驗(yàn)規(guī)則可重用
在大型項(xiàng)目中,很多個(gè)接口需要重復(fù)這種校驗(yàn)的過(guò)程疤剑,因此將上面的校驗(yàn)規(guī)則抽象成方法供其他地方使用滑绒。比如客戶端要更新movie也會(huì)傳一些新的字段內(nèi)容,也需要校驗(yàn)隘膘。
避免重復(fù)疑故,我們可以將movie的校驗(yàn)整合到一個(gè)單獨(dú)的ValidateMovie()函數(shù)中去。理論上弯菊,這個(gè)函數(shù)可以放在任意位置纵势。但就個(gè)人而言,我喜歡將驗(yàn)證檢查放在internal/data包中的相關(guān)領(lǐng)域類型附近。
如果按照下面的步驟操作钦铁,請(qǐng)重新打開(kāi)internal/data/movies.go然后添加一個(gè)ValidateMovie()函數(shù)软舌,其中包含如下檢查:
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")
}
重要提示:現(xiàn)在檢查是對(duì)一個(gè)movie結(jié)構(gòu)體實(shí)例各個(gè)字段進(jìn)行的,而不是對(duì)input結(jié)構(gòu)體牛曹。
完成上面的改造之后佛点,我們需要返回createMovieHandler并更新代碼,通過(guò)初始化一個(gè)新的Movie結(jié)構(gòu)體黎比,從input結(jié)構(gòu)體復(fù)制數(shù)據(jù)到movie結(jié)構(gòu)體中恋脚,然后調(diào)用這個(gè)新的驗(yàn)證函數(shù)。像這樣:
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實(shí)例
v := validator.New()
//調(diào)用ValidateMovie()函數(shù)焰手,如果有錯(cuò)誤就返回給客戶端糟描。
if data.ValidateMovie(v, movie); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
fmt.Fprintf(w, "%+v\n", input)
}
當(dāng)您查看這些代碼時(shí),您的腦海中可能會(huì)有幾個(gè)問(wèn)題书妻。
首先船响,您可能想知道為什么我們?cè)谔幚沓绦蛑谐跏蓟疺alidator實(shí)例并將其傳遞給ValidateMovie()函數(shù)——而不是在ValidateMovie()中初始化它并將其作為返回值傳遞回來(lái)。
這是因?yàn)殡S著應(yīng)用程序變得越來(lái)越復(fù)雜躲履,我們將需要從處理程序調(diào)用多個(gè)校驗(yàn)幫助函數(shù)见间,而不是像上面所示的就一個(gè)。因此工猜,在處理程序中初始化Validator米诉,然后傳遞給幫助函數(shù),這給了我們更多的靈活性篷帅。
您可能還想知道史侣,為什么我們要將JSON請(qǐng)求解碼為input結(jié)構(gòu)體類型,然后復(fù)制數(shù)據(jù)魏身,而不是直接解碼為Movie結(jié)構(gòu)體實(shí)例惊橱。
因?yàn)閙ovie里面有些字段例如ID和Version是不需要客戶端提供的,如果使用movie的話箭昵,客戶端提供ID和Verison字段也會(huì)被解碼到movie結(jié)構(gòu)體中税朴,這就需要多余的檢查工作。
但是將客戶端的請(qǐng)求內(nèi)容解析到一個(gè)臨時(shí)的結(jié)構(gòu)體中家制,會(huì)更靈活正林,簡(jiǎn)潔而且代碼更健壯。
有了這些解釋颤殴,您應(yīng)該能夠再次啟動(dòng)應(yīng)用程序觅廓,并且從客戶端的角度來(lái)看,效果應(yīng)該與之前的一樣诅病。如果你發(fā)起一個(gè)無(wú)效的請(qǐng)求哪亿,你應(yīng)該會(huì)得到一個(gè)包含類似這樣的錯(cuò)誤消息的響應(yīng):
$ 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"
}
}
您可以隨意測(cè)試粥烁,并嘗試在JSON中發(fā)送不同的值,直到所有的校驗(yàn)都按預(yù)期工作為止蝇棉。