是服务就需要对外提供接口,否则该服务就没有任何意义。接口需要指定具体的入参情况,以保证服务能够正常地运行。spring mvc通过controller中的method对外提供接口服务,本文就如何在spring mvc中对RequestParam
、PathVariable
和RequestBody
三种类型的参数做参数校验做简单介绍。
为了更好地介绍上诉三种类型参数校验的方式,本文将通过一个简单的接口需求来完成相应的接口校验,相关的代码在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
四个接口,接口的具体要求如下:
- 添加:采用
POST
的方式请求,姓名不能为空,年龄在1~200之间,性别用0和1表示,出生日期不能为空且只能是过去的时间。 - 删除:根据姓名删除学生,姓名不能为空
- 修改:修改指定学生的出生日期
- 查询:查询所有出生日期在指定范围内的学生
默认情况下请求参数无法直接映射成
LocalDate
类型的,需要在spring中配置jackson
的objectmapper
添加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 Validator是JSR 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
中所述的需求,现在对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; }
-
在方法的参数中对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); }
@Valid
是Hibernate 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 Validator给rejected
了。从中我们可以发现,校验不通过的时候,会抛出org.springframework.web.bind.MethodArgumentNotValidException
异常。因此我们可以通过统一异常捕获的方式处理校验不通过的情况,给出友好的接口返回。 -
捕获
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}ß
-
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
参数。
-
直接通过
@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
参数上生效。 -
Controller
上添加@Validated
配合@NotBlank
校验参考Validating RequestParams and PathVariables in Spring MVC这篇文章,了解到
@RequestParam
上的validation需要在类上标注@Validated
注解(即在StudentController
上注解)然而添加改注解运行后,
@NotBlank
仍然没有生效。原因是没有为@RequestParam
配置注解器。 -
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
异常。 -
捕获
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
参数,又该如何呢?
-
请求中直接通过
@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
上,导致studentName
为null
。这也是必然的,因为@JsonProperty
是用来处理json字符串转对象的,而请求中并没有json格式的学生信息。当把请求中的student_name
改回studentName
后,studentName
就会被正确赋值。
那么,如何才能解决非POST json
形式下,请求参数和对象的属性名不一致的情况呢?- 请求及接口修改成
PSOT json
的形式 - 参考 How to customize parameter names when binding spring mvc command objects中的讨论或者绑定SpringMvc GET请求对象时自定义参数名总结的方法。
- 拆分对象字段到接口参数中,通过
@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);
}
-
正常请求
localhost:8080/student/get/wangwu
返回
{ "status": "OK", "code": 200, "msg": "成功", "data": "wangwu" }
-
异常请求
localhost:8080/student/get/yy
返回
{ "status": "Illegal request parameters", "code": 460, "msg": "student_name长度不能小于3" }
抛出的与
@RequestParam
方式一样的ConstraintViolationException
异常。
4 总结
通过上面的示例演示,对于spring mvc中的参数校验,可以得出如下结论:
- 如果接口参数对应的是请求中请求体部分(
@RequestBody
),且请求体的格式为json
,可以将请求参数封装到一个类中,在类中通过@NotNull
等标注设置校验规则,在接口中通过@Valid
表明需要进行校验,校验失败后会抛出MethodArgumentNotValidException
异常。如果请求中参数的名称和接口参数中字段的名称不一致,可以通过@JsonProperty
标注进行重命名。 - 如果接口参数中对应的是请求参数(
@RequestParam
)或者请求路径中的变量(@PathVariable
),则可以通过对应的@RequestParam
或者@PathVariable
结合@NotNull
进行参数检验,注意这里需要在Controller
上添加@Validated
注解,并且需要给spring配置MethodValidationPostProcessor
才能工作。如果校验失败,会抛出ConstraintViolationException
异常。如果需要将这些参数封装到一个类中,那么请求中的参数名必须和类中的字段一致,否则会匹配不上。当然,可以通过额外的配置满足这个需求,但是比较麻烦而且不支持继承的类。