首頁 > 軟體

Golang 經典校驗庫 validator 用法解析

2022-08-26 14:03:05

開篇

今天繼續我們的 Golang 經典開源庫學習之旅,這篇文章的主角是 validator,Golang 中經典的校驗庫,它可以讓開發者可以很便捷地通過 tag 來控制對結構體欄位的校驗,使用面非常廣泛。

本來打算一節收尾,越寫越發現 validator 整體複雜度還是很高的,而且支援了很多場景。可拆解的思路很多,於是打算分成兩篇文章來講。這篇我們會先來了解 validator 的用法,下一篇我們會關注實現的思路和原始碼解析。

validator

Package validator implements value validations for structs and individual fields based on tags.

validator 是一個結構體引數驗證器。

它提供了【基於 tag 對結構體以及單獨屬性的校驗能力】。經典的 gin 框架就是用了 validator 作為預設的校驗器。它的能力能夠幫助開發者最大程度地減少【基礎校驗】的程式碼,你只需要一個 tag 就能完成校驗。完整的檔案參照 這裡

目前 validator 最新版本已經升級到了 v10,我們可以用

go get github.com/go-playground/validator/v10

新增依賴後,import 進來即可

import "github.com/go-playground/validator/v10"

我們先來看一個簡單的例子,瞭解 validator 能怎樣幫助開發者完成校驗。

package main
import (
	"fmt"
	"github.com/go-playground/validator/v10"
)
type User struct {
	Name string `validate:"min=6,max=10"`
	Age  int    `validate:"min=1,max=100"`
}
func main() {
	validate := validator.New()
	u1 := User{Name: "lidajun", Age: 18}
	err := validate.Struct(u1)
	fmt.Println(err)
	u2 := User{Name: "dj", Age: 101}
	err = validate.Struct(u2)
	fmt.Println(err)
}

這裡我們有一個 User 結構體,我們希望 Name 這個字串長度在 [6, 10] 這個區間內,並且希望 Age 這個數位在 [1, 100] 區間內。就可以用上面這個 tag。

校驗的時候只需要三步:

  • 呼叫 validator.New() 初始化一個校驗器;
  • 將【待校驗的結構體】傳入我們的校驗器的 Struct 方法中;
  • 校驗返回的 error 是否為 nil 即可。

上面的例子中,lidajun 長度符合預期,18 這個 Age 也在區間內,預期 err 為 nil。而第二個用例 Name 和 Age 都在區間外。我們執行一下看看結果:

<nil>
Key: 'User.Name' Error:Field validation for 'Name' failed on the 'min' tag
Key: 'User.Age' Error:Field validation for 'Age' failed on the 'max' tag

這裡我們也可以看到,validator 返回的報錯資訊包含了 Field 名稱 以及 tag 名稱,這樣我們也容易判斷哪個校驗沒過。

如果沒有 tag,我們自己手寫的話,還需要這樣處理:

func validate(u User) bool {
	if u.Age < 1 || u.Age > 100 {
		return false
	}
	if len(u.Name) < 6 || len(u.Name) > 10 {
		return false
	}
	return true
}

乍一看好像區別不大,其實一旦結構體屬性變多,校驗規則變複雜,這個校驗函數的代價立刻會上升,另外你還要顯示的處理報錯資訊,以達到上面這樣清晰的效果(這個手寫的範例程式碼只返回了一個 bool,不好判斷是哪個沒過)。

越是大結構體,越是規則複雜,validator 的收益就越高。我們還可以把 validator 放到中介軟體裡面,對所有請求加上校驗,用的越多,效果越明顯。

其實筆者個人使用經驗來看,validator 帶來的另外兩個好處在於:

  • 因為需要經常使用校驗能力,養成了習慣,每定義一個結構,都事先想好每個屬性應該有哪些約束,促使開發者思考自己的模型。這一點非常重要,很多時候我們就是太隨意定義一些結構,沒有對應的校驗,結果導致各種髒資料,把校驗邏輯一路下沉;
  • 有了 tag 來描述約束規則,讓結構體本身更容易理解,可讀性,可維護性提高。一看結構體,掃幾眼 tag 就知道業務對它的預期。

這兩個點雖然比較【意識流】,但在開發習慣上還是很重要的。

好了,到目前只是淺嘗輒止,下面我們結合範例看看 validator 到底提供了哪些能力。

使用方法

我們上一節舉的例子就是最簡單的場景,在一個 struct 中定義好 validate:"xxx" tag,然後呼叫校驗器的 err := validate.Struct(user) 方法來校驗。

這一節我們結合範例來看看最常用的場景下,我們會怎樣用 validator:

package main
import (
	"fmt"
	"github.com/go-playground/validator/v10"
)
// User contains user information
type User struct {
	FirstName      string     `validate:"required"`
	LastName       string     `validate:"required"`
	Age            uint8      `validate:"gte=0,lte=130"`
	Email          string     `validate:"required,email"`
	FavouriteColor string     `validate:"iscolor"`                // alias for 'hexcolor|rgb|rgba|hsl|hsla'
	Addresses      []*Address `validate:"required,dive,required"` // a person can have a home and cottage...
}
// Address houses a users address information
type Address struct {
	Street string `validate:"required"`
	City   string `validate:"required"`
	Planet string `validate:"required"`
	Phone  string `validate:"required"`
}
// use a single instance of Validate, it caches struct info
var validate *validator.Validate
func main() {
	validate = validator.New()
	validateStruct()
	validateVariable()
}
func validateStruct() {
	address := &Address{
		Street: "Eavesdown Docks",
		Planet: "Persphone",
		Phone:  "none",
	}
	user := &User{
		FirstName:      "Badger",
		LastName:       "Smith",
		Age:            135,
		Email:          "Badger.Smith@gmail.com",
		FavouriteColor: "#000-",
		Addresses:      []*Address{address},
	}
	// returns nil or ValidationErrors ( []FieldError )
	err := validate.Struct(user)
	if err != nil {
		// this check is only needed when your code could produce
		// an invalid value for validation such as interface with nil
		// value most including myself do not usually have code like this.
		if _, ok := err.(*validator.InvalidValidationError); ok {
			fmt.Println(err)
			return
		}
		for _, err := range err.(validator.ValidationErrors) {
			fmt.Println(err.Namespace())
			fmt.Println(err.Field())
			fmt.Println(err.StructNamespace())
			fmt.Println(err.StructField())
			fmt.Println(err.Tag())
			fmt.Println(err.ActualTag())
			fmt.Println(err.Kind())
			fmt.Println(err.Type())
			fmt.Println(err.Value())
			fmt.Println(err.Param())
			fmt.Println()
		}
		// from here you can create your own error messages in whatever language you wish
		return
	}
	// save user to database
}
func validateVariable() {
	myEmail := "joeybloggs.gmail.com"
	errs := validate.Var(myEmail, "required,email")
	if errs != nil {
		fmt.Println(errs) // output: Key: "" Error:Field validation for "" failed on the "email" tag
		return
	}
	// email ok, move on
}

仔細觀察你會發現,第一步永遠是建立一個校驗器,一個 validator.New() 解決問題,後續一定要複用,內部有快取機制,效率比較高。

關鍵在第二步,大體上分為兩類:

  • 基於結構體呼叫 err := validate.Struct(user) 來校驗;
  • 基於變數呼叫 errs := validate.Var(myEmail, "required,email")

結構體校驗這個相信看完這個範例,大家已經很熟悉了。

變數校驗這裡很有意思,用起來確實簡單,大家看 validateVariable 這個範例就 ok,但是,但是,我只有一個變數,我為啥還要用這個 validator 啊?

原因很簡單,不要以為 validator 只能幹一些及其簡單的,比大小,比長度,判空邏輯。這些非常基礎的校驗用一個 if 語句也搞定。

validator 支援的校驗規則遠比這些豐富的多。

我們先把前面範例的結構體拿出來,看看支援哪些 tag:

// User contains user information
type User struct {
	FirstName      string     `validate:"required"`
	LastName       string     `validate:"required"`
	Age            uint8      `validate:"gte=0,lte=130"`
	Email          string     `validate:"required,email"`
	FavouriteColor string     `validate:"iscolor"`                // alias for 'hexcolor|rgb|rgba|hsl|hsla'
	Addresses      []*Address `validate:"required,dive,required"` // a person can have a home and cottage...
}
// Address houses a users address information
type Address struct {
	Street string `validate:"required"`
	City   string `validate:"required"`
	Planet string `validate:"required"`
	Phone  string `validate:"required"`
}

格式都是 validate:"xxx",這裡不再說,關鍵是裡面的設定。

validator 中如果你針對同一個 Field,有多個校驗項,可以用下面兩種運運算元:

  • , 逗號表示【與】,即每一個都需要滿足;
  • | 表示【或】,多個條件滿足一個即可。

我們一個個來看這個 User 結構體出現的 tag:

  • required 要求必須有值,不為空;
  • gte=0,lte=130 其中 gte 代表大於等於,lte 代表小於等於,這個語意是 [0,130] 區間;
  • required, emal 不僅僅要有值,還得符合 Email 格式;
  • iscolor 後面註釋也提了,這是個別名,本質等價於 hexcolor|rgb|rgba|hsl|hsla,屬於 validator 自帶的別名能力,符合這幾個規則任一的,我們都認為屬於表示顏色。
  • required,dive,required 這個 dive 大有來頭,注意這個 Addresses 是個 Address 陣列,我們加 tag 一般只是針對單獨的資料型別,這種【容器型】的怎麼辦?

這時 dive 的能力就派上用場了。

dive 的語意在於告訴 validator 不要停留在我這一級,而是繼續往下校驗,無論是 slice, array 還是 map,校驗要用的 tag 就是在 dive 之後的這個。

這樣說可能不直觀,我們來看一個例子:

[][]string with validation tag "gt=0,dive,len=1,dive,required"
// gt=0 will be applied to []
// len=1 will be applied to []string
// required will be applied to string

第一個 gt=0 適用於最外層的陣列,出現 dive 後,往下走,len=1 作為一個 tag 適用於內層的 []string,此後又出現 dive,繼續往下走,對於最內層的每個 string,要求每個都是 required。

[][]string with validation tag "gt=0,dive,dive,required"
// gt=0 will be applied to []
// []string will be spared validation
// required will be applied to string

第二個例子,看看能不能理解?

其實,只要記住,每次出現 dive,都往裡面走就 ok。

回到我們一開始的例子:

Addresses []*Address validate:"required,dive,required"

表示的意思是,我們要求 Addresses 這個陣列是 required,此外對於每個元素,也得是 required。

內建校驗器

validator 對於下面六種場景都提供了豐富的校驗器,放到 tag 裡就能用。這裡我們簡單看一下:

(注:想看完整的建議參考檔案 以及倉庫 README

1. Fields

對於結構體各個屬性的校驗,這裡可以針對一個 field 與另一個 field 相互比較。

2. Network

網路相關的格式校驗,可以用來校驗 IP 格式,TCP, UDP, URL 等

3. Strings

字串相關的校驗,用的非常多,比如校驗是否是數位,大小寫,前字尾等,非常方便。

4. Formats

符合特定格式,如我們上面提到的 email,信用卡號,顏色,html,base64,json,經緯度,md5 等

5. Comparisons

比較大小,用的很多

6. Other

雜項,各種通用能力,用的也非常多,我們上面用的 required 就在這一節。包括校驗是否為預設值,最大,最小等。

7. 別名

除了上面的六個大類,還包含兩個內部封裝的別名校驗器,我們已經用過 iscolor,還有國家碼:

錯誤處理

Golang 的 error 是個 interface,預設其實只提供了 Error() 這一個方法,返回一個字串,能力比較雞肋。同樣的,validator 返回的錯誤資訊也是個字串:

Key: 'User.Name' Error:Field validation for 'Name' failed on the 'min' tag

這樣當然不錯,但問題在於,線上環境下,很多時候我們並不是【人工地】來閱讀錯誤資訊,這裡的 error 最終是要轉化成錯誤資訊展現給使用者,或者打點上報的。

我們需要有能力解析出來,是哪個結構體的哪個屬性有問題,哪個 tag 攔截了。怎麼辦?

其實 validator 返回的型別底層是 validator.ValidationErrors,我們可以在判空之後,用它來進行型別斷言,將 error 型別轉化過來再判斷:

err := validate.Struct(mystruct)
validationErrors := err.(validator.ValidationErrors)

底層的結構我們看一下:

// ValidationErrors is an array of FieldError's
// for use in custom error messages post validation.
type ValidationErrors []FieldError
// Error is intended for use in development + debugging and not intended to be a production error message.
// It allows ValidationErrors to subscribe to the Error interface.
// All information to create an error message specific to your application is contained within
// the FieldError found within the ValidationErrors array
func (ve ValidationErrors) Error() string {
	buff := bytes.NewBufferString("")
	var fe *fieldError
	for i := 0; i &lt; len(ve); i++ {
		fe = ve[i].(*fieldError)
		buff.WriteString(fe.Error())
		buff.WriteString("n")
	}
	return strings.TrimSpace(buff.String())
}

這裡可以看到,所謂 ValidationErrors 其實一組 FieldError,所謂 FieldError 就是每一個屬性的報錯,我們的 ValidationErrors 實現的 func Error() string 方法,也是將各個 fieldError(對 FieldError 介面的預設實現)連線起來,最後 TrimSpace 清掉空格展示。

在我們拿到了 ValidationErrors 後,可以遍歷各個 FieldError,拿到業務需要的資訊,用來做紀錄檔列印/打點上報/錯誤碼對照等,這裡是個 interface,大家各取所需即可:

// FieldError contains all functions to get error details
type FieldError interface {
	// Tag returns the validation tag that failed. if the
	// validation was an alias, this will return the
	// alias name and not the underlying tag that failed.
	//
	// eg. alias "iscolor": "hexcolor|rgb|rgba|hsl|hsla"
	// will return "iscolor"
	Tag() string
	// ActualTag returns the validation tag that failed, even if an
	// alias the actual tag within the alias will be returned.
	// If an 'or' validation fails the entire or will be returned.
	//
	// eg. alias "iscolor": "hexcolor|rgb|rgba|hsl|hsla"
	// will return "hexcolor|rgb|rgba|hsl|hsla"
	ActualTag() string
	// Namespace returns the namespace for the field error, with the tag
	// name taking precedence over the field's actual name.
	//
	// eg. JSON name "User.fname"
	//
	// See StructNamespace() for a version that returns actual names.
	//
	// NOTE: this field can be blank when validating a single primitive field
	// using validate.Field(...) as there is no way to extract it's name
	Namespace() string
	// StructNamespace returns the namespace for the field error, with the field's
	// actual name.
	//
	// eq. "User.FirstName" see Namespace for comparison
	//
	// NOTE: this field can be blank when validating a single primitive field
	// using validate.Field(...) as there is no way to extract its name
	StructNamespace() string
	// Field returns the fields name with the tag name taking precedence over the
	// field's actual name.
	//
	// eq. JSON name "fname"
	// see StructField for comparison
	Field() string
	// StructField returns the field's actual name from the struct, when able to determine.
	//
	// eq.  "FirstName"
	// see Field for comparison
	StructField() string
	// Value returns the actual field's value in case needed for creating the error
	// message
	Value() interface{}
	// Param returns the param value, in string form for comparison; this will also
	// help with generating an error message
	Param() string
	// Kind returns the Field's reflect Kind
	//
	// eg. time.Time's kind is a struct
	Kind() reflect.Kind
	// Type returns the Field's reflect Type
	//
	// eg. time.Time's type is time.Time
	Type() reflect.Type
	// Translate returns the FieldError's translated error
	// from the provided 'ut.Translator' and registered 'TranslationFunc'
	//
	// NOTE: if no registered translator can be found it returns the same as
	// calling fe.Error()
	Translate(ut ut.Translator) string
	// Error returns the FieldError's message
	Error() string
}

小結

今天我們瞭解了 validator 的用法,其實整體還是非常簡潔的,我們只需要全域性維護一個 validator 範例,內部會幫我們做好快取。此後只需要把結構體傳入,就可以完成校驗,並提供可以解析的錯誤。

validator 的實現也非常精巧,只不過內容太多,我們今天暫時覆蓋不到,更多關於Go 校驗庫validator 的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com