Random walk to my blog

my blog for sharing my knowledge,experience and viewpoint

0%

checker v1.0-声明式的Golang参数校验函数库

Golang使用Gin框架的开发中,服务器在绑定HTTP请求的参数后,需要对参数进行校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Req.Email需要符合电子邮箱的格式
type Req struct {
Info typeInfo
Email string
}

type typeStr string

// Req.Info.Type = "range",typeInfo.Type的长度为2,元素都是格式符合"2006-01-02"
// Req.Info.Type = "last",typeInfo.Type的长度为1,元素是正整数,Granularity只能是day/week/month之一
type typeInfo struct {
Type typeStr
Range []string
Unit string
Granularity string
}

func BindParams(ctx *gin.Context) {
req := Req{}
_ = ctx.BindJSON(&req)
// 参数校验
}

参数校验一般有3种方式:

  1. 使用if/else或者switch的原生的校验方法。
  2. 使用gin自带的结构体标签来校验。
  3. 使用checker进行声明式的参数校验。

原生的校验方法

可以看到,原生的if/else,switch的的校验方法比较繁琐,不容易阅读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func (r Req) validate() bool {
emailRegexString := "^(?:(?:(?:(?:[a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(?:\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|(?:(?:\\x22)(?:(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(?:\\x20|\\x09)+)?(?:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(\\x20|\\x09)+)?(?:\\x22))))@(?:(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"

regexObject := regexp.MustCompile(emailRegexString)
if !regexObject.MatchString(r.Email) {
return false
}
switch r.Info.Type {
case "range":
if len(r.Info.Range) != 2 {
return false
}
for _, value := range r.Info.Range {
if _, err := time.Parse("2006-01-02", value); err != nil {
return false
}
}
case "last":
if len(r.Info.Range) != 1 {
return false
}
valInt, err := strconv.Atoi(r.Info.Range[0])
if err != nil {
return false
}
if valInt <= 0 {
return false
}
if r.Info.Granularity != "hour" &&
r.Info.Granularity != "day" &&
r.Info.Granularity != "week" &&
r.Info.Granularity != "month" &&
r.Info.Granularity != "year" &&
r.Info.Granularity != "decade" {
return false
}

default:
return false
}
return true
}

结构体标签校验

在结构体定义时,在字段加上标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Req struct {
Info typeInfo
Email string `binding:"required,email"`
}

type typeStr string


type typeInfo struct {
Type typeStr
Range []string `binding:"required,min=1,max=2"`
Granularity string
}

结构体标签校验的缺点有:

  1. 支持的方法不完整,例如Granularity的枚举校验并没有对应的标签。
  2. 标签与结构体强耦合。如果同个结构体,想有多个校验方法,目前还不支持。例如,Range字段在
    Type=range的长度是2,Type=last的长度是1.
  3. 难以阅读,容易出错。将较为复杂的校验规则写在标签上,不利于代码可读性。而且,如果一个int字段绑定了字符类型的校验标签,就会panic

Checker

checker通过在结构体外部定义校验规则,降低耦合度。
并且,有大量的规则方便校验。

checker中一个重要的方法是fetchFiled,它用来在结构体中获取字段的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func fetchField(param interface{}, filedExpr string) (interface{}, reflect.Kind) {
pValue := reflect.ValueOf(param)
if filedExpr == "" {
return param, pValue.Kind()
}

exprs := strings.Split(filedExpr, ".")
for i := 0; i < len(exprs); i++ {
expr := exprs[i]
if pValue.Kind() == reflect.Ptr {
pValue = pValue.Elem()
}
if !pValue.IsValid() || pValue.Kind() != reflect.Struct {
return nil, reflect.Invalid
}
pValue = pValue.FieldByName(expr)
}

// last field is pointer
if pValue.Kind() == reflect.Ptr {
if pValue.IsNil() {
return nil, reflect.Ptr
}
pValue = pValue.Elem()
}

if !pValue.IsValid() {
return nil, reflect.Invalid
}
return pValue.Interface(), pValue.Kind()
}

fetchField的参数fieldExpr有三种情况:

  • fieldExpr为空字符串,这时会直接校验值。
  • fieldExpr为单个字段,这时会先取字段的值,再校验。
  • fieldExpr为点(.)分割的字段,先按照.的层级关系取值,再校验

按字段取值时,如果字段是指针,就取指针的值校验;如果是空指针,则视为没有通过校验。

上述的校验规则使用checker改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
rule := And(
Email("Email"),
Field("Info",
Or(
And(
EqStr("Type", "range"),
Length("Range", 2, 2),
Array("Range", isDatetime("", "2006-01-02")),
),
And(
EqStr("Type", "last"),
InStr("Granularity", "day", "week", "month"),
Number("Unit"),
),
),
),
)
itemChecker := NewChecker()
// 校验参数
itemChecker.Add(rule, "wrong item")

相比其他两种方法,Rule的定义,更为简洁,清晰,强大,耦合度低。

Bonus

checkerfieldExpr的定义,
使得它可以用来校验链表的值以及链表的长度,这是使用标签的校验方法无法做到的。

1
2
3
4
5
6
7
type list struct {
Name *string
Next *list
}

// 校验第2个节点的Name的长度在[1,20]
nameRule := Length("Next.Next.Name", 1, 20)

自定义校验

日常开发中,函数库现有的校验规则不能满足需求,这时候需要自定义校验规则。下面比较GinChecker实现判断字段是否为nil的校验规则。

Gin的自定义校验

使用Gin的校验需要对Gin的校验Engine有一定了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type customizedStruct struct {
NilPtr *int
}

// 自定义验证规则断言
func nilPtr(fl validator.FieldLevel) bool {
if fl.Field().Interface()==nil{
return true;
}
return false;
}

func main() {
route := gin.Default()
// 注册验证
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
err := v.RegisterValidation("nilPtr", nilPtr)
if err != nil {
fmt.Println("success")
}
}
// ......
}

Checker的自定义校验

用户通过实现Rule接口,来实现自定义的校验规则。下面的customizedRule, 用来校验fieldExpr是否为空指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type customizedRule struct {
fieldExpr string
name string
}

func (r customizedRule) Check(param interface{}) (bool, string) {
exprValue, _ := fetchField(param, r.fieldExpr)
if exprValue != nil{
return false,fmt.Sprintf("[%s]:'%s' is not nil", r.name, r.fieldExpr)
}
return true, ""
}

func main(){
ch := NewChecker()
customRule := customizedRule{}
ch.Add(customRule, "invalid ptr")
}

自定义错误提示

日常开发中,除了校验结构体外,还需要根据校验结果,给出不同的错误提示。

原生的校验方法

将上述Reqvalidate方法改为,需要返回特定的错误提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Req.Email需要符合电子邮箱的格式
type Req struct {
Info typeInfo
Email string `binding:"required,email"`
}

type typeStr string

// Req.Info.Type = "range",typeInfo.Type的长度为2,元素都是格式符合"2006-01-02"
// Req.Info.Type = "last",typeInfo.Type的长度为1,元素是正整数,Granularity只能是hour/day/week/month/quarter/year/decade之一
type typeInfo struct {
Type typeStr
Range []string `binding:"required,min=1,max=2"`
Granularity string
}


func (r Req) validate() (bool,string) {
emailRegexString := "^(?:(?:(?:(?:[a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(?:\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|(?:(?:\\x22)(?:(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(?:\\x20|\\x09)+)?(?:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(?:(?:(?:\\x20|\\x09)*(?:\\x0d\\x0a))?(\\x20|\\x09)+)?(?:\\x22))))@(?:(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(?:(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])(?:[a-zA-Z]|\\d|-|\\.|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*(?:[a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"

regexObject := regexp.MustCompile(emailRegexString)
if !regexObject.MatchString(r.Email) {
return false
}
switch r.Info.Type {
case "range":
if len(r.Info.Range) != 2 {
return false, "错误的range长度"
}
for _, value := range r.Info.Range {
if _, err := time.Parse("2006-01-02", value); err != nil {
return false, "错误的range值"
}
}
case "last":
if len(r.Info.Range) != 1 {
return false,"错误的range长度"
}
valInt, err := strconv.Atoi(r.Info.Range[0])
if err != nil {
return false, "错误的range值"
}
if valInt <= 0 {
return false
}
if r.Info.Granularity != "hour" &&
r.Info.Granularity != "day" &&
r.Info.Granularity != "week" &&
r.Info.Granularity != "month" &&
r.Info.Granularity != "year" &&
r.Info.Granularity != "decade" {
return false, "错误的 granalrity"
}

default:
return false, "错误的 type"
}
return true,""
}

Gin的自定义错误提示

Gin的错误提示一般是用于开发者调试的,不能直接返回。

1
2
3
4
5
6
7
8
9
10
11
type LoginRequest struct {
Mobile string `form:"mobile" json:"mobile" binding:"required"`
Code string `form:"code" json:"code" binding:"required"`
}

router.POST("/loginJSON", func(c *gin.Context) {
var json Login
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
})

ShouldBindJSON返回的错误信息, 一般难以阅读,并且不能直接返回给前端。

1
2
3
{
"message": "Key: 'LoginRequest.Mobile' Error:Field validation for 'Mobile' failed on the 'required' tag\nKey: 'LoginRequest.Code' Error:Field validation for 'Code' failed on the 'required' tag"
}

Gin的自定义错误提示比较复杂,可以参考gin框架自定义验证错误提示信息

Checker的自定义错误提示

Checker是一个接口

  • Add(rule Rule, prompt string): 添加规则,和没有通过规则是的错误提示。
  • Check(param interface{}) (bool, string, string): 校验参数,依次返回是否通过校验,错误提示,错误日志。错误日志包含哪个字段没有通过哪个规则的信息。

想要自定义错误提示,只需要添加规则的时候,附上没有通过校验规则的错误提示即可。

1
itemChecker.Add(rule, "wrong item")

参考文档

  1. checker