更好用、能扩展、支持多国语言提示的表单验证类库,使用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
定义的数据类型,用于传入数据用,我们可以直接使用它。
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)
type User struct {
Name string
}
v := fire.New(fire.Rule{
"name":"required",
})
u := User{Name:"Tom"}
传入指针也是可以的,但是指针必须指向一个结构体。
u := &User{Name:"Tom"}
v.Validate(u)
除了fire.Data
、map[string]interface{}
、结构体以及指向结构体的指针外,Validate
方法并不支持传入其他类型的参数,如果传入了错误类型的参数,返回的err
不为空,检查New
函数返回的err
是否为空是有必要的。
在上一部分的例子里,我们看到结构体的属性是Name
,而Rule
的key
是name
,严格来说,它们并不相等(用==判断),那么fire
是如何将他们对应起来的呢?
fire
会先将struct
的属性按照一定的规则转为Rule
的key
,当这些规则都用完了还没有找到映射关系,那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
中寻找Name
、name
、name
,先找到谁,就认定那个验证规则,在这个例子里,规则就是required
,而不会验证name
是否是int
类型。
在GoLang
的世界里,很多验证类库的方式都利用了tag
,这样做的好处是,key
的定义非常清晰,没有歧义;但也有弊端,它会使结构体中包含很多让人眼花缭乱的tag
,举一个在真实的项目里见过的例子:
type User struct {
Name string `json:"name", validator:"name", form:"name", db:"name"`
}
在上面的结构体中,json
标签是用来序列化和反序列化用的,validator
标签是验证表单用的,db
和form
也各有其用途,本来是一个"纯粹"的结构体,在加入很多的tag
后,结构体变得非常臃肿;我们在fire
中偏爱"约定优于配置"的原则,这些约定都是比较符合一般做法和直觉的,不会令人觉得怪异,这样就不大需要写tag
了。
但是,我们的约定可能无法满足所有的要求,因为我们不总是从一个的项目的零步做起,很可能接手的是一个老项目,而那里大概率已经有一些约定俗称的规则了,我们不能因为引入了一个表单验证的类库就打破原来的规则,所以,fire
也是支持tag
的,当约定不能满足要求的时候,可以将tag
应用于特殊的需求,fire
给了tag
最高的优先级,请看下面的例子:
type User struct {
Name string `fire:"nickname"`
}
这样name
对应的key
就是nickname
,即使data
有另外一个名为name
的key
,fire
也不会去验证它,因为我们
给了tag
最高的优先级。
如果你不喜欢fire
这个默认的tag
(也可能是想复用名为json
的tag
),可以通过以下设置修改:
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 的数据相同 常用于判断密码不能和账号相同 |
邮箱格式 | 邮箱格式 | ||
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
内置的这些规则中,都有中英文提示,比如我们要验证一个key
为birthday
的值,它不能通过日期格式(date)
验证时,返回的中文提示是date必须是日期格式
,英文提示是date must be date format
。在某些时候,我们可能不希望用户看到中英文掺杂的文字提示,换句话说,我们想让birthday
有指定的中文翻译,这时,我们就可以调用fire
的RegisterI18nDataKey
函数:
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
的一连串方法来判断的,所以我们只需要注册一个新的Token
让fire
知道就可以了。
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()
的返回值决定的;但这个token
是fire
所不认识的,我们把它注册给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
负责制定规则并验证数据,parser
被Token
调用把字符串转为有意义的数据结构,就像解释有指定语法的代码,Validator
负责把数据传给相应的Token
,让它去验证,最后把结果返回给调用者。
得益于GoLang
的接口,先定义接口,写出主要的业务逻辑和单元测试,剩下的工作就是完成一个个内置Token
的开发,开发完后注册到保存Token
的map
里。
所以,fire
最重要的并不是那些内置的规则,而是基于接口的一系列调用,即使fire
中一个内置的规则都没有,开发者还是可以通过自己开发Token
然后注册到fire
来使用,这些Token
甚至可以覆盖内置的Token
,制定自己的验证规则。