6

本篇概述

  在正常的项目开发中,我们常常需要对程序的参数进行校验来保证程序的安全性。参数校验非常简单,说白了就是对参数进行正确性验证,例如非空验证、范围验证、类型验证等等。校验的方式也有很多种。如果架构设计的比较好的话,可能我们都不需要做任何验证,或者写比较少的代码就可以满足验证的需求。如果架构设计的有缺陷,或者说压根就没有架构的话,那么我们对参数进行验证时,就需要我们写大量相对重复的代码进行验证了。


手动参数校验

  下面我们还是以上一篇的内容为例,我们首先手动对参数进行校验。下面为Controller源码:

package com.jilinwula.springboot.helloworld.controller;

import com.jilinwula.springboot.helloworld.Repository.UserInfoRepository;
import com.jilinwula.springboot.helloworld.entity.UserInfoEntity;
import com.jilinwula.springboot.helloworld.query.UserInfoQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequestMapping("/userinfo")
public class UserInfoController {

    @Autowired
    private UserInfoRepository userInfoRepository;

    @GetMapping("/query")
    public Object list(UserInfoQuery userInfo) {
        if (StringUtils.isEmpty(userInfo.getUsername())) {
            return "账号不能为空";
        }
        if (StringUtils.isEmpty(userInfo.getRoleId()) || userInfo.getRoleId() > 100 || userInfo.getRoleId() < 1) {
            return "权限不能为空,并且范围为[1-99]";
        }
        UserInfoEntity userInfoEntity = userInfoRepository.findByUsernameAndRoleId(userInfo.getUsername(), userInfo.getRoleId());
        return userInfoEntity;
    }
}

  我们只验证了username和roleId参数,分别验证为空验证及范围验证。下面我们测试一下。启动项目后,访问以下地址:

http://127.0.0.1:8080/springb...

  我们看一下程序的运行结果。

  title

  因为我们没有写任何参数,所以参数验证一定是不能通过的。所以就返回的上图中的提示信息。下面我们看一下数据库中的数据,然后访问一下正确的地址,看看能不能成功的返回数据库中的数据。下图为数据库中的数据:

  title

  下面我们访问一下正确的参数,然后看一下返回的结果。访问地址:

http://127.0.0.1:8080/springb...

  访问结果:

  title

  我们看上图已经成功的返回数据库中的数据了,这就是简单的参数校验,正是因为简单,所以我们就不做过多的介绍了。下面我们简单分析一下,这样做参数验证好不好。如果我们的项目比较简单,那答案一定是肯定的,因为站在软件设计角度考虑,没必要为了一个简单的功能而设计一个复杂的架构。因为越是复杂的功能,出问题的可能性就越大,程序就越不稳定。但如果站在程序开发角度,那上面的代码一定是有问题的,因为上面的代码根本没办法复用,如果要开发很多这样的项目,要进行参数验证时,那结果一定是代码中有很多相类似的代码,这显然是不合理的。那怎么办呢?那答案就是本篇中的重点内容,也就是SpringBoot对参数的验证,实际上本篇的内容主要是和Spring内容相关和SpringBoot的关系不大。但SpringBoot中基本包括了所有Spring的内容,所以我们还是以SpringBoot项目为例。下面我们看一下,怎么在SpringBoot中的对参数进行校验。


ObjectError参数校验

  我们首先看一下代码,然后在详细介绍代码中的新知识。下面为接受的参数类的源码。

  修改前:

package com.jilinwula.springboot.helloworld.query;

import lombok.Data;
import org.springframework.stereotype.Component;

@Component
@Data
public class UserInfoQuery{

    private String username;

    private Long roleId;
}

  修改后:

package com.jilinwula.springboot.helloworld.query;

import lombok.Data;
import org.springframework.stereotype.Component;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

@Component
@Data
public class UserInfoQuery{

    @NotNull(message = "账号不能为空")
    private String username;

    @NotNull(message = "权限不能为空")
    @Min(value = 1, message = "权限范围为[1-99]")
    @Max(value = 99, message = "权限范围为[1-99]")
    private Long roleId;
}

  我们看代码中唯一的区别就是添加了很多的注解。没错,在SpringBoot项目中进行参数校验时,就是使用这些注解来完成的。并且注解的命名很直观,基本上通过名字就可以知道什么含义。唯一需要注意的就是这些注解的包是javax中的,而不是其它第三方引入的包。这一点要特别注意,因为很多第三方的包,也包含这些同名的注解。下面我们继续看Controller中的改动(备注:有关javax中的校验注解相关的使用说明,我们后续在做介绍)。Controller源码:

  改动前:

package com.jilinwula.springboot.helloworld.controller;

import com.jilinwula.springboot.helloworld.Repository.UserInfoRepository;
import com.jilinwula.springboot.helloworld.entity.UserInfoEntity;
import com.jilinwula.springboot.helloworld.query.UserInfoQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequestMapping("/userinfo")
public class UserInfoController {

    @Autowired
    private UserInfoRepository userInfoRepository;

    @GetMapping("/query")
    public Object list(UserInfoQuery userInfo) {
        if (StringUtils.isEmpty(userInfo.getUsername())) {
            return "账号不能为空";
        }
        if (StringUtils.isEmpty(userInfo.getRoleId()) || userInfo.getRoleId() > 100 || userInfo.getRoleId() < 1) {
            return "权限不能为空,并且范围为[1-99]";
        }
        UserInfoEntity userInfoEntity = userInfoRepository.findByUsernameAndRoleId(userInfo.getUsername(), userInfo.getRoleId());
        return userInfoEntity;
    }
}

  改动后:

package com.jilinwula.springboot.helloworld.controller;

import com.jilinwula.springboot.helloworld.Repository.UserInfoRepository;
import com.jilinwula.springboot.helloworld.entity.UserInfoEntity;
import com.jilinwula.springboot.helloworld.query.UserInfoQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;


@RestController
@RequestMapping("/userinfo")
public class UserInfoController {

    @Autowired
    private UserInfoRepository userInfoRepository;

    @GetMapping("/query")
    public Object list(@Valid UserInfoQuery userInfo, BindingResult result) {
        if (result.hasErrors()) {
            for (ObjectError error : result.getAllErrors()) {
                return error.getDefaultMessage();
            }
        }
        UserInfoEntity userInfoEntity = userInfoRepository.findByUsernameAndRoleId(userInfo.getUsername(), userInfo.getRoleId());
        return userInfoEntity;
    }
}

  我们看代码改动的还是比较大的首先在入参中添加了@Valid注解。该注解就是标识让SpringBoot对请求参数进行验证。也就是和参数类里的注解是对应的。其次我们修改了直接在Controller中进行参数判断的逻辑,将以前的代码修改成了SpringBoot中指定的校验方式。下面我们启动项目,来验证一下上述代码是否能成功的验证参数的正确性。我们访问下面请求地址:

http://127.0.0.1:8080/springb...

  返回结果:

  title

  我们看上图成功的验证了为空的校验,下面我们试一下范围的验证。我们访问下面的请求地址:

http://127.0.0.1:8080/springb...

  看一下返回结果:

  title

  我们看成功的检测到了参数范围不正确。这就是SpringBoot中的参数验证功能。但上面的代码一个问题,就是只是会返回错误的提示信息,而没有提示,是哪个参数不正确。下面我们修改一下代码,来看一下怎么返回是哪个参数不正确。

FieldError参数校验

package com.jilinwula.springboot.helloworld.controller;

import com.jilinwula.springboot.helloworld.Repository.UserInfoRepository;
import com.jilinwula.springboot.helloworld.entity.UserInfoEntity;
import com.jilinwula.springboot.helloworld.query.UserInfoQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;


@RestController
@RequestMapping("/userinfo")
public class UserInfoController {

    @Autowired
    private UserInfoRepository userInfoRepository;

    @GetMapping("/query")
    public Object list(@Valid UserInfoQuery userInfo, BindingResult result) {
        if (result.hasErrors()) {
            FieldError error = result.getFieldError();
            return error.getField() + "+" + error.getDefaultMessage();
        }
        UserInfoEntity userInfoEntity = userInfoRepository.findByUsernameAndRoleId(userInfo.getUsername(), userInfo.getRoleId());
        return userInfoEntity;
    }
}

  我们将获取ObjectError的类型修改成了FieldError。因为FieldError类型可以获取到验证错误的字段名字,所以我们将ObjectError修改为FieldError。下面我们看一下请求返回的结果。

  title

  我们看这回我们就获取到了验证错误的字段名子了。在实际的项目开发中,我们在返回接口数据时,大部分都会采用json格式的方式返回,下面我们简单封装一个返回的类,使上面的验证返回json格式。下面为封装的返回类的源码:

package com.jilinwula.springboot.helloworld.utils;

import lombok.Data;

@Data
public class Return {
    private int code;
    private Object data;
    private String msg;

    public static Return error(Object data, String msg) {
        Return r = new Return();
        r.setCode(-1);
        r.setData(data);
        r.setMsg(msg);
        return r;
    }
}

  Controller修改:

package com.jilinwula.springboot.helloworld.controller;

import com.jilinwula.springboot.helloworld.Repository.UserInfoRepository;
import com.jilinwula.springboot.helloworld.entity.UserInfoEntity;
import com.jilinwula.springboot.helloworld.query.UserInfoQuery;
import com.jilinwula.springboot.helloworld.utils.Return;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;


@RestController
@RequestMapping("/userinfo")
public class UserInfoController {

    @Autowired
    private UserInfoRepository userInfoRepository;

    @GetMapping("/query")
    public Object list(@Valid UserInfoQuery userInfo, BindingResult result) {
        if (result.hasErrors()) {
            FieldError error = result.getFieldError();
            return Return.error(error.getField(), error.getDefaultMessage());
        }
        UserInfoEntity userInfoEntity = userInfoRepository.findByUsernameAndRoleId(userInfo.getUsername(), userInfo.getRoleId());
        return userInfoEntity;
    }
}

  我们还是启动项目,并访问下面地址看看返回的结果:

http://127.0.0.1:8080/springb...

  返回结果:

  title

创建切面

  这样我们就返回一个简单的json类型的数据了。虽然我们的校验参数的逻辑没有在Controller里面写,但我们还是在Controller里面写了很多和业务无关的代码,并且这些代码还是重复的,这显然是不合理的。我们可以将上述相同的代码的封装起来,然后统一的处理。这样就避免了有很多重复的代码了。那这代码封装到哪里呢?我们可以使用Spring中的切面功能。因为SpringBoot中基本包括了所有Spring中的技术,所以,我们可以放心大胆的在SpringBoot项目中使用Spring中的技术。我们知道在使用切面技术时,我们可以对方法进行前置增强、后置增强、环绕增强等。这样我们就可以利用切面的技术,在方法之前,也就是请求Controller之前,做参数的校验工作,这样就不会对我们的业务代码产生侵入了。下面我们看一下切面的源码然后在做详细说明:

package com.jilinwula.springboot.helloworld.aspect;

import com.jilinwula.springboot.helloworld.utils.Return;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;

@Slf4j
@Aspect
@Component
public class UserAspect {

    @Before("execution(public * com.jilinwula.springboot.helloworld.controller..*(..))")
    public void doBefore(JoinPoint joinPoint) {
        for (Object arg : joinPoint.getArgs()) {
            if (arg instanceof BindingResult) {
                BindingResult result = (BindingResult) arg;
                if (result.hasErrors()) {
                    FieldError error = result.getFieldError();
                    Return.error(error.getField(), error.getDefaultMessage());
                }
            }
        }
    }
}

  我们看上述的代码中我们添加了一个@Aspect注解,这个就是切面的注解,然后我们在方法中又添加了@Before注解,也就是对目标方法进行前置增强,Spring在请求Controller之前会先请求此方法。所以我们可以将校验参数的代码逻辑写在这个方法中。execution参数为切点函数,也就是目标方法的切入点。切点函数包含一些通配符的语法,下面我们简单介绍一下:

    • 匹配任意字符,但它可能匹配上下文中的一个元素
  • .. 匹配任意字符,可以匹配上下文中的多个元素
    • 表示按类型匹配指定类的所有类,必须跟在类名后面,也就是会匹配继承或者扩展指定类的所有类,包括指定类.

创建异常类

  我们通过上述代码知道,Spring中的切面功能是没有返回值的。所以我们在使用切面功能时,是没有办法在切面里面做参数返回的。那我们应该怎么办呢?这时异常就派上用场了。我们知道当程序抛出异常时,如果当前方法没有做try catch处理,那么异常就会一直向上抛出,如果程序也一直没有做处理,那么当前异常就会一直抛出,直到被Java虚拟机捕获。但Java虚拟机也不会对异常进行处理,而是直接抛出异常。这也就是程序不做任何处理抛出异常的根本原因。我们正好可以利用异常的这种特性,返回参数验证的结果。因为在Spring中为我们提供了统一捕获异常的方法,我们可以在这个方法中,将我们的异常信息封装成json格式,这样我们就可以返回统一的jons格式了。所以在上述的切面中我们手动了抛出了一个异常。该异常因为我们没有用任何处理,所以上述异常会被SpringBoot中的统一异常拦截处理。这样当SpringBoot检测到参数不正确时,就会抛出一个异常,然后SpringBoot就会检测到程序抛出的异常,然后返回异常中的信息。下面我们看一下异常类的源码:

  异常类:

package com.jilinwula.springboot.helloworld.exception;

import com.jilinwula.springboot.helloworld.utils.Return;
import lombok.Data;

@Data
public class UserInfoException extends RuntimeException {
    private Return r;

    public UserInfoException(Return r) {
        this.r = r;
    }
}

  Return源码:

package com.jilinwula.springboot.helloworld.utils;

import com.jilinwula.springboot.helloworld.exception.UserInfoException;
import lombok.Data;

@Data
public class Return {
    private int code;
    private Object data;
    private String msg;

    public static void error(Object data, String msg) {
        Return r = new Return();
        r.setCode(-1);
        r.setData(data);
        r.setMsg(msg);
        throw new UserInfoException(r);
    }

    public static Return success() {
        Return r = new Return();
        r.setCode(0);
        return r;
    }
}

SpringBoot统一异常拦截

  因为该异常类比较简单,我们就不会过多的介绍了,唯一有一点需要注意的是该异常类继承的是RuntimeException异常类,而不是Exception异常类,原因我们已经在上一篇中介绍了,Spring只会回滚RuntimeException异常类及其子类,而不会回滚Exception异常类的。下面我们看一下Spring中统一拦截异常处理,下面为该类的源码:

package com.jilinwula.springboot.helloworld.handler;

import com.jilinwula.springboot.helloworld.exception.UserInfoException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class UserInfoHandler {


    /**
     * 校验错误拦截处理
     *
     * @param e 错误信息集合
     * @return 错误信息
     */
    @ExceptionHandler(UserInfoException.class)
    public Object handle(UserInfoException e) {
        return e.getR();
    }
}

  我们在该类添加了@RestControllerAdvice注解。该注解就是为了定义我们统一获取异常拦截的。然后我们又添加了@ExceptionHandler注解,该注解就是用来拦截异常类的注解,并且可以在当前方法中,直接获取到该异常类的对象信息。这样我们直接返回这个异常类的信息就可以了。因为我们在这个自定义异常类中添加了Return参数,所以,我们只要反悔Return对象的信息即可,而不用返回整个异常的信息。下面我们访问一下下面的请求,看看上述代码是否能检测到参数不正确。请求地址:

http://127.0.0.1:8080/springb...

  返回结果:

  title

  这样我们完成了参数校验的功能了,并且这种方式有很大的复用性,即使我们在写新的Controller,也不需要手动的校验参数了,只要我们的请求参数是UserInfoQuery类就可以了。还有一点要注意,所以我们不用手动验证参数了,但我们的请求参数中还是要写BindingResult参数,这一点要特别注意。


正则表达式校验注解

  下面我们更详细的介绍一下参数验证的注解,我们首先看一下正则校验,我们在实体类中添加一个新属性,然后用正则的的方式,验证该参数的正确性。下面为实体类源码:

package com.jilinwula.springboot.helloworld.query;

import lombok.Data;
import org.springframework.stereotype.Component;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;

@Component
@Data
public class UserInfoQuery{

    @NotNull(message = "用户编号不能为空")
    @Pattern(regexp = "^[1-10]$",message = "用户编号范围不正确")
    private String id;

    @NotNull(message = "账号不能为空")
    private String username;

    @NotNull(message = "权限不能为空")
    @Min(value = 1, message = "权限范围为[1-99]")
    @Max(value = 99, message = "权限范围为[1-99]")
    private Long roleId;
}

  下面我们访问以下地址:

http://127.0.0.1:8080/springb...

http文件请求接口

  但这回我们不在浏览器里请求,因为浏览器请求不太方便,并且返回的json格式也没有格式化不方便浏览,除非要装一些浏览器插件才可以。实际上在IDEA中我们可以很方便的请求一下接口地址,并且返回的json内容是自动格式化的。下面我们来看一下怎么在IDEA中发起接口请求。在IDEA中请求一个接口很简单,我们只要创建一个.http类型的文件名字就可以。然后我们可以在该文件中,指定我们接口的请求类型,例如GET或者POST。当我们在文件的开口写GET或者POST时,IDEA会自动有相应的提示。下面我们看一下http文件中的内容。

  http.http:

GET http://127.0.0.1:8080/springboot/userinfo/query?roleId=3&username=阿里巴巴&id=-1

  这时标识GET参数的地方,就会出现绿色剪头,但我们点击这个绿色箭头,IDEA就会就会启动请求GET参数后面的接口。下面我们看一下上述的返回结果。

GET http://127.0.0.1:8080/springboot/userinfo/query?roleId=3&username=%E9%98%BF%E9%87%8C%E5%B7%B4%E5%B7%B4&id=-1

HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 18 Feb 2019 03:57:29 GMT

{
  "code": -1,
  "data": "id",
  "msg": "用户编号范围不正确"
}

Response code: 200; Time: 24ms; Content length: 41 bytes

  这就是.http文件类型的返回结果,用该文件请求接口,相比用浏览器来说,要方便的多。因为我们在实体类中使用正则指定参数范围为1-10,所以请求接口时反悔了id参数有错误。下面我们输入一个正确的值在看一下返回结果。

  http.http:

GET http://127.0.0.1:8080/springboot/userinfo/query?roleId=3&username=阿里巴巴&id=1

  返回结果:


  GET <http://127.0.0.1:8080/springboot/userinfo/query?roleId=3&username=%E9%98%BF%E9%87%8C%E5%B7%B4%E5%B7%B4&id=1>

  HTTP/1.1 200

  Content-Type: application/json;charset=UTF-8

  Transfer-Encoding: chunked

  Date: Mon, 18 Feb 2019 05:46:49 GMT

  {

  "id": 61,

  "username": "阿里巴巴",

  "password": "alibaba",

  "nickname": "阿里巴巴",

  "roleId": 3

  }

  Response code: 200; Time: 25ms; Content length: 77 bytes

常见校验注解

  我们看已经正确的返回数据库中的数据了。在Spring中,提供了很多种注解来方便我们进行参数校验,下面是比较常见的注解:

注解 作用
@Null 参数必须为null
@NotNull 参数必须不为null
@NotBlank 参数必须不为null,并且长度必须大于0
@NotEmpty 参数必须不为空
@Min 参数必须大于等于该值
@Max 参数必须小于等于该值
@Size 参数必须在指定的范围内
@Past 参数必须是一个过期的时间
@Future 参数必须是一个未来的时间
@Pattern 参数必须满足正则表达式
@Email 参数必须为电子邮箱

  上述内容就是SpringBoot中的参数校验全部内容,如有不正确的欢迎留言,谢谢。


源码地址

https://github.com/jilinwula/...

原文地址

http://jilinwula.com/article/...

吉林乌拉
456 声望31 粉丝