Go语言高效率Web开发一:参数校验三种方式的最优解

web开发中,你肯定见过各种各样的表单或接口数据校验:

  • 客户端参数校验:在数据提交到服务器之前,发生在浏览器端或者app应用端,相比服务器端校验,用户体验更好,能实时反馈用户的输入校验结果。

  • 服务器端参数校验:发生在客户端提交数据并被服务器端程序接收之后,通常服务器端校验都是发生在将数据写入数据库之前,如果数据没通过校验,则会直接从服务器端返回错误消息,并且告诉客户端发生错误的具体位置和原因,服务器端校验不像客户端校验那样有好的用户体验,因为它直到整个表单都提交后才能返回错误信息。但是服务器端校验是应用对抗错误,恶意数据的最后防线,在这之后,数据将被持久化至数据库。当今所有的服务端框架都提供了数据校验与过滤功能(让数据更安全)。

本文主要讨论服务器端参数校验

确保用户以正确格式输入数据,提交的数据能使后端应用程序正常工作,同时在一切用户的输入都是不可信的前提下(比如xss跨域脚本攻击,sql注入),参数验证是不可或缺的一环,也是很繁琐效率不高的一环,在对接表单提交或者api接口数据提交,程序里充斥着大量重复验证逻辑和if else语句,本文分析参数校验的四种方式,从而提高参数验证程序代码的开发效率。

学习方式自下而上:提出问题 -> 分析问题 -> 解决问题 -> 总结

需求场景:

常见的网站登陆场景
1639119540448.jpg

业务需求

接口一:
场景:输入手机号,获取短信验证码
校验需求:判断手机号非空,手机号格式是否正确
接口二:
场景:手机收到短信验证码,输入验证码,点击登陆
校验需求:1、判断手机号非空,手机号格式是否正确;2、验证码非空,验证码格式是否正确
复制代码

技术选型:web框架gin

参数校验第一种实现方法:自定义实现校验逻辑

func main() {
   engine := gin.New()

   ctrUser := new(controller.User)
   ctrCaptcha := new(controller.Captcha)
   engine.POST("/captcha/send", ctrCaptcha.Send)
   engine.POST("/user/login", ctrUser.Login)
   engine.Run()
}


type Captcha struct {}

func (ctr *Captcha) Send(c *gin.Context) {
   mobile := c.PostForm("mobile")

   // 校验手机号逻辑
   if mobile == "" {
      c.JSON(http.StatusBadRequest, gin.H{"error": "手机号不能为空"})
      return
   }

   matched, _ := regexp.MatchString(`^(1[3-9][0-9]\d{8})$`, mobile)
   if !matched {
      c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
      return
   }

   fmt.Println(mobile)
}

type User struct {}

func (ctr *User) Login(c *gin.Context) {
   mobile := c.PostForm("mobile")
   code := c.PostForm("code")

   // 校验手机号逻辑
   if mobile == "" {
      c.JSON(http.StatusBadRequest, gin.H{"error": "手机号不能为空"})
      return
   }

   matched, _ := regexp.MatchString(`^(1[3-9][0-9]\d{8})$`, mobile)
   if !matched {
      c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})
      return
   }

   // 校验手机号逻辑
   if code == "" {
      c.JSON(http.StatusBadRequest, gin.H{"error": "验证码不能为空"})
      return
   }

   if len(code) != 4 {
      c.JSON(http.StatusBadRequest, gin.H{"error": "验证码为4位"})
      return
   }

   fmt.Println(mobile, code)
}
复制代码

源码链接

代码分析:
这是一种比较初级也是最朴素的实现方式,在现实代码review中经常遇到,这样实现会有什么问题?
1、手机号码验证逻辑重复;
2、违背了controller层的职责,这种写法导致controller层充斥着大量的验证函数(controller层职责:从HTTP请求中获得信息,提取参数,并分发给不同的处理服务);

重复代码是软件质量下降的重大来源!!!

1、重复代码会造成维护成本的成倍增加;
2、需求的变动导致需要修改重复代码,如果遗漏某处,就会产生bug(例如手机号码增加12开头的验证规则);
3、重复代码会导致项目代码体积变得臃肿;

聪明的开发者肯定第一时间想到一个解决办法:提取出验证逻辑,工具包util实现IsMobile函数

package util

func IsMobile(mobile string) error {
   if mobile == "" {
      return errors.New("手机号不能为空")
   }

   matched, _ := regexp.MatchString(`^(1[3-9][0-9]\d{8})$`, mobile)
   if !matched {
      return errors.New("手机号格式不正确")
   }
   return nil
}

代码分析:
问题:代码会大量出现util.IsMobile、util.IsEmail等校验代码
复制代码

思考:从面向对象的思想出发,IsMobile属于util的动作或行为吗?

参数校验第二种实现方法:模型绑定校验

技术选型:web框架gin自带的模型验证器中文提示不是很好用,这里使用govalidator

模型绑定校验是目前参数校验最主流的验证方式,每个编程语言的web框架基本都支持这种模式,模型绑定时将Http请求中的数据映射到模型对应的参数,参数可以是简单类型,如整形,字符串等,也可以是复杂类型,如Product,Order等,然后对各种数据类型进行验证,然后抛出相应的错误信息。

源码链接

func init() {
   validator.TagMap["IsMobile"] = func(value string) bool {
      return IsMobile(value)
   }
}

func IsMobile(value string) bool {
   b, _ := regexp.MatchString(`^(1[1-9][0-9]\d{8})$`, value)
   return b
}

type Captcha struct {
   Mobile string `form:"mobile" valid:"required~手机号不能为空,numeric~手机号码应该为数字型,IsMobile~手机号码格式错误"`
}

type User struct {
   Mobile string `form:"mobile" valid:"required~手机号不能为空,numeric~手机号码应该为数字型,IsMobile~手机号码格式错误"`
   Code string `form:"code" valid:"required~验证码不能为空,numeric~验证码应该为数字型"`
}


type Captcha struct {}

func (ctr *Captcha) Send(c *gin.Context) {
   var request request.Captcha
   if err := c.ShouldBind(&request); err != nil {
      c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
      return
   }

   if _, err := validator.ValidateStruct(request); err != nil {
      c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
      return
   }

   fmt.Println(request)
}

type User struct {}

func (ctr *User) Login(c *gin.Context) {
   var request request.User
   if err := c.ShouldBind(&request); err != nil {
      c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
      return
   }

   if _, err := validator.ValidateStruct(request); err != nil {
      c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
      return
   }

   fmt.Println(request)
}
注:现在的模型校验实现的不是很优雅,后期会通过修改gin源代码的方式把govalidator集成在框架中,实现自动校验
复制代码

代码分析:
1、mobile校验逻辑同样重复(注释实现校验的逻辑重复)
2、ValidateStruct函数会验证结构体所有属性

对于2问题不太好理解,举例解释
业务需求:电商业务中,需要给商品增加一个分类,比如衣服的分类,长裤类型、T恤类型等。
接口一:
场景:快速添加分类,只需要输入分类名称
校验需求:判断分类名称非空,分类名称最少四个字符
接口二:
场景:分类编辑,可以修改分类名称,补充分类介绍
校验需求:1、判断分类名称非空,分类名称2-10个字符;2、分类介绍非空

type GoodsCategory struct {
   ID  int `form:"id" valid:"required~id不能为空,int~id格式不正确"`
   Name string `form:"mobile" valid:"required~分类名称不能为空,stringlength(2|10)~分类名称2-10个字符"`
   Desc string `form:"desc" valid:"required~分类介绍不能为空"`
}
复制代码

代码分析:
接口一只需要校验Name字段,接口二需要校验ID、Name、Desc三个字段,ValidateStruct只能校验全部属性,所以只能如下拆解成两个结构体:

type GoodsCategoryAdd struct {
   Name string `form:"name" valid:"required~分类名称不能为空,stringlength(2|10)~分类名称2-10个字符"`
}

type GoodsCategoryEdit struct {
   ID  int `form:"id" valid:"required~id不能为空,int~id格式不正确"`
   Name string `form:"name" valid:"required~分类名称不能为空,stringlength(2|10)~分类名称2-10个字符"`
   Desc string `form:"desc" valid:"required~分类介绍不能为空"`
}
复制代码

同样问题再次出现,Name校验逻辑重复。

参数校验第三种实现方法:拆解模型字段,组合结构体

解决字段校验逻辑重复的最终方法就是拆解字段为独立的接结构体,通过多个字段结构体的不同组合为所需的校验结构体,代码如下:
源码链接

type ID struct {
   Val  int `form:"id" valid:"required~id不能为空,int~id格式不正确"`
}

func (req *ID) ID() int {
   return req.Val
}

type Mobile struct {
   Val string `form:"mobile" valid:"required~手机号不能为空,numeric~手机号码应该为数字型,IsMobile~手机号码格式错误"`
}

func (req *Mobile) Mobile() string {
   return req.Val
}

type Name struct {
   Val string `form:"Name" valid:"required~分类名称不能为空,stringlength(2|10)~分类名称2-10个字符"`
}

func (req *Name) Name() string {
   return req.Val
}

type Desc struct {
   Val string `form:"desc" valid:"required~分类介绍不能为空"`
}

func (req *Name) Desc() string {
   return req.Val
}

type Code struct {
   Val string `form:"code" valid:"required~验证码不能为空,numeric~验证码应该为数字型"`
}

func (req *Code) Code() string {
   return req.Val
}

//组合所需结构体
type CaptchaSend struct {
   Mobile
}

type UserLogin struct {
   Mobile
   captcha.Code
}

type GoodsCategoryAdd struct {
   goodscategory.Name
}

type GoodsCategoryEdit struct {
   ID
   goodscategory.Name
   goodscategory.Desc
}
// 示例代码不能很好的展示项目结构,可以查看源代码
复制代码

1、独立的字段结构体通常以表名为包名定义范围,比如商品名称和分类名称字段名都为Name,但是所需定义的校验逻辑(字符长度等)很有可能不同;
2、每一个接口建立对应的验证结构体,例如用户登陆校验结构体UserLogin,验证码发送校验结构体CaptchaSend
3、公用的字段结构体例如ID、Mobile,集中放在common_field.go文件中;

独立字段结构体组合成不同的校验结构体,这种方式在实际项目开发中有很大的灵活性,可以满足参数校验比较多变复杂的需求场景,有些场景需要多种参数组合验证,需要手动写验证逻辑,可以在组合结构体实现。

type UserLogin struct {
   Mobile
   captcha.Code
}

func (req *UserLogin) Validate() error {
   // 实现校验逻辑
}

// 后续到项目结构篇章时会实现这一功能
复制代码

上文中遗留了一个思考问题:从面向对象的思想出发,IsMobile属于util的动作或行为吗?

面向对象是以对象为中心的开发方法,这里所说的对象是现实世界里的对象,不是随便建几个类就是面向对象开发;

现实世界里的手机号码,不仅仅是一串数字字符,它有区号(86)、长度限制(11位)、以1[3,4,5,6,7,8,9]开头、4G、5G的区别,还有运营商的不同,它是真实存在的一个活生生的对象,它有自己的属性和行为,把它的属性和行为分配给别的对象,有考虑过它的感受吗?

同理,短信验证码在现实世界里也有自己的属性和行为,可能是4位数字组成,有过期时间,有短信内容(####为您的登录验证码,如非本人操作,请忽略本短信。)等等。

短信验证码属性:
1、码值:code
2、过期时间:expire
3、需要验证的手机号:mobile
4、短信文本内容:content
短信验证码行为:
1、生成码值
2、验证码值格式是否正确
3、生成码值
4、生成短信文本内容
复制代码

所以手机号的校验行为应该是Mobile结构体的函数,代码如下:

type Mobile struct {
   Val string `form:"mobile" valid:"required~手机号不能为空,numeric~手机号码应该为数字型,IsMobile~手机号码格式错误"`
}

func (req *Mobile) Mobile() string {
   return req.Val
}

func (req *Mobile) Valid(value string) bool {
   b, _ := regexp.MatchString(`^(1[1-9][0-9]\d{8})$`, value)
   return b
}
复制代码

面向对象是一个很大的课题,这里先抛砖引玉,后续有篇章着重讲解,大家先行体会。

猜你喜欢

转载自juejin.im/post/7041508590913323039