V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
diamondfsd
V2EX  ›  Java

Spring MVC 通过切面,实现超灵活的注解式数据校验

  •  
  •   diamondfsd · 2017-02-19 12:43:01 +08:00 · 3540 次点击
    这是一个创建于 2833 天前的主题,其中的信息可能已经有所发展或是发生改变。

    这篇文在主要是介绍,如何在 Controller 的方法里面,让校验注解 ( @NotNull @Email @Size...等),对基本类型的数据生效(基本类型 Integer,String,Long 等)。

    Spring MVC 有什么校验方式?

    大家都知道, Spring MVC 默认依赖了 hibernate-validator 校验框架。使用这个,我们可以在可以在 model 的字段上,加相应的校验注解来轻松的实现数据校验。 例如:

    // 实体类
    public class User {
      @NotNull
      private String username;
      
      @NotBlank
      @Length(min = 6, max = 32)
      private String password;
    
    }
    
    // Controller 请求
    @RequestMapping("save-user")
     // 使用 @Valid 注解,告诉 Spring MVC 要校验 user 对象的数据
    public User save(@Valid User user){
    .....
    }
    

    相信大家都有接触过,使用这种方法来实现整体对象的校验,而且还可以根据不同场景,加上不同的 @Group 注解,来实现不同请求对数据的校验规则。

    我们想实现什么?

    但是有些时候,我们的请求参数并不多,可能只是一些零碎的基本类型的参数 例如 String Integer Long 等等。就像下面这个请求:

    @RequestMapping("update-user-status")
    public User update(String userId, Integer status){
    ....
    }
    

    这种情况相信大家经常遇到,大部分情况下,我们都需要对接收过来的数据做校验。 如果接收过来的是基本类型,我们一般都是包装一些工具类,然后通过编码的方式来实现校验。如果这个时候,我们想用 @NotNull, @Email,@Size 等校验注解,直接加在参数上,是做不到的。
    例如这样, spring mvc 是不支持的。

    @RequestMapping("update-user-status")
    public User update( 
                  @NotNull String userId, 
                  @NotNull @Range(min = 0, max = 5) Integer status){
    ....
    }
    

    那么这篇文章主要就是讲解,如何让加在基本类型上的校验注解生效,最终实现上代码所呈现的效果,在基本类型参数上校验注解,执行校验逻辑。

    Hibernate-Validator 方法参数校验说明

    因为 Spring MVC 默认使用的是 Hibernate-Validator 来进行数据校验,那么我首先瞄准的目标也就是看看 Hibernate-Validator 有没有什么方法可以直接对方法的参数进行校验。
    最终我在官方文档里找到, ExecutableValidator 这个接口里有一个 validateParameters,实现我们想要的功能,该方法声明如下:

    interface ExecutableValidator{
    //...
     <T> Set<ConstraintViolation<T>> validateParameters(
                                  T object, // 需要校验的方法所属对象
                                  Method method,  // 需要校验的方法
                                  Object[] parameterValues, // 需要交验的方法对应的参数
                                  Class<?>... groups);  // 校验组(这里我们暂时用不到)
    //...
    }
    

    如何使用呢? 我写了一个测试类来测试这个方法具体返回的内容。

    package diamond.cms.server.mvc.valid;
    
    import java.lang.reflect.Method;
    import java.util.Set;
    
    import javax.validation.ConstraintViolation;
    import javax.validation.Validation;
    import javax.validation.ValidatorFactory;
    import javax.validation.constraints.NotNull;
    import javax.validation.executable.ExecutableValidator;
    
    import org.hibernate.validator.constraints.NotBlank;
    import org.hibernate.validator.constraints.Range;
    import org.junit.Test;
    
    public class ExecutableValidatorTest {
    
        @Test
        public void hibernateVaildTest() throws NoSuchMethodException, SecurityException {
            // 需要校验的方法实例
            ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
            ExecutableValidator validator = factory.getValidator().forExecutables();
    
            Method method = this.getClass().getMethod("vaildMethod",  Integer.class, String.class, String.class);
            // 校验参数,应该是有两个非法的参数
            Object [] params = new Object[]{100, "", "test"};
    
            // 获得校验结果 Set 集合,有多少个字段校验错误 Set 的大小就是多少
            Set<ConstraintViolation<ExecutableValidatorTest>> constraintViolationSet =
                    validator.validateParameters(this, method, params);
    
            System.out.println("非法参数校验结果条数: " + constraintViolationSet.size());
            constraintViolationSet.forEach(cons -> {
                System.out.println("非法消息: " + cons.getMessage());
            });
    
            params = new Object[]{10, "build-test", "test"};
            constraintViolationSet =
                    validator.validateParameters(this, method, params);
    
            System.out.println("合法参数校验结果条数: " + constraintViolationSet.size());
        }
    
        // 校验示范方法
        public void vaildMethod(@NotNull @Range(min = 0, max = 18)Integer age,@NotBlank String build, String test){}
    }
    
    

    上面的方法最终输出:

    非法参数校验结果条数: 2
    非法消息: 需要在 0 和 18 之间
    非法消息: 不能为空
    合法参数校验结果条数: 0
    

    获得校验所需参数,统一处理进行数据校验

    如何去使用我们上面提到的数据校验方法呢?首先我们要想,如何去获得我们需要的参数。我们需要以下参数:

    1. 请求执行的目标对象
    2. 请求执行的方法
    3. 请求的参数

    有两种方式来获得:

    1. 通过实现 HandlerInterceptor 拦截器来实现

    因为通过拦截器实现,有很多坑要填,这里不推荐使用。主要讲第二个方法,通过 AOP 来实现校验数据获取。

    2. 通过 AOP (切面)来实现校验数据获取

    首先说,推荐使用这种方式,上面的那个方式在这篇文章里只是说说而已。我们来讲一讲,如何实现这样一个切面,来获取我们校验数据所需的参数。

    首先定义一个切面,切入点是所有 controllers 包下所有类的所有方法。 最后我们定义一个方法,在切入点方法之前执行。

    当执行到 Controller 这一层的时候,所有的数据已经被 Spring MVC 处理好了,包括数据类型的转换,自定义的WebDataBinder等。所以我们可以直接通过切面获得所需的校验参数,做最终校验。

    @Component
    @Aspect
    public class RequestParamValidAspect{
    
        Logger log = LoggerFactory.getLogger(getClass());
    
        @Pointcut("execution(* diamond.cms.server.mvc.controllers.*.*(..))")
        public void controllerBefore(){};
    
        ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
    
        @Before("controllerBefore()")
        public void before(JoinPoint point) throws NoSuchMethodException, SecurityException, ParamValidException{
            //  获得切入目标对象
            Object target = point.getThis();
            // 获得切入方法参数
            Object [] args = point.getArgs(); 
            // 获得切入的方法
            Method method = ((MethodSignature)point.getSignature()).getMethod(); 
              
            // 执行校验,获得校验结果
            Set<ConstraintViolation<Object>> validResult = validMethodParams(target, method, args);
    
            if (!validResult.isEmpty()) {
                String [] parameterNames = parameterNameDiscoverer.getParameterNames(method); // 获得方法的参数名称
                List<FieldError> errors = validResult.stream().map(constraintViolation -> {
                    PathImpl pathImpl = (PathImpl) constraintViolation.getPropertyPath();  // 获得校验的参数路径信息
                    int paramIndex = pathImpl.getLeafNode().getParameterIndex(); // 获得校验的参数位置
                    String paramName = parameterNames[paramIndex];  // 获得校验的参数名称
                    FieldError error = new FieldError();  // 将需要的信息包装成简单的对象,方便后面处理
                    error.setName(paramName);  // 参数名称(校验错误的参数名称)
                    error.setMessage(constraintViolation.getMessage()); // 校验的错误信息
                    return error;
                }).collect(Collectors.toList());
                throw new ParamValidException(errors);  // 我个人的处理方式,抛出异常,交给上层处理
            }
        }
    
        private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        private final ExecutableValidator validator = factory.getValidator().forExecutables();
    
        private <T> Set<ConstraintViolation<T>> validMethodParams(T obj, Method method, Object [] params){
            return validator.validateParameters(obj, method, params);
        }
    }
    

    FieldError.java

    class FieldError implements Serializable{
        private String name;
        private String message;
        // getter / setter...
    }
    

    ParamValidException.java

    public class ParamValidException extends Exception{
        private List<FieldError>;
        public ParamValidException(List<FieldError> errors) {
            this.fieldErrors = errors;
        }
    }
    

    通过这样的方式,我们请求这个方法:

    @RequestMapping(value = "token")
    public Result token(@NotBlank String username, @NotBlank String password){
        String token = userService.login(username, PwdUtils.pwd(password));
        Result result = new Result(token);
        return result;
    }
    
    1. 模拟请求不传参数 http://localhsot/token
    {
      "success": false,
      "msg": "invalid params: [`password` 不能为空, `username` 不能为空]",
      "code": 10012,
      "data": [
        {
          "name": "password",
          "message": "不能为空"
        },
        {
          "name": "username",
          "message": "不能为空"
        }
      ]
    }
    
    1. 模拟请求,只传 username 参数 http://localhost/token?username=testusername
    {
      "success": false,
      "msg": "invalid params: [`password` 不能为空]",
      "code": 10012,
      "data": [
        {
          "name": "password",
          "message": "不能为空"
        }
      ]
    }
    
    1. 模拟请求,传正确参数 http://localhost/token?username=testusername&password=testpassword
    {
      "success": true,
      "code": 0,
      "data": "token-data"
    }
    

    以上请求结果,都是获取基本错误信息封装得来的,根据实际情况可能不同,主要是为了讲解在方法上添加 校验注解 的效果。

    FAQ (常见问题解答)

    为什么我抛出异常后捕获不到?

    切面内抛出的异常都会被 UndeclaredThrowableException包装,需要先捕获这个异常,获得这个异常后,调用这个他的 getUndeclaredThrowable() 方法,就可以获得实际的异常了, 例如:

     @ExceptionHandler(UndeclaredThrowableException.class)
     public Result undeclaredThrowableException(UndeclaredThrowableException ex, HttpServletResponse response){
            Throwable throwable = ex.getUndeclaredThrowable(); // 获得实际异常
            if (throwable instanceof ParamValidException) { // 如果是我们自定义异常就调用自定义异常的处理方法
                return paramValidExceptionHandler((ParamValidException)throwable, response);
            }
            return exception(ex, response);
        }
    

    为什么不实现 HandlerInterceptor 拦截器来处理?

    实现拦截器后,拦截器提供 preHandle 方法,在请求处理之前执行。

    preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
    

    这个方法传过来的最后一个参数 Object handler 实际上是一个 HandlerMethod 对象。
    可以通过强制转换获得 HandlerMethod methodHandler = (HandlerMethod) handler;

    通过这个对象,我们可以获取到处理本次请求的处理对象 HandlerMethod.getBean(),本次请求的处理方法 MethodHandler.getMethod()。 至此,我们校验需要的前两个参数都有了。
    问题就在这最后一个参数上,最后一个参数我们需要获得前端传过来的数据,在这里,我们只能从 HttpServletRequest request 里面获取。 从 request 获取的参数,都只是原始的 String[] 没有经过处理和转换。
    如果要实际使用,还需要转换成 方法 对应的数据类型,并考虑自定义的 WebDataBinder 或其复杂类型的数据转换。 相当于要把 Spring MVC 处理参数的逻辑重新实现一遍。虽然也是可以完成的,但是太过于复杂,所以不推荐使用这种方式。

    代码中的 LocalVariableTableParameterNameDiscoverer 是个什么东西

    我们需要知道被校验的参数的名称,以便告诉前端,具体是哪个参数有问题。 通过 LocalVariableTableParameterNameDiscoverer.getParameterNames(Method method) 方法,获取到一个字符串数组,里面就是包含的方法的参数名称。例如如下这个方法。

    @RequestMapping(value = "token")
    public Result token(@NotBlank String username, @NotBlank String password){
        String token = userService.login(username, PwdUtils.pwd(password));
        Result result = new Result(token);
        return result;
    }
    
    // 演示代码
    LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
    Method method = demo.getMethod("token", String.class, String.class);
    String [] paramNames = discoverer.getParameterNames(method);
    // 最终获得  
    paramNames: ["username", "password"]
    

    项目源码

    博客系统后台源码: Github-cms-admin-end
    文章内容的代码片段: Github-cms-admin-end-valid
    个人博客: https://diamondfsd.com

    文章到此就结束了,希望对大家有所帮助,有什么问题也可以在下方进行讨论。

    4 条回复    2017-03-02 16:32:39 +08:00
    gejun123456
        1
    gejun123456  
       2017-02-19 18:19:01 +08:00 via Android
    写得挺好的,我一般都把一个参数也封装成一个 request 这个解决方法不错
    jelinet
        2
    jelinet  
       2017-02-22 15:16:20 +08:00
    鼓掌。顺便说,我前段时间也是写了一个类似的。我是放在 service 用的。我支持了方法级别校验 @NotNull 这种,另外支持了 Spring 的 Validated 。
    UndeclaredThrowableException 这种异常出现使得我专门定义了一个 RuntimeException 。
    jelinet
        3
    jelinet  
       2017-02-22 15:58:28 +08:00
    看了楼主写的,我又回过头看了一眼 MethodValidationPostProcessor 。
    发现可以不用写这么多就能实现 controller 的校验。。我好像是来砸场子的。。。 2333333
    Michaelssss
        4
    Michaelssss  
       2017-03-02 16:32:39 +08:00
    其实吧。。我想说的是遇到这种问题。。自己手撸一个反射解决的更快。。。 Spring 全家桶真心不习惯。。。 Spring 只有 IOC 和 JDBC 我是用得最多的。。。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1487 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 17:16 · PVG 01:16 · LAX 09:16 · JFK 12:16
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.