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

1,075 阅读4分钟

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

// 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的的校验方法比较繁琐,不容易阅读。

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
}

结构体标签校验

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

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,它用来在结构体中获取字段的值。

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改写为:

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的定义, 使得它可以用来校验链表的值以及链表的长度,这是使用标签的校验方法无法做到的。

type list struct {
	Name *string
	Next *list
}

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

参考文档:

  1. checker