更好用、能擴充套件、支援多國語言提示的表單驗證類庫

joyant發表於2020-07-09

fire

更好用、能擴充套件、支援多國語言提示的表單驗證類庫,使用GoLang開發。

用法

我們先看一個最簡單的例子,這是fire類庫的Hello World

import (
    "fmt"
    "github.com/joyant/fire"
)

func main() {
    v := fire.New(fire.Rule{
        "name":"required|lengthBetween:5,10",
    })
    data := fire.Data{"name":"Tom"}
    qulified, err := v.Validate(data)
}

使用fire是簡單的。

傳入 fire.Data

fire.Datafire定義的資料型別,用於傳入資料用,我們可以直接使用它。

v := fire.New(fire.Rule{
    "name":"required",
})
data := fire.Data{"name":"Tom"}
v.Validate(data)

fire.Data本質上一個map[string]interface{}型別,所以我們也可以傳入map[string]interface{}型別。

v := fire.New(fire.Rule{
    "name":"required",
})
data := map[string]interface{}{"name":"Tom"}
v.Validate(data)

傳入 struct

type User struct {
    Name string
}
v := fire.New(fire.Rule{
    "name":"required",
})
u := User{Name:"Tom"}

傳入指標也是可以的,但是指標必須指向一個結構體。

u := &User{Name:"Tom"}
v.Validate(u)

除了fire.Datamap[string]interface{}、結構體以及指向結構體的指標外,Validate方法並不支援傳入其他型別的引數,如果傳入了錯誤型別的引數,返回的err不為空,檢查New函式返回的err是否為空是有必要的。

結構體 (struct) 與 Rule 的對映關係

在上一部分的例子裡,我們看到結構體的屬性是Name,而Rulekeyname,嚴格來說,它們並不相等 (用==判斷),那麼fire是如何將他們對應起來的呢?

fire會先將struct的屬性按照一定的規則轉為Rulekey,當這些規則都用完了還沒有找到對映關係,那fire就會 認為這個規則並不存在,規則如下:

Name       -> Name, name, name
FirstName  -> FirstName, first_name, firstname
First_Name -> First_Name, first_name, first_name

fire拿到一個key,它會依次尋找其原始、下劃線形式、全小寫在Rule是否有對應的key,如果都沒有找到,就會認為其對應的規則是空,也就是說這個屬性是不會被校驗的。

假設傳入Validate函式的引數是fire.Data{"name":"Tom"},規則是Rule{"Name":"required","name":"int"},fire會依次在Rule中尋找Namenamename,先找到誰,就認定那個驗證規則,在這個例子裡,規則就是required,而不會驗證name是否是int型別。

GoLang的世界裡,很多驗證類庫的方式都利用了tag,這樣做的好處是,key的定義非常清晰,沒有歧義;但也有弊端,它會使結構體中包含很多讓人眼花繚亂的tag,舉一個在真實的專案裡見過的例子:

type User struct {
    Name string `json:"name", validator:"name", form:"name", db:"name"`
}

在上面的結構體中,json標籤是用來序列化和反序列化用的,validator標籤是驗證表單用的,dbform也各有其用途,本來是一個"純粹"的結構體,在加入很多的tag後,結構體變得非常臃腫;我們在fire中偏愛"約定優於配置"的原則,這些約定都是比較符合一般做法和直覺的,不會令人覺得怪異,這樣就不大需要寫tag了。

但是,我們的約定可能無法滿足所有的要求,因為我們不總是從一個的專案的零步做起,很可能接手的是一個老專案,而那裡大概率已經有一些約定俗稱的規則了,我們不能因為引入了一個表單驗證的類庫就打破原來的規則,所以,fire也是支援tag的,當約定不能滿足要求的時候,可以將tag應用於特殊的需求,fire給了tag最高的優先順序,請看下面的例子:

type User struct {
    Name string `fire:"nickname"`
}

這樣name對應的key就是nickname,即使data有另外一個名為namekeyfire也不會去驗證它,因為我們 給了tag最高的優先順序。

如果你不喜歡fire這個預設的tag(也可能是想複用名為jsontag),可以通過以下設定修改:

fire.Tag = "json" // 也可以是其他任意設定的tag

驗證規則

我們把內建的規則都列出來:

token 用途 舉例 備註
alphaNum 英文字元或數字 alphaNum 英文字元或數字
alpha 英文字元 alpha 英文字元
between 數字大小必須在指定範圍內 between:1, 10
between:1.1, 9.9
左右包含
bool 布林值 bool 布林值的選項如下
1, t, T, true, TRUE, True
0, f, F, false, FALSE, False
contains 包含指定字元 contains:abc
contains:abc/i
字串裡必須包含 abc
abc 連在一起時才能通過驗證
abc/i 表示不區分大小寫
含有 ABC 的字串也能通過驗證
date 日期格式 date
date:2006-01-02
只寫一個 date 時,以下資料均能通過
2020
2020-01-02
2020-01-02 15:15:15
date 後面可以指定日期格式
2006
2006-01
2006-01-02
2006-01-02 15:04:05
different 和指定 key 對應的值不同 different:username 資料不能和 key 為 username
的資料相同
常用於判斷密碼不能和賬號相同
email 郵箱格式 email 郵箱格式
equals 與指定 key 的值相同 equals:confirm_password 必須與指定 key 的值相同
常用於二次輸入密碼和
第一次輸入是否相同的驗證
in 在指定字串之內 in:1,2,3 必須在 [1,2,3] 之內
會自動 trim 逗號之間的空白
notIn 不能在指定字串之內 notIn:1,2,3 不能在 [1,2,3] 之內
會自動 trim 逗號之間的空白
integer
int
整型 integer
int
必須是整型
ip ipv4 或 ipv6 格式 ip 必須是 ipv4 或 ipv6 格式
ipv4 ipv4 格式 ipv4 必須是 ipv4 格式
Ipv6 ipv6 格式 Ipv6 必須是 ipv6 格式
lengthBetween 字串長度在指定範圍之內 lengthBetween:1,10 字串長度在 1~10 之間
左右包含,utf8 編碼
lengthMax 字串長度最大範圍 lengthMax:10 字串長度不能超過 10
utf8 編碼
lengthMin 字串長度最小範圍 lengthMin:10 字串長度不能小於 10
utf8 編碼
length 字串長度必須是指定值 length:5 字串長度必須是 5,utf8 編碼
max 最大值 max:100 值不能超過 100
內部實現是轉成 float64 再比較的
min 最小值 min:10 值不能小於 10
內部實現是轉成 float64 再比較的
numeric 必須是數字(整數或小數) numeric 必須是數字(整數或小數)
100 和 99.99 都能通過驗證
regexp 匹配指定正規表示式 regexp:\\d+{1,10} 匹配 1~10 個數字
required
require
必填項 required
require
必填項
url 網址格式 url 必須是網址格式
http://www.abc.com
https://www.abc.com
http://abc.com
https://abc.com
www.abc.com
abc.com
abc.com/path1/path2?key=1
都可以通過驗證

需要注意的是,規則之間要用|分開,|:都要用半形輸入,比如我們可以設定以下規則來驗證:

rule := Rule{
    "username":"in:Tom,Jim,Jerry|lengthBetween:1,10|email|int|numeric|alpha",
}

內建的中英文提示

不能驗證通過時返回的提示資訊非常重要,它能明確告訴使用者哪個欄位不能同通過驗證,而不是籠統告訴使用者發生錯誤了。在fire內建的這些規則中,都有中英文提示,比如我們要驗證一個keybirthday的值,它不能通過日期格式(date)驗證時,返回的中文提示是date必須是日期格式,英文提示是date must be date format。在某些時候,我們可能不希望使用者看到中英文摻雜的文字提示,換句話說,我們想讓birthday有指定的中文翻譯,這時,我們就可以呼叫fireRegisterI18nDataKey函式:

fire.RegisterI18nDataKey(fire.DataKey("birthday"), map[fire.Lang]string{
    fire.LangZH:"生日"
})

這個函式的作用是為birthday註冊多語言的翻譯,這個時候我們就能看到全中文提示了生日必須是日期格式,如果你的使用者本來就是英文世界的,那就不需要呼叫以上的函式了,只需要把預設提示語言設定成英文就行了,設定方法有兩種:

//第一種方法,是全域性設定
fire.DefaultLang = fire.LangEN 
//第二種方法是以validator為單位設定,這個validator返回的驗證結果將會是英文提示,不會被全域性設定覆蓋
fire.New(fire.Rule{}, fire.LangEN) 

fire的預設語言設定是中文,即使正合你的需要,還是建議設定預設語言。我們內建了一些常量供開發者使用,這些常量是為開發擴充套件和設定訊息提供方便,並不是說fire內建了這些語言的提示,內建的只有中文和英文。

LangZH Lang = "zh" // 漢語
LangEN Lang = "en" // 英語
LangDE Lang = "de" // 德語
LangFR Lang = "fr" // 法語
LangRU Lang = "ru" // 俄語
LangES Lang = "es" // 西班牙語
LangJA Lang = "ja" // 日語
LangAR Lang = "ar" // 阿拉伯語
LangKR Lang = "kr" // 韓語
LangPT Lang = "pt" // 葡萄牙語

多國語言支援

假設你的專案有不少中東使用者,所以需要一套阿拉伯文的驗證結果提示,但是內建的語言只有中文和英文,該如果解決這個問題呢?我們還拿生日來舉例子,直接看程式碼:

fire.RegisterMsgFormat("birthday", map[fir.Lang]fire.MsgFormat{
    fire.LangAR:"${0} يجب أن يكون شكل عيد ميلاد ", //翻譯成中文就是:必須是生日格式
})

這是我們第一次接觸到資訊提示的佔位符,在以上的例子裡${0}就代表birthday,關於佔位符,我們會在擴充套件驗證部分更多提到。

擴充套件驗證規則

雖然fire內建了常用的驗證規則,但一定會遇到不夠用的時候,在這種情況下,fire允許使用者擴充套件驗證規則,假設我們現在有一個比較奇怪的規則:驗證姓名,如果是英文名字,那必須得是全名 (full name),其他名字(中文,阿拉伯文等)必須滿足指定的最小長度才可以通過驗證;這個規則顯然是常用的規則不能滿足要求的,那我們來擴充套件一個規則吧。

原理

希望所有的擴充套件開發者都能知道擴充套件的原理是什麼,這樣一定寫得更好。在fire內部,每個驗證規則都對應了一個Token例項,Token是一個介面,有一系列規定的方法。

type Token interface {
    Evaluate(value DataValue, data Data) (qualified bool, literalValue []string, err error)
    TokenType() TokenType
    I18nMsgFormat(Lang) MsgFormat
    ParseLiteral(literal string) error
    Clone() Token
}

fire在驗證資料的時候,就是呼叫Token的一連串方法來判斷的,所以我們只需要註冊一個新的Tokenfire知道就可以了。

程式碼

type specialNameToken struct {
        literal string
    length int
}

func (t *specialNameToken) Evaluate(value DataValue, data Data) 
(qualified bool, literalValue []string, err error) {
    if value == nil { //如果傳入的資料是空,那我們就不驗證了
        qualified = true
        return
    }
    v, ok := value.(string)
    if !ok {
        err = fmt.Errorf("%s's value must be string", t.TokenType())
        return
    }
    isENName, includeSpace := isName(v)
    if isENName {
        return includeSpace, []string{t.literal}, nil
    } else {
        return utf8.RuneCountInString(v) >= t.length, []string{t.literal}, nil
    }
}

func (t *specialNameToken) TokenType() TokenType {
    return "specialName" //token獨一無二的名稱,不應該和其他規則重複
}

func (t *specialNameToken) I18nMsgFormat(lg Lang) MsgFormat {
    if lg == LangZH {
        return "${0}必須是指定格式" // ${0}是一個佔位符,將會被替換成資料的key
    } else if lg == LangEN {
        return "${0} must be special format"
    } else if lg == LangAR {
            ... //如果你的專案需要支援多種語言,那麼你可以寫更多的分支來支援不同語言的提示,這裡就省略了
    }
    return ""
}
//ParseLiteral接收到的引數是specialName:後面的值,
//假設規則是specialName:20,它表示如果名字是英文,那麼長度不能超過20
//在這個例子裡,literal的值是"20"
func (t *specialNameToken) ParseLiteral(literal string) error {
    if literal != "" {
        length, err := strconv.Atoi(literal)
        if err != nil {
            return err
        }
        t.length = length
    }
    t.literal = literal
  return nil
}

//clone用來保證深拷貝一個物件,修改拷貝時不會影響原來的物件, 請保證深拷貝
func (t *specialNameToken) Clone() Token {
    c := *t
    return &c
}

func isName(s string) (isENName bool, includeSpace bool) {
    for _, v := range s {
        if !((v >= 'a' && v <= 'z') || (v >= 'A' && v <= 'Z') || v == ' ') {
            return false, false
        }
        if v == ' ' {
           includeSpace = true
        }
    }
    isENName = true
    return
}

我們完成了一個擴充套件,並且規定了其名稱是specialName,名稱是TokenType()的返回值決定的;但這個tokenfire所不認識的,我們把它註冊給fire,然後就可以放心的使用了:

fire.RegisterToken(&specialNameToken{})

rule := fire.Rule{
    "name":"specialName:20",
}

佔位符

按我們約定的,現在詳細的來看下佔位符,我們拿fire內建的一個驗證規則來舉例子,比較有代表性的規則是lengthBetween:

// fire.Rule{"name":"lengthBetween:1, 10"}
func (t *lengthBetweenToken) I18nMsgFormat(lg Lang) MsgFormat {
    if lg == LangZH {
        return "${0}長度必須在${1}和${2}之間"
    } else if lg == LangEN {
        return "${0}'s length must between ${1} and ${2}"
    }
    return ""
}

${0}將會被name填充,${1}將會被1填充,${2}將會被10填充。

我們為什麼要解釋佔位符呢?因為佔位符不但在開發擴充套件的時候有用,在設定多國語言提示的也是有用的。在"多國語言"部分,我們提到了如果我們的專案是中文和英文世界之外的使用者使用,我們如何給他們提示相應的方言呢?看程式碼:

fire.RegisterMsgFormat("birthday", map[fir.Lang]fire.MsgFormat{
    fire.LangAR:"${0} يجب أن يكون شكل عيد ميلاد ", 
})

這樣我們就註冊了一個date規則對應的訊息提示,當錯誤發生時,就會返回阿拉伯文的提示,當然,前提是設定了預設的語言。

別名

請看以下程式碼:

fire.RegisterI18nDataKey(fire.DataKey("class_name"), map[fire.Lang]string{
    fire.LangZH:"班級名稱"
})
v := fire.New(fire.Rule{
    "name":"alias:class_name|required"
})

由於name這個關鍵詞實在太廣泛了,它在user表中表示使用者名稱,在class表中表示班級名稱,在teacher表中又表示教室名稱,但是在不同的介面中,它們都有一個相同的名稱:name,我們可不希望每個介面都提示為名稱,因為這個提示太不具體了,我們還是希望提示:"名稱不能為空","班級名稱不能為空","教室名稱不能為空"……,所以fire內建了名為alias的規則,在上面的例子裡,當name不能通過驗證時,提示的是"班級名稱是必填項"。

最佳實踐

fire的設計裡,我們故意把解析Rule和驗證資料分開了,當呼叫fire.New方法返回一個介面時,已經把規則都解析好,"快取"起來,剩下的就是資料的驗證了,因為驗證本身不會修改物件的成員變數,所以同一個物件的Validate方法可以在多個協程中同時呼叫,而不會因為併發發生錯誤。所以比較好的實踐是不要每次收到請求時都去呼叫fire.New,而應該在專案初始的時候就把物件例項化好,在需要驗證資料的時候,呼叫指定物件的Validate方法就可以了,這減少了很多解析規則的開銷,舉個簡單的例子,假設以handleAPI為字首的函式執行在不同的協程中:

var v1 = fire.New(rule1)
var v2 = fire.New(rule2)

func handleAPI1(data fire.Data) {
    v1.Validate(data)
}

func handleAPI2(data fire.Data) {
    v2.Validate(data)
}

下面是錯誤的例子:

func handleAPI1(data fire.Data) {
        var v1 = fire.New(rule1)
    v1.Validate(data)
}

func handleAPI2(data fire.Data) {
        var v2 = fire.New(rule2)
    v2.Validate(data)
}

在每個請求到來時都建立fire.Validator物件是沒有必要的,增加了不必要的開銷。

fire裡,那些Register開頭的函式,實際的作用是把一些我們需要的資料以鍵值對的形式存放在map裡,fire使用的就是GoLang內建的map,沒有加鎖,不支援同時讀寫,所以註冊類的函式呼叫應該在呼叫fire.New前進行,一旦有fire.Validator例項被建立出來了,任何註冊類函式都不應該再被呼叫。

起源

表單驗證類庫在web應用中是如此重要,它是業務邏輯的起始,我自己在開發專案時,總傾向於用一種儘量簡單的方法驗證資料的正確性,而把更多的精力用在真正業務邏輯的編寫上。受到thinkphp框架以及很多其他驗證類庫的啟發,就想把簡單的方法帶到經手的專案中來,這就是fire的來源。

在開發fire的過程中,我也從解析器中借鑑了一些方法,把驗證規則變成可驗證的函式,其實就是把字串解析成有特定目的的資料結構。所以先確定下了幾個重要介面:Token,parser,Validator,明確了他們各自的職責,Token負責制定規則並驗證資料,parserToken呼叫把字串轉為有意義的資料結構,就像解釋有指定語法的程式碼,Validator負責把資料傳給相應的Token,讓它去驗證,最後把結果返回給呼叫者。

得益於GoLang的介面,先定義介面,寫出主要的業務邏輯和單元測試,剩下的工作就是完成一個個內建Token的開發,開發完後註冊到儲存Tokenmap裡。

所以,fire最重要的並不是那些內建的規則,而是基於介面的一系列呼叫,即使fire中一個內建的規則都沒有,開發者還是可以通過自己開發Token然後註冊到fire來使用,這些Token甚至可以覆蓋內建的Token,制定自己的驗證規則。

更多原創文章乾貨分享,請關注公眾號
  • 更好用、能擴充套件、支援多國語言提示的表單驗證類庫
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章