Go 框架 Gin使用 validator 若干實用技巧

技术颜良發表於2024-09-18

Go 框架 Gin使用 validator 若干實用技巧

網管叨bi叨
2024年09月17日 10:39 北京

以下文章來源於李文周 ,作者李文周

李文周.

一個北漂程式設計師的自我更新之旅。

在web開發中一個不可避免的環節就是對請求引數進行校驗,通常我們會在程式碼中定義與請求引數相對應的模型(結構體),藉助模型繫結快捷地解析請求中的引數,例如 gin 框架中的BindShouldBind系列方法。本文就以 gin 框架的請求引數校驗為例,介紹一些validator庫的實用技巧。

gin框架使用github.com/go-playground/validator進行引數校驗,目前已經支援github.com/go-playground/validator/v10了,我們需要在定義結構體時使用 binding tag標識相關校驗規則,可以檢視validator文件檢視支援的所有 tag。

基本示例

首先來看gin框架內建使用validator做引數校驗的基本示例。

package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

type SignUpParam struct {
Age uint8 `json:"age" binding:"gte=1,lte=130"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}

func main() {
r := gin.Default()

r.POST("/signup", func(c *gin.Context) {
var u SignUpParam
if err := c.ShouldBind(&u); err != nil {
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
return
}
// 儲存入庫等業務邏輯程式碼...

c.JSON(http.StatusOK, "success")
})

_ = r.Run(":8999")
}

我們使用curl傳送一個POST請求測試下:

curl -H "Content-type: application/json" -X POST -d '{"name":"q1mi","age":18,"email":"123.com"}' http://127.0.0.1:8999/signup

輸出結果:

{"msg":"Key: 'SignUpParam.Email' Error:Field validation for 'Email' failed on the 'email' tag\nKey: 'SignUpParam.Password' Error:Field validation for 'Password' failed on the 'required' tag\nKey: 'SignUpParam.RePassword' Error:Field validation for 'RePassword' failed on the 'required' tag"}

從最終的輸出結果可以看到 validator 的檢驗生效了,但是錯誤提示的欄位不是特別友好,我們可能需要將它翻譯成中文。

翻譯校驗錯誤提示資訊

validator庫本身是支援國際化的,藉助相應的語言包可以實現校驗錯誤提示資訊的自動翻譯。下面的示例程式碼演示瞭如何將錯誤提示資訊翻譯成中文,翻譯成其他語言的方法類似。

package main

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/en"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
enTranslations "github.com/go-playground/validator/v10/translations/en"
zhTranslations "github.com/go-playground/validator/v10/translations/zh"
)

// 定義一個全域性翻譯器T
var trans ut.Translator

// InitTrans 初始化翻譯器
func InitTrans(locale string) (err error) {
// 修改gin框架中的Validator引擎屬性,實現自定製
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {

zhT := zh.New() // 中文翻譯器
enT := en.New() // 英文翻譯器

// 第一個引數是備用(fallback)的語言環境
// 後面的引數是應該支援的語言環境(支援多個)
// uni := ut.New(zhT, zhT) 也是可以的
uni := ut.New(enT, zhT, enT)

// locale 通常取決於 http 請求頭的 'Accept-Language'
var ok bool
// 也可以使用 uni.FindTranslator(...) 傳入多個locale進行查詢
trans, ok = uni.GetTranslator(locale)
if !ok {
return fmt.Errorf("uni.GetTranslator(%s) failed", locale)
}

// 註冊翻譯器
switch locale {
case "en":
err = enTranslations.RegisterDefaultTranslations(v, trans)
case "zh":
err = zhTranslations.RegisterDefaultTranslations(v, trans)
default:
err = enTranslations.RegisterDefaultTranslations(v, trans)
}
return
}
return
}

type SignUpParam struct {
Age uint8 `json:"age" binding:"gte=1,lte=130"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}

func main() {
if err := InitTrans("zh"); err != nil {
fmt.Printf("init trans failed, err:%v\n", err)
return
}

r := gin.Default()

r.POST("/signup", func(c *gin.Context) {
var u SignUpParam
if err := c.ShouldBind(&u); err != nil {
// 獲取validator.ValidationErrors型別的errors
errs, ok := err.(validator.ValidationErrors)
if !ok {
// 非validator.ValidationErrors型別錯誤直接返回
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
return
}
// validator.ValidationErrors型別錯誤則進行翻譯
c.JSON(http.StatusOK, gin.H{
"msg":errs.Translate(trans),
})
return
}
// 儲存入庫等具體業務邏輯程式碼...

c.JSON(http.StatusOK, "success")
})

_ = r.Run(":8999")
}

同樣的請求再來一次:

curl -H "Content-type: application/json" -X POST -d '{"name":"q1mi","age":18,"email":"123.com"}' http://127.0.0.1:8999/signup

這一次的輸出結果如下:

{"msg":{"SignUpParam.Email":"Email必須是一個有效的郵箱","SignUpParam.Password":"Password為必填欄位","SignUpParam.RePassword":"RePassword為必填欄位"}}

自定義錯誤提示資訊的欄位名

上面的錯誤提示看起來是可以了,但是還是差點意思,首先是錯誤提示中的欄位並不是請求中使用的欄位,例如:RePassword是我們後端定義的結構體中的欄位名,而請求中使用的是re_password欄位。如何是錯誤提示中的欄位使用自定義的名稱,例如jsontag指定的值呢?

只需要在初始化翻譯器的時候像下面一樣新增一個獲取json tag的自定義方法即可。

// InitTrans 初始化翻譯器
func InitTrans(locale string) (err error) {
// 修改gin框架中的Validator引擎屬性,實現自定製
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {

// 註冊一個獲取json tag的自定義方法
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})

zhT := zh.New() // 中文翻譯器
enT := en.New() // 英文翻譯器

// 第一個引數是備用(fallback)的語言環境
// 後面的引數是應該支援的語言環境(支援多個)
// uni := ut.New(zhT, zhT) 也是可以的
uni := ut.New(enT, zhT, enT)

// ... liwenzhou.com ...
}

再嘗試發請求,看一下效果:

{"msg":{"SignUpParam.email":"email必須是一個有效的郵箱","SignUpParam.password":"password為必填欄位","SignUpParam.re_password":"re_password為必填欄位"}}

可以看到現在錯誤提示資訊中使用的就是我們結構體中jsontag設定的名稱了。

但是還是有點瑕疵,那就是最終的錯誤提示資訊中心還是有我們後端定義的結構體名稱——SignUpParam,這個名稱其實是不需要隨錯誤提示返回給前端的,前端並不需要這個值。我們需要想辦法把它去掉。

這裡參考https://github.com/go-playground/validator/issues/633#issuecomment-654382345提供的方法,定義一個去掉結構體名稱字首的自定義方法:

func removeTopStruct(fields map[string]string) map[string]string {
res := map[string]string{}
for field, err := range fields {
res[field[strings.Index(field, ".")+1:]] = err
}
return res
}

我們在程式碼中使用上述函式將翻譯後的errors做一下處理即可:

if err := c.ShouldBind(&u); err != nil {
// 獲取validator.ValidationErrors型別的errors
errs, ok := err.(validator.ValidationErrors)
if !ok {
// 非validator.ValidationErrors型別錯誤直接返回
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
return
}
// validator.ValidationErrors型別錯誤則進行翻譯
// 並使用removeTopStruct函式去除欄位名中的結構體名稱標識
c.JSON(http.StatusOK, gin.H{
"msg": removeTopStruct(errs.Translate(trans)),
})
return
}

看一下最終的效果:

{"msg":{"email":"email必須是一個有效的郵箱","password":"password為必填欄位","re_password":"re_password為必填欄位"}}

這一次看起來就比較符合我們預期的標準了。

自定義結構體校驗方法

上面的校驗還是有點小問題,就是當涉及到一些複雜的校驗規則,比如re_password欄位需要與password欄位的值相等這樣的校驗規則,我們的自定義錯誤提示欄位名稱方法就不能很好解決錯誤提示資訊中的其他欄位名稱了。

curl -H "Content-type: application/json" -X POST -d '{"name":"q1mi","age":18,"email":"123.com","password":"123","re_password":"321"}' http://127.0.0.1:8999/signup

最後輸出的錯誤提示資訊如下:

{"msg":{"email":"email必須是一個有效的郵箱","re_password":"re_password必須等於Password"}}

可以看到re_password欄位的提示資訊中還是出現了Password這個結構體欄位名稱。這有點小小的遺憾,畢竟自定義欄位名稱的方法不能影響被當成param傳入的值。

此時如果想要追求更好的提示效果,將上面的Password欄位也改為和json tag一致的名稱,就需要我們自定義結構體校驗的方法。

例如,我們為SignUpParam自定義一個校驗方法如下:

// SignUpParamStructLevelValidation 自定義SignUpParam結構體校驗函式
func SignUpParamStructLevelValidation(sl validator.StructLevel) {
su := sl.Current().Interface().(SignUpParam)

if su.Password != su.RePassword {
// 輸出錯誤提示資訊,最後一個引數就是傳遞的param
sl.ReportError(su.RePassword, "re_password", "RePassword", "eqfield", "password")
}
}

然後在初始化校驗器的函式中註冊該自定義校驗方法即可:

func InitTrans(locale string) (err error) {
// 修改gin框架中的Validator引擎屬性,實現自定製
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {

// ... liwenzhou.com ...

// 為SignUpParam註冊自定義校驗方法
v.RegisterStructValidation(SignUpParamStructLevelValidation, SignUpParam{})

zhT := zh.New() // 中文翻譯器
enT := en.New() // 英文翻譯器

// ... liwenzhou.com ...
}

最終再請求一次,看一下效果:

{"msg":{"email":"email必須是一個有效的郵箱","re_password":"re_password必須等於password"}}

這一次re_password欄位的錯誤提示資訊就符合我們預期了。

自定義欄位校驗方法

除了上面介紹到的自定義結構體校驗方法,validator還支援為某個欄位自定義校驗方法,並使用RegisterValidation()註冊到校驗器例項中。

接下來我們來為SignUpParam新增一個需要使用自定義校驗方法checkDate做引數校驗的欄位Date

type SignUpParam struct {
Age uint8 `json:"age" binding:"gte=1,lte=130"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
RePassword string `json:"re_password" binding:"required,eqfield=Password"`
// 需要使用自定義校驗方法checkDate做引數校驗的欄位Date
Date string `json:"date" binding:"required,datetime=2006-01-02,checkDate"`
}

其中datetime=2006-01-02是內建的用於校驗日期類引數是否滿足指定格式要求的tag。 如果傳入的date引數不滿足2006-01-02這種格式就會提示如下錯誤:

{"msg":{"date":"date的格式必須是2006-01-02"}}

針對date欄位除了內建的datetime=2006-01-02提供的格式要求外,假設我們還要求該欄位的時間必須是一個未來的時間(晚於當前時間),像這樣針對某個欄位的特殊校驗需求就需要我們使用自定義欄位校驗方法了。

首先我們要在需要執行自定義校驗的欄位後面新增自定義tag,這裡使用的是checkDate,注意使用英文分號分隔開。

// customFunc 自定義欄位級別校驗方法
func customFunc(fl validator.FieldLevel) bool {
date, err := time.Parse("2006-01-02", fl.Field().String())
if err != nil {
return false
}
if date.Before(time.Now()) {
return false
}
return true
}

定義好了欄位及其自定義校驗方法後,就需要將它們聯絡起來並註冊到我們的校驗器例項中。

// 在校驗器註冊自定義的校驗方法
if err := v.RegisterValidation("checkDate", customFunc); err != nil {
return err
}

這樣,我們就可以對請求引數中date欄位執行自定義的checkDate進行校驗了。 我們傳送如下請求測試一下:

curl -H "Content-type: application/json" -X POST -d '{"name":"q1mi","age":18,"email":"123@qq.com","password":"123", "re_password": "123", "date":"2020-01-02"}' http://127.0.0.1:8999/signup

此時得到的響應結果是:

{"msg":{"date":"Key: 'SignUpParam.date' Error:Field validation for 'date' failed on the 'checkDate' tag"}}

這...自定義欄位級別的校驗方法的錯誤提示資訊很“簡單粗暴”,和我們上面的中文提示風格有出入,必須想辦法搞定它呀!

自定義翻譯方法

我們現在需要為自定義欄位校驗方法提供一個自定義的翻譯方法,從而實現該欄位錯誤提示資訊的自定義顯示。

// registerTranslator 為自定義欄位新增翻譯功能
func registerTranslator(tag string, msg string) validator.RegisterTranslationsFunc {
return func(trans ut.Translator) error {
if err := trans.Add(tag, msg, false); err != nil {
return err
}
return nil
}
}

// translate 自定義欄位的翻譯方法
func translate(trans ut.Translator, fe validator.FieldError) string {
msg, err := trans.T(fe.Tag(), fe.Field())
if err != nil {
panic(fe.(error).Error())
}
return msg
}

定義好了相關翻譯方法之後,我們在InitTrans函式中透過呼叫RegisterTranslation()方法來註冊我們自定義的翻譯方法。

// InitTrans 初始化翻譯器
func InitTrans(locale string) (err error) {
// ...liwenzhou.com...

// 註冊翻譯器
switch locale {
case "en":
err = enTranslations.RegisterDefaultTranslations(v, trans)
case "zh":
err = zhTranslations.RegisterDefaultTranslations(v, trans)
default:
err = enTranslations.RegisterDefaultTranslations(v, trans)
}
if err != nil {
return err
}
// 注意!因為這裡會使用到trans例項
// 所以這一步註冊要放到trans初始化的後面
if err := v.RegisterTranslation(
"checkDate",
trans,
registerTranslator("checkDate", "{0}必須要晚於當前日期"),
translate,
); err != nil {
return err
}
return
}
return
}

這樣再次嘗試傳送請求,就能得到想要的錯誤提示資訊了。

{"msg":{"date":"date必須要晚於當前日期"}}

總結

本文總結的gin框架中validator的使用技巧同樣也適用於直接使用validator庫,區別僅僅在於我們配置的是gin框架中的校驗器還是由validator.New()建立的校驗器。同時使用validator庫確實能夠在一定程度上減少我們的編碼量,但是它不太可能完美解決我們所有需求,所以你需要找到兩者之間的平衡點。

網管叨bi叨
分享軟體開發和系統架構設計基礎、Go 語言和Kubernetes。
298篇原創內容

最後真心推薦一下我前段時間寫的付費專欄,該專欄主要用一些實際的案例講解專案業務需求分析、技術實現分析、模組劃分和分層的方法論,講明白這些底層邏輯後再教大家怎麼用 UML 等工具把它們圖形化表達出來。詳細內容請可掃碼觀看,或訪問:獨家原創--程式設計師的全能畫圖課

圖片

另外現在我在做的一個Go實戰專案的專欄,整個專案也採用了這裡講的知識來實際地一步步做分析和開發的,大家可以先把這些方法論學會,後面跟著新專欄學習,看專案和程式碼的實踐能明顯提高自己做專案的架構水平。

閱讀原文
閱讀 1265
寫留言
寫留言
留言

暫無留言

相關文章