Go 語言 validator 請求校驗

2023-03-22 15:03 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-04-validator.html


5.4 validator 請求校驗

社區(qū)里曾經(jīng)有人用 圖 5-10 來嘲笑 PHP:


圖 5-10 validator 流程

這其實是一個語言無關(guān)的場景,需要進(jìn)行字段校驗的情況有很多,Web 系統(tǒng)的 Form 或 JSON 提交只是一個典型的例子。我們用 Go 來寫一個類似上圖的校驗示例。然后研究怎么一步步對其進(jìn)行改進(jìn)。

5.4.1 重構(gòu)請求校驗函數(shù)

假設(shè)我們的數(shù)據(jù)已經(jīng)通過某個開源綁定庫綁定到了具體的結(jié)構(gòu)體上。

type RegisterReq struct {
    Username       string   `json:"username"`
    PasswordNew    string   `json:"password_new"`
    PasswordRepeat string   `json:"password_repeat"`
    Email          string   `json:"email"`
}

func register(req RegisterReq) error{
    if len(req.Username) > 0 {
        if len(req.PasswordNew) > 0 && len(req.PasswordRepeat) > 0 {
            if req.PasswordNew == req.PasswordRepeat {
                if emailFormatValid(req.Email) {
                    createUser()
                    return nil
                } else {
                    return errors.New("invalid email")
                }
            } else {
                return errors.New("password and reinput must be the same")
            }
        } else {
            return errors.New("password and password reinput must be longer than 0")
        }
    } else {
        return errors.New("length of username cannot be 0")
    }
}

我們用 Go 里成功寫出了波動拳開路的箭頭型代碼。。這種代碼一般怎么進(jìn)行優(yōu)化呢?

很簡單,在《重構(gòu)》一書中已經(jīng)給出了方案:Guard Clauses

func register(req RegisterReq) error{
    if len(req.Username) == 0 {
        return errors.New("length of username cannot be 0")
    }

    if len(req.PasswordNew) == 0 || len(req.PasswordRepeat) == 0 {
        return errors.New("password and password reinput must be longer than 0")
    }

    if req.PasswordNew != req.PasswordRepeat {
        return errors.New("password and reinput must be the same")
    }

    if emailFormatValid(req.Email) {
        return errors.New("invalid email")
    }

    createUser()
    return nil
}

代碼更清爽,看起來也不那么別扭了。這是比較通用的重構(gòu)理念。雖然使用了重構(gòu)方法使我們的校驗過程代碼看起來優(yōu)雅了,但我們還是得為每一個 http 請求都去寫這么一套差不多的 validate() 函數(shù),有沒有更好的辦法來幫助我們解除這項體力勞動?答案就是 validator。

5.4.2 用 validator 解放體力勞動

從設(shè)計的角度講,我們一定會為每個請求都聲明一個結(jié)構(gòu)體。前文中提到的校驗場景我們都可以通過 validator 完成工作。還以前文中的結(jié)構(gòu)體為例。為了美觀起見,我們先把 json tag 省略掉。

這里我們引入一個新的 validator 庫:

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

使用 go get github.com/go-playground/validator/v10 可以下載 validator 庫。

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

type RegisterReq struct {
    // 字符串的 gt=0 表示長度必須 > 0,gt = greater than
    Username       string   `validate:"gt=0"`
    // 同上
    PasswordNew    string   `validate:"gt=0"`
    // eqfield 跨字段相等校驗
    PasswordRepeat string   `validate:"eqfield=PasswordNew"`
    // 合法 email 格式校驗
    Email          string   `validate:"email"`
}

var validate = validator.New()

func validateFunc(req RegisterReq) error {
    err := validate.Struct(req)
    if err != nil {
        doSomething()
        return err
    }
    ...
}

這樣就不需要在每個請求進(jìn)入業(yè)務(wù)邏輯之前都寫重復(fù)的 validate() 函數(shù)了。本例中只列出了這個校驗器非常簡單的幾個功能。

我們試著跑一下這個程序,輸入?yún)?shù)設(shè)置為:

//...

var req = RegisterReq {
    Username       : "Xargin",
    PasswordNew    : "ohno",
    PasswordRepeat : "ohn",
    Email          : "alex@abc.com",
}

err := validateFunc(req)
fmt.Println(err)

// Key: 'RegisterReq.PasswordRepeat' Error:Field validation for
// 'PasswordRepeat' failed on the 'eqfield' tag

如果覺得這個 validator 提供的錯誤信息不夠人性化,例如要把錯誤信息返回給用戶,那就不應(yīng)該直接顯示英文了??梢葬槍γ糠N tag 進(jìn)行錯誤信息定制,讀者可以自行探索。

5.4.3 原理

從結(jié)構(gòu)上來看,每一個結(jié)構(gòu)體都可以看成是一棵樹。假如我們有如下定義的結(jié)構(gòu)體:

type Nested struct {
    Email string `validate:"email"`
}
type T struct {
    Age	int `validate:"eq=10"`
    Nested Nested
}

把這個結(jié)構(gòu)體畫成一棵樹,見 圖 5-11


圖 5-11 validator 樹

從字段校驗的需求來講,無論我們采用深度優(yōu)先搜索還是廣度優(yōu)先搜索來對這棵結(jié)構(gòu)體樹來進(jìn)行遍歷,都是可以的。

我們來寫一個遞歸的深度優(yōu)先搜索方式的遍歷示例:

package main

import (
    "fmt"
    "reflect"
    "regexp"
    "strconv"
    "strings"
)

type Nested struct {
    Email string `validate:"email"`
}
type T struct {
    Age	int `validate:"eq=10"`
    Nested Nested
}

func validateEmail(input string) bool {
    if pass, _ := regexp.MatchString(
        `^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})
, input,
    ); pass {
        return true
    }
    return false
}

func validate(v interface{}) (bool, string) {
    validateResult := true
    errmsg := "success"
    vt := reflect.TypeOf(v)
    vv := reflect.ValueOf(v)
    for i := 0; i <vv.NumField(); i++ {
        fieldVal := vv.Field(i)
        tagContent := vt.Field(i).Tag.Get("validate")
        k := fieldVal.Kind()

        switch k {
        case reflect.Int:
            val := fieldVal.Int()
            tagValStr := strings.Split(tagContent, "=")
            tagVal, _ := strconv.ParseInt(tagValStr[1], 10, 64)
            if val != tagVal {
                errmsg = "validate int failed, tag is:"+ strconv.FormatInt(
                    tagVal, 10,
                )
                validateResult = false
            }
        case reflect.String:
            val := fieldVal.String()
            tagValStr := tagContent
            switch tagValStr {
            case "email":
                nestedResult := validateEmail(val)
                if nestedResult == false {
                    errmsg = "validate mail failed, field val is:"+ val
                    validateResult = false
                }
            }
        case reflect.Struct:
            // 如果有內(nèi)嵌的 struct,那么深度優(yōu)先遍歷
            // 就是一個遞歸過程
            valInter := fieldVal.Interface()
            nestedResult, msg := validate(valInter)
            if nestedResult == false {
                validateResult = false
                errmsg = msg
            }
        }
    }
    return validateResult, errmsg
}

func main() {
    var a = T{Age: 10, Nested: Nested{Email: "abc@abc.com"}}

    validateResult, errmsg := validate(a)
    fmt.Println(validateResult, errmsg)
}

這里我們簡單地對 eq=x 和 email 這兩個 tag 進(jìn)行了支持,讀者可以對這個程序進(jìn)行簡單的修改以查看具體的 validate 效果。為了演示精簡掉了錯誤處理和復(fù)雜情況的處理,例如 reflect.Int8/16/32/64,reflect.Ptr 等類型的處理,如果給生產(chǎn)環(huán)境編寫校驗庫的話,請務(wù)必做好功能的完善和容錯。

在前一小節(jié)中介紹的開源校驗組件在功能上要遠(yuǎn)比我們這里的例子復(fù)雜的多。但原理很簡單,就是用反射對結(jié)構(gòu)體進(jìn)行樹形遍歷。有心的讀者這時候可能會產(chǎn)生一個問題,我們對結(jié)構(gòu)體進(jìn)行校驗時大量使用了反射,而 Go 的反射在性能上不太出眾,有時甚至?xí)绊懙轿覀兂绦虻男阅堋_@樣的考慮確實有一些道理,但需要對結(jié)構(gòu)體進(jìn)行大量校驗的場景往往出現(xiàn)在 Web 服務(wù),這里并不一定是程序的性能瓶頸所在,實際的效果還是要從 pprof 中做更精確的判斷。

如果基于反射的校驗真的成為了你服務(wù)的性能瓶頸怎么辦?現(xiàn)在也有一種思路可以避免反射:使用 Go 內(nèi)置的 Parser 對源代碼進(jìn)行掃描,然后根據(jù)結(jié)構(gòu)體的定義生成校驗代碼。我們可以將所有需要校驗的結(jié)構(gòu)體放在單獨(dú)的包內(nèi)。這就交給讀者自己去探索了。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號