解析 Golang 經典校驗庫 validator 用法

https://juejin.cn/post/7135803728916905997

開篇

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

validator

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

validator[1] 是一個結構體參數驗證器。

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

目前 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。

校驗的時候只需要三步:

  1. 調用 validator.New() 初始化一個校驗器;

  2. 將【待校驗的結構體】傳入我們的校驗器的 Struct 方法中;

  3. 校驗返回的 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 帶來的另外兩個好處在於:

  1. 因爲需要經常使用校驗能力,養成了習慣,每定義一個結構,都事先想好每個屬性應該有哪些約束,促使開發者思考自己的模型。這一點非常重要,很多時候我們就是太隨意定義一些結構,沒有對應的校驗,結果導致各種髒數據,把校驗邏輯一路下沉;

  2. 有了 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() 解決問題,後續一定要複用,內部有緩存機制,效率比較高。

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

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

變量校驗這裏很有意思,用起來確實簡單,大家看 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:

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

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 裏就能用。這裏我們簡單看一下:

(注:想看完整的建議參考文檔 [5] 以及倉庫 README[6])

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 < 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'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 實例,內部會幫我們做好緩存。此後只需要把結構體傳入,就可以完成校驗,並提供可以解析的錯誤。

參考資料

[1]

validator: https://github.com/go-playground/validator

[2]

gin: https://github.com/gin-gonic/gin

[3]

這裏: https://pkg.go.dev/github.com/go-playground/validator/v10

[4]

dive: https://pkg.go.dev/github.com/go-playground/validator/v10#hdr-Dive

[5]

文檔: https://pkg.go.dev/github.com/go-playground/validator/v10#hdr-Baked_In_Validators_and_Tags

[6]

README: https://github.com/go-playground/validator

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5Pvf5_hHBqKaHxYtjjAEjg