spring mvc validation

是服务就需要对外提供接口,否则该服务就没有任何意义。接口需要指定具体的入参情况,以保证服务能够正常地运行。spring mvc通过controller中的method对外提供接口服务,本文就如何在spring mvc中对RequestParamPathVariableRequestBody三种类型的参数做参数校验做简单介绍。

为了更好地介绍上诉三种类型参数校验的方式,本文将通过一个简单的接口需求来完成相应的接口校验,相关的代码在spring-demo 项目上。

1 需求

现在有一个学生管理系统(假设所有学生的姓名都是唯一的),我们需要对学生信息进行管理,即实现最常见的CURD操作。学生类(Student)如下所示:

@JsonIgnoreProperties(ignoreUnknown = true)
public class Student {
    private String studentName;
    private int age;
    private int gender;
    private LocalDate birthDay;
    
    // ... getter setter toString
}

现在需要提供CURD四个接口,接口的具体要求如下:

  1. 添加:采用POST的方式请求,姓名不能为空,年龄在1~200之间,性别用0和1表示,出生日期不能为空且只能是过去的时间。
  2. 删除:根据姓名删除学生,姓名不能为空
  3. 修改:修改指定学生的出生日期
  4. 查询:查询所有出生日期在指定范围内的学生

默认情况下请求参数无法直接映射成LocalDate类型的,需要在spring中配置jacksonobjectmapper添加JavaTimeModule

2 依赖项

2.1 JSR 380

JSR制定了许多Java开发的规范,其中JSR 380就制定Bean Validation的相关规范,可以在pom.xml中加入依赖引入相关API。

<!-- https://mvnrepository.com/artifact/javax.validation/validation-api -->
<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>

JSR 380只是规范,并没有具体实现检验的方法,如果直接使用validation-api进行校验,会抛出javax.validation.NoProviderFoundException,提示需要提供实现JSR 380的校验器

2.2 Hibernate Validator

Hibernate ValidatorJSR 380规范的具体实现,并且除了JSR 380中的校验器,它还提供了更多的自定义的校验器。
pom.xml中加入如下依赖引入Hibernate Validator

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.13.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.1-b09</version>
</dependency>

实际上只需要添加2.2的依赖即可,2.1的依赖可以不用添加,因为2.2中已经包含了validation-api中的内容。

3 应用Hibernate Validator

Hibernate Validator实现了JSR 380的规范,提供了诸如@NotNull等的校验器,本文这里不具体介绍Hibernate Validator都提供了哪些校验器,感兴趣的话可以去Hibernate Validator官网查看相关的文档。

3.1 添加学生信息

  1. 根据1中所述的需求,现在对StudentModel的字段添加校验规则,如下:

    public class StudentModel {
        @NotBlank(message = "studentName不能为空")
        private String studentName;
    
        @Min(value = 1, message = "参数age不能小于1")
        @Max(value = 200, message = "参数age不能大于200")
        @Range(min = 1, max = 200, message = "age只能在1到200之间")
        private int age;
        
        @Range(min = 0, max = 1, message = "gender只能取0或者1")
        private int gender;
    
        @Past(message = "birthDay只能是过去的时间")
        private LocalDate birthDay; 
    }
    
  2. 在方法的参数中对StudentModel通过@Validation进行校验

    @PostMapping(value = "/add", consumes =      MediaType.APPLICATION_JSON_UTF8_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Map<String, Object> add(@Valid @RequestBody StudentModel studentModel) {
      return OutPut.success(HttpStatusWrapper.OK,"成功", studentModel);
    }
    

    @ValidHibernate Validator中用来校验对象合法性的注解。
    请求运行并且请求add接口的时候,当post body中的数据不符合设置的校 验规则是,系统并没有返回对应的错误信息,而是输出下面的信息:

    Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.util.Map<java.lang.String, java.lang.Object> com.lianglei.spring.demo.controller.StudentController.add(com.lianglei.spring.demo.model.StudentModel): [Field error in object 'studentModel' on field 'gender': rejected value [2]; codes [Range.studentModel.gender,Range.gender,Range.int,Range]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [studentModel.gender,gender]; arguments []; default message [gender],1,0]; default message [gender只能取0或者1]] ]
    

    从上面的异常信息中可以看出,gender要求只能是0或者1,但是输入的2,被Hibernate Validatorrejected了。从中我们可以发现,校验不通过的时候,会抛出org.springframework.web.bind.MethodArgumentNotValidException异常。因此我们可以通过统一异常捕获的方式处理校验不通过的情况,给出友好的接口返回。

  3. 捕获MethodArgumentNotValidException

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
        
        /**
         * 接口参数校验异常
         * @param e
         * @return
         */
        @ExceptionHandler(value = {MethodArgumentNotValidException.class})
        public Map<String, Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
            LOGGER.error(e.getMessage(), e);
            final String message = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining());
            return OutPut.failure(HttpStatusWrapper.ILLEGAL_REQUEST_PARAMETERS, message);
        }
    }
    

    添加异常捕获后的输出如下:

    {
        "status": "Illegal request parameters",
        "code": 460,
        "msg": "gender只能取0或者1"
    }
    

    如果想在GlobalExceptionHandler中处理MethodArgumentNotValidException.class异常的话,需要注意GlobalExceptionHandler不能继承ResponseEntityExceptionHandler否则会发生冲突。

    org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'handlerExceptionResolver' defined in org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.web.servlet.HandlerExceptionResolver]: Factory method 'handlerExceptionResolver' threw exception; nested exception is java.lang.IllegalStateException: Ambiguous @ExceptionHandler method mapped for [class org.springframework.web.bind.MethodArgumentNotValidException]: {public java.util.Map com.lianglei.spring.demo.exception.GlobalExceptionHandler.handleMethodArgumentNotValidException(org.springframework.web.bind.MethodArgumentNotValidException), public final org.springframework.http.ResponseEntity org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler.handleException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception}ß 
    
  4. request body中参数名与StudentModel 不一致的情况
    在实际开发过程中,经常会出现入参的名字与请求类中的名字不一致的情况,比如说请求是student_name,而类中字段名为studentName

    因为是POST请求,采用的是JSON的方式,所以只需要在studentName上通过@JsonProperty注解一下即可,如下:

    @JsonProperty("student_name")
    @NotBlank(message = "student_name不能为空")
    private String studentName;
    

3.2 删除学生信息

采用DELETE删除指定姓名的学生,需要判断姓名不能为空,这里采用@RequestParam获取student_name参数。

  1. 直接通过@NotBlank校验

    @DeleteMapping(value = "/delete", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Map<String, Object> delete(@NotBlank(message = "student_name不可以为空") @RequestParam(name = "student_name") String name) {
        return OutPut.success(HttpStatusWrapper.OK,"成功", name);
    }
    

    这里通过@NotBlank要求student_name不可以为空(null或者trim之后的""),运行后程序正常运行,但是@NotBlank并没有生效--student_name即使填了空白也没有报错

    这是因为不像@Valid可以直接作用在@RequestBody参数上,@NotBlank并不会直接在@RequestParam参数上生效。

  2. Controller上添加@Validated配合@NotBlank校验

    参考Validating RequestParams and PathVariables in Spring MVC这篇文章,了解到@RequestParam上的validation需要在类上标注@Validated注解(即在StudentController上注解)

    然而添加改注解运行后,@NotBlank仍然没有生效。原因是没有为@RequestParam配置注解器。

  3. spring mvc配置校验器

    @Configuration
    @EnableWebMvc
    @EnableAspectJAutoProxy
    @EnableScheduling
    @ComponentScan(basePackages = "com.lianglei.spring.demo")
    public class ApplicationConfig implements WebMvcConfigurer {
        @Bean
        public Validator validator() {
            ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                    .configure()
                    .failFast(true)
                    .buildValidatorFactory();
    
            return validatorFactory.getValidator();
        }
    
        @Bean
        public MethodValidationPostProcessor methodValidationPostProcessor() {
            MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
            methodValidationPostProcessor.setValidator(validator());
            return methodValidationPostProcessor;
        }
    
    }
    

    failFast的意思只要出现校验失败的情况,就立即结束校验,不再进行后续的校验。

    如何在spring中配置bean,若有疑问请参看Spring中的Bean配置方式一文

    配置成功后,再次运行服务进行delete请求,系统抛出如下异常:

    [ERROR] 2018-12-23 09:51:10,243 method:com.lianglei.spring.demo.exception.GlobalExceptionHandler.handleException(GlobalExceptionHandler.java:34)
    delete.arg0: name不可以为空
    javax.validation.ConstraintViolationException: delete.arg0: name不可以为空
        at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:116)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688)
        at com.lianglei.spring.demo.controller.StudentController$$EnhancerBySpringCGLIB$$2cfc55af.delete(<generated>)
        at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:215)
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:142)
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:102)
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:800)
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1038)
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:942)
        at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:998)
        at org.springframework.web.servlet.FrameworkServlet.doDelete(FrameworkServlet.java:923)
        ...
    

    从这里可以看出,@RequestParam上validate失败后抛出的异常是javax.validation.ConstraintViolationException而不是@RequestBody中的MethodArgumentNotValidException异常。

  4. 捕获ConstraintViolationException异常

    @ExceptionHandler(value = {ConstraintViolationException.class})
    public Map<String, Object> handleConstraintViolationException(ConstraintViolationException e) {
        LOGGER.error(e.getMessage(), e);
        final String message = e.getConstraintViolations().stream()
                .map(ConstraintViolation::getMessage)
                .collect(Collectors.joining());
        return OutPut.failure(HttpStatusWrapper.ILLEGAL_REQUEST_PARAMETERS, message);
    }
    

    自此,@RequestParam就能够自动生效了。

    {
        "status": "Illegal request parameters",
        "code": 460,
        "msg": "student_name不可以为空"
    }
    

3.3 修改学生信息

修改学生信息一般通过@RequestBody将参数传递给StudentModel,这时候校验方式同添加学生信息中所述。但是,如果需要通过StudentModel mapping 原先的@RequestParam参数,又该如何呢?

  1. 请求中直接通过@Valid校验StudentModel

    @PutMapping(value = "/update", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Map<String, Object> update(@Valid StudentModel studentModel) {
        return OutPut.success(HttpStatusWrapper.OK,"成功", studentModel);
    }
    

    执行请求:

    localhost:8080/student/update?student_name=wangwu&age=1&gender=0&birth_day=1994-06-15
    

    异常如下:

    Field error in object 'studentModel' on field 'studentName': rejected value [null]; codes [NotBlank.studentModel.studentName,NotBlank.studentName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [studentModel.studentName,studentName]; arguments []; default message [studentName]]; default message [studentName不能为空]
        at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:164)
        at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:124)
        at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:165)
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138)
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:102)
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:800)
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1038)
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:942)
        at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:998)
        at org.springframework.web.servlet.FrameworkServlet.doPut(FrameworkServlet.java:912)
        at javax.servlet.http.HttpServlet.service(HttpServlet.java:710)
        at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:875)
        at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
        at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:848)
    

    可见student_name并没有映射到studentName上,导致studentNamenull。这也是必然的,因为@JsonProperty是用来处理json字符串转对象的,而请求中并没有json格式的学生信息。当把请求中的student_name改回studentName后,studentName就会被正确赋值。
    那么,如何才能解决非POST json形式下,请求参数和对象的属性名不一致的情况呢?

    1. 请求及接口修改成PSOT json的形式
    2. 参考 How to customize parameter names when binding spring mvc command objects中的讨论或者绑定SpringMvc GET请求对象时自定义参数名总结的方法。
    3. 拆分对象字段到接口参数中,通过@RequestParam结合Hibernate Validator完成验证

3.4 查询学生信息

通过GET方式查询学生信息,student_name@PathVariable的方式进行赋值。

@GetMapping(value = "/get/{student_name}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Map<String, Object> get(@NotBlank(message = "student_name不可以为空") @Size(min = 3, message = "student_name长度不能小于3") @PathVariable(name = "student_name") String name) {
    return OutPut.success(HttpStatusWrapper.OK,"成功", name);
}
  1. 正常请求

    localhost:8080/student/get/wangwu
    

    返回

    {
        "status": "OK",
        "code": 200,
        "msg": "成功",
        "data": "wangwu"
    }
    
  2. 异常请求

    localhost:8080/student/get/yy
    

    返回

    {
        "status": "Illegal request parameters",
        "code": 460,
        "msg": "student_name长度不能小于3"
    }
    

    抛出的与@RequestParam方式一样的ConstraintViolationException异常。

4 总结

通过上面的示例演示,对于spring mvc中的参数校验,可以得出如下结论:

  1. 如果接口参数对应的是请求中请求体部分(@RequestBody),且请求体的格式为json,可以将请求参数封装到一个类中,在类中通过@NotNull等标注设置校验规则,在接口中通过@Valid表明需要进行校验,校验失败后会抛出MethodArgumentNotValidException异常。如果请求中参数的名称和接口参数中字段的名称不一致,可以通过@JsonProperty标注进行重命名。
  2. 如果接口参数中对应的是请求参数(@RequestParam)或者请求路径中的变量(@PathVariable),则可以通过对应的@RequestParam或者@PathVariable结合@NotNull进行参数检验,注意这里需要在Controller上添加@Validated注解,并且需要给spring配置MethodValidationPostProcessor才能工作。如果校验失败,会抛出ConstraintViolationException异常。如果需要将这些参数封装到一个类中,那么请求中的参数名必须和类中的字段一致,否则会匹配不上。当然,可以通过额外的配置满足这个需求,但是比较麻烦而且不支持继承的类。

推荐阅读更多精彩内容