Echo 系列教程——定制篇2:自定义 Validator,进行输入校验

上一篇讲 Binder 时提到,参数自动绑定和校验是 Web 框架很重要的两个功能,可以极大的提升开发速度,并更好的保证数据的可靠性(服务端数据校验很重要)。本节,我们就一起看看如何自定义 Echo 的表单校验功能。

不同于 Binder,Echo 并没有内置数据校验的能力,也就是没有默认的 Validator 实现。然而,你可以很方便的集成第三方的数据校验库。跟 Binder 类似,Echo 提供了一个 Validator 接口,方便将第三方数据校验库集成进来。

Validator interface {
  Validate(i interface{}) error
}

通过这个实现这个接口,可以很方便的将任何第三方数据校验库集成到 Echo 中。在 Awesome-Go 上可以找到第三方数据校验库:https://github.com/avelino/awesome-go#validation。本文我们使用最流行的 https://github.com/go-playground/validator 库来讲解。

go-playground/validator

这是一个 Go 结构体及字段校验器,包括:跨字段和跨结构体校验,Map,切片和数组,是目前校验器相关库中 Star 数最高的一个,对国际化支持也很好,建议大家使用它。

它具有以下独特功能:

  • 通过使用验证标签(tag)或自定义验证程序进行跨字段和跨结构体验证;
  • 切片,数组和 map,可以验证任何的多维字段或多层级;
  • 能够深入(多维)了解 map 键和值以进行验证;
  • 通过在验证之前确定其基础类型来处理接口类型;
  • 处理自定义字段类型,例如 sql driver Valuer;
  • 别名验证标签,允许将多个验证映射到单个标签,以便更轻松地定义结构上的验证;
  • 提取自定义定义的字段名称,例如可以指定在验证时提取 JSON 名称,并将其用于结果 FieldError 中;
  • 可自定义的 i18n 错误消息;
  • gin Web 框架的默认验证器;

一个简单的例子

通过一个简单例子来看看如何使用该库。

package main

import (
	"fmt"
	"flag"

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

type User struct {
	Name  string `validate:"required"`
	Age   uint   `validate:"gte=1,lte=130"`
	Email string `validate:"required,email"`
}

var (
	name  string
	age   uint
	email string
)

func init() {
	flag.StringVar(&name, "name", "", "输入名字")
	flag.UintVar(&age, "age", 0, "输入年龄")
	flag.StringVar(&email, "email", "", "输入邮箱")
}

func main() {
	flag.Parse()

	user := &User{
		Name:  name,
		Age:   age,
		Email: email,
	}

	validate := validator.New()
	err := validate.Struct(user)
	if err != nil {
		fmt.Println(err)
	}
}

执行如下命令,运行代码:

go run main.go -name studygolang -age 7 -email polaris@studygolang.com

什么都没有输出,表示一切正常。如果我们提供一个非法的邮箱地址:

go run main.go -name studygolang -age 7 -email polaris@studygolang

输出如下错误:

Key: 'User.Email' Error:Field validation for 'Email' failed on the 'email' tag

错误显示不友好。怎么能够更友好,并进行国际化呢?

国际化(i18n)

在介绍校验库错误消息国际化之前,有一个概念需要了解下,那就是 CLDR。

什么是 CLDR?

它是 i18n 的一套核心规范( Common Locale Data Respository),即通用的本地化数据存储库,什么意思呢?比如我们的手机,电脑都可以选择语言模式为 英语、汉语、日语、法语等等,这套操作背后的规范,就是 CLDR;CLDR 是以 Unicode 的编码标准作为前提,将多国的语言文字进行编码的。

看看官方对于 CLDR 的说明,官方网址:http://cldr.unicode.org/

Unicode CLDR 提供了支持世界语言的软件的关键构建块,并且具有最大和最广泛的本地设置数据标准存储库。大量的公司使用此数据进行软件的国际化和本地化,使它们的软件适应此类通用软件任务的不同语言的约定。

需要进行国际化和本地化的主要包括:

  • 用于格式化和解析的特定于语言环境的模式:日期,时间,时区,数字和货币值,度量单位,…
  • 名称的翻译:语言,脚本,国家和地区,货币,时代,月份,工作日,白天,时区,城市和时间单位,表情符号字符和序列(和搜索关键字),…
  • 语言和文字信息:使用的字符;复数情况;性别;大写;分类和搜索规则;写作方向;音译规则;拼写数字的规则;将文本分割成字符,单词和句子的规则;键盘布局…
  • 国家/地区信息:语言使用情况,货币信息,日历首选项,星期惯例等…
  • 有效性:Unicode 语言环境,语言,脚本,区域和扩展名的定义,别名和有效性信息,…

CLDR 的 Go 语言实现

本文讲解的校验库是 go-playground 这个组织创建的,它们还提供了其他的一些有用库,其中就包括了 CLDR 的 Go 语言实现,这就是 locales

该库是从 CLDR 项目生成的一组语言环境,可以单独使用或在 i18n 软件包中使用;这些是专为 https://github.com/go-playground/universal-translator 构建的,但也可以单独他用。

这引出了该组织的另外一个库:universal-translator

universal-translator :一个使用 CLDR 数据+复数规则(比如英语很多复数规则是加 s)的 Go i18n 转换器(翻译器)。该库是 locales 的薄包装,以便存储和翻译文本,供你在应用程序中使用。

universal-translator 简明教程

这个通用的翻译器包主要包含了两个核心数据结构:Translator 接口和 UniversalTranslator 结构体,其他的是错误类型。我们先看 Translator 接口。(注意,该包的包名是 ut)

Translator 接口

type Translator interface {
    locales.Translator

    // adds a normal translation for a particular language/locale
    // {#} is the only replacement type accepted and are ad infinitum
    // eg. one: '{0} day left' other: '{0} days left'
    Add(key interface{}, text string, override bool) error

    // adds a cardinal plural translation for a particular language/locale
    // {0} is the only replacement type accepted and only one variable is accepted as
    // multiple cannot be used for a plural rule determination, unless it is a range;
    // see AddRange below.
    // eg. in locale 'en' one: '{0} day left' other: '{0} days left'
    AddCardinal(key interface{}, text string, rule locales.PluralRule, override bool) error

    // adds an ordinal plural translation for a particular language/locale
    // {0} is the only replacement type accepted and only one variable is accepted as
    // multiple cannot be used for a plural rule determination, unless it is a range;
    // see AddRange below.
    // eg. in locale 'en' one: '{0}st day of spring' other: '{0}nd day of spring'
    // - 1st, 2nd, 3rd...
    AddOrdinal(key interface{}, text string, rule locales.PluralRule, override bool) error

    // adds a range plural translation for a particular language/locale
    // {0} and {1} are the only replacement types accepted and only these are accepted.
    // eg. in locale 'nl' one: '{0}-{1} day left' other: '{0}-{1} days left'
    AddRange(key interface{}, text string, rule locales.PluralRule, override bool) error

    // creates the translation for the locale given the 'key' and params passed in
    T(key interface{}, params ...string) (string, error)

    // creates the cardinal translation for the locale given the 'key', 'num' and 'digit' arguments
    //  and param passed in
    C(key interface{}, num float64, digits uint64, param string) (string, error)

    // creates the ordinal translation for the locale given the 'key', 'num' and 'digit' arguments
    // and param passed in
    O(key interface{}, num float64, digits uint64, param string) (string, error)

    //  creates the range translation for the locale given the 'key', 'num1', 'digit1', 'num2' and
    //  'digit2' arguments and 'param1' and 'param2' passed in
    R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) (string, error)

    // VerifyTranslations checks to ensures that no plural rules have been
    // missed within the translations.
    VerifyTranslations() error
}

关于该接口需要需要如下几点说明

  • 内嵌了 locales.Translator 接口;
  • 几类复数规则:cardinal plural(基数复数规则,即单数和复数两种);ordinal plural(序数复数规则,如 1st, 2nd, 3rd…);ordinal plural (范围复数规则,如 0-1)。对中文来说,这里大部分不需要。
  • 几个 Add 方法,和上面几类规则对应;一个 key 和 一个带站位符的 text;
  • 单字符的几个方法和 Add 几个方法的对应关系:T -> Add;C -> AddCardinal;O -> AddOrdinal;R -> AddRange ;表示用具体的值替换 key 表示的文本 text 中的占位符。
  • 以上方法参数中,num 表示占位符处的值,但对于有复数形式的语言,这个值必须符合复数语言的规范,否则会报错;digits 表示 num 值的有效数字(或者说小数位数);
  • VerifyTranslations 确保翻译库中没有缺少对应的语言规则;

UniversalTranslator 结构体

它用于保存所有语言环境和翻译数据。该结构体方法不多,我们关注几个核心的。

func New(fallback locales.Translator, supportedLocales ...locales.Translator) *UniversalTranslator

New 返回一个 UniversalTranslator 实例,该实例具有后备语言环境(fallback)和应支持的语言环境(supportedLocales)。可以看到,New 函数接收的参数是 locales.Translator 类型,因此我们肯定需要用到 locales 包。

得到 UniversalTranslator 实例后,需要获得 universal-translator 包中的 Translator 接口实例,这就用到了下面几个方法。

1)GetTranslator

func (t *UniversalTranslator) GetTranslator(locale string) (trans Translator, found bool)

返回给定语言环境的指定翻译器,如果未找到,则返回后备语言环境的翻译器(即 New 中的 fallback)。

2)GetFallback

func (t *UniversalTranslator) GetFallback() Translator

直接返回后备语言环境的翻译器。

3)FindTranslator

func (t *UniversalTranslator) FindTranslator(locales ...string) (trans Translator, found bool)

尝试根据语言环境数组查找翻译器,并返回它可以找到的第一个翻译器,否则返回后备翻译器。

总结来说,New 函数加上这三个方法,相当于是 locales.Translator 到 ut.Translator 的转换。

示例

通过一个实际的例子来学习下这两个包的使用。

package main

import (
	"flag"
	"fmt"

	"github.com/go-playground/locales"
	"github.com/go-playground/locales/en"
	"github.com/go-playground/locales/zh"
	"github.com/go-playground/locales/zh_Hant_TW"
	ut "github.com/go-playground/universal-translator"
)

var universalTraslator *ut.UniversalTranslator

func main() {
	acceptLanguage := flag.String("language", "zh", "语言")
	flag.Parse()

	e := en.New()
	universalTraslator = ut.New(e, e, zh.New(), zh_Hant_TW.New())

	translator, _ := universalTraslator.GetTranslator(*acceptLanguage)

	switch *acceptLanguage {
	case "zh":
		translator.Add("welcome", "欢迎 {0} 来到 studygolang.com!", false)
		translator.AddCardinal("days", "你只剩 {0} 天时间可以注册", locales.PluralRuleOther, false)
		translator.AddOrdinal("day-of-month", "第{0}天", locales.PluralRuleOther, false)
		translator.AddRange("between", "距离 {0}-{1} 天", locales.PluralRuleOther, false)
	case "en":
		translator.Add("welcome", "Welcome {0} to studygolang.com.", false)
		translator.AddCardinal("days", "You have {0} day left to register", locales.PluralRuleOne, false)
		translator.AddOrdinal("day-of-month", "{0}st", locales.PluralRuleOne, false)
		translator.AddRange("between", "It's {0}-{1} days away", locales.PluralRuleOther, false)
	}

	fmt.Println(translator.T("welcome", "polaris"))
	fmt.Println(translator.C("days", 1, 0, translator.FmtNumber(1, 0)))
	fmt.Println(translator.O("day-of-month", 1, 0, translator.FmtNumber(1, 0)))
	fmt.Println(translator.R("between", 1, 0, 2, 0, translator.FmtNumber(1, 0), translator.FmtNumber(2, 0)))
}

主要通过这个例子说明相关函数的使用。

  • 根据 acceptLanguage 的不同值,设置不同的语言文案;
  • 对于中文来说,没有复数,因此 AddXX 三个方法的第二个参数都是 locales.PluralRuleOther,表示该语言环境没有复数形式;
  • 英文环境下,PluralRule 规则不能乱填,根据实际情况来;
  • 最后在实际填充值时,num 表示占位符要填入的值,digits 表示 num 这个值最终要保留几位小数;
  • FmtNumber 方法的参数需要和前面的 num 和 digits 对应上,第一个参数是 num 的值,第二个是 digits 的值;

Validator 怎么和以上两个库集成提供 i18n

Validator 库提供了相应的子库,对以上两个库进行了封装。比如中文的库:github.com/go-playground/validator/translations/zh ,这些子库提供了一个 RegisterDefaultTranslations ,为所有内置标签的验证器注册一组默认翻译。

func RegisterDefaultTranslations(v *validator.Validate, trans ut.Translator) (err error)

具体怎么做?还是看最开始的例子,其他不变,main 函数改为如下:

func main() {
	flag.Parse()

	user := &User{
		Name:  name,
		Age:   age,
		Email: email,
	}

	validate := validator.New()

	e := en.New()
	uniTrans := ut.New(e, e, zh.New(), zh_Hant_TW.New())
	translator, _ := uniTrans.GetTranslator("zh")
	zh_translate.RegisterDefaultTranslations(validate, translator)

	err := validate.Struct(user)
	if err != nil {
		errs := err.(validator.ValidationErrors)
		for _, err := range errs {
			fmt.Println(err.Translate(translator))
		}
	}
}

注册一个默认的中文翻译器,在校验出错后,对错误进行翻译。不输入任何参数运行程序,输出:

Name为必填字段 Age必须大于或等于1 Email为必填字段

大功告成。

将 Validator 集成到 Echo 中

首先,需要定义一个类型,实现 Echo 的接口 Validator :

type CustomValidator struct {
	once     sync.Once
	validate *validator.Validate
}

func (c *CustomValidator) Validate(i interface{}) error {
	c.lazyInit()
	return c.validate.Struct(i)
}

func (c *CustomValidator) lazyInit() {
	c.once.Do(func() {
		c.validate = validator.New()
	})
}

因为 validator.Validate 实例化做了不少事情,这里将实例化推迟到使用时。简单几行代码就实现了一个自定义的 Validator。

接下来和 Echo 集成起来就很容易了。

e := echo.New()
e.Validator = &CustomValidator{}

之后就可以在需要进行表单校验的地方通过 ctx.Validate() 进行校验。

自此我们完成了 Validator 集成到 Echo 的功能。

还剩最后一块内容,那就是校验错误信息的国际化显示。国际化相关的内容,上面有了较详细的介绍,Validator 集成到 Echo 后如何国际化我们在后面实战篇再讲。

完整代码见:https://github.com/polaris1119/go-echo-example/blob/master/pkg/validator/validator.go