[TOC]
简介
后端编程中,通常对于前端传递过来的数据,我们都需要进行校验,确保数据正确且安全。
最直接的方法当然是在 Controller 相应方法内对数据进行手动校验,但是,由于很多校验都具备相似性,因此这种做法稍显冗余。
因此,相关的校验规范就应运而生。比如:
-
JSR-303:它是一项 Bean Validation 校验标准,规定了一些校验规范,比如
@Null
,@NotNull
,@Pattern
,相关注解都位于javax.validation.constraints
包下。需要注意的是,JSR-303 只提供校验规范,不提供实现。
JSR-303 是 Bean Validation 1.0 版本,随着越来越多的新规范并入,它的版本也一直在更新,比如,JSR-349 就是 Bean Validation 1.1 版本,而当前最新的版本为 JSR-380,也即 Bean Validation 2.0 版本...
由于 JSR-303 只提供规范,因此其实现需要其他库进行提供。当前使用最广泛的 Bean Validation 实现库为:hibernate-validator。
hibernate-validator 是对 JSR-303 的实现,同时它也增添了其他一些校验注解,比如,@URL
,@Length
,@Ranger
等。
而在 Spring 中,其也提供了相应的 Bean Validation 实现:Java Bean Validation。
Spring Validation 主要是对 hibernate-validator 进行了二次封装,并在 SpringMVC 中添加了自动校验,以及将校验信息封装进特定类中等功能。
本文主要介绍下在 Spring Boot 中进行数据校验(Bean Validation)。
依赖添加
Spring Boot 中进行数据校验需要添加起步依赖:spring-boot-starter-validation
,如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
注:在spring-boot-starter-web
旧版本中,其内置了spring-boot-starter-validation
,但是 Spring Boot 官方似乎认为并不是很多应用会使用数据校验功能,因此对其进行了移除。具体请参考:issue#19550。
基本使用
数据校验最基本的操作就是使用相关注解对一个 Java Bean 内的相关字段进行约束,然后前端传递上来的数据会首先组装为相应的 Java Bean 对象,该对象会被移交到一个Validator
,让其检查对象字段(即数据)是否满足约束,如果不满足的话,则会通过如抛出异常等方式通知系统。
具体的使用步骤如下所示:
-
首先定义一个需要校验的 Java Bean 类:
@Data public class User { private int id; @NotBlank(message = "用户名不能为空") private String name; @NotNull(message = "请输入密码") @Length(min = 6, max = 10, message = "密码为 6 到 10 位") private String password; @Email private String email; }
上述代码中,我们使用
@NotBlank
、@NotNull
、@Length
和@Email
等注解对User
类中的相应字段进行了约束。
各注解对应的约束内容请参考后文。 -
在 Controller 相应接口方法中,使用
@Valid
/@Validated
等注解开启数据校验功能:@RestController @RequestMapping("validate") public class ValidationController { @PostMapping("/user") public String addUser(@Validated @RequestBody User user){ return "add user successfully! " + user; } }
-
如果数据校验不通过,就会抛出一个
MethodArgumentNotValidException
异常。默认情况下,Spring 会将该异常及其信息以错误码 400 进行下发。我们可以通过自定义一个全局异常捕获器拦截该异常,提取出数据校验出错信息,进行展示:@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public String handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) { return e.getBindingResult().getFieldErrors() .stream() .map(fieldError -> { return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage()); }).collect(Collectors.joining()); } }
以上,就完成了一个基础的数据校验功能。
此时我们进行如下访问:
$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"password\": \"123456\"}"
[name: 用户名不能为空]
$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"12345\"}"
[password: 密码为 6 到 10 位]
$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\"}"
add user successfully! User(id=0, name=Whyn, password=123456, email=null)
可以看到,结果符合预期。
注:上述代码如果数据校验不通过,就会抛出MethodArgumentNotValidException
,其实是因为我们在为参数注解了@RequestBody
,此时HttpMessageConverter
会负责转换过程,当遇到数据校验失败时,就会抛出MethodArgumentNotValidException
。
而如果去除@RequestBody
注解,默认就会由@ModelAttribute
负责数据绑定和校验,如果此时校验失败,则会抛出BindException
(更多详情,可参考:issue#14790),因此,为了程序更加健壮,最好为我们的全局异常处理器增加BindException
异常捕获。如下所示:
@RestControllerAdvice
public class GlobalExceptionHandler {
...
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleBindException(BindException e){
return e.getBindingResult().getFieldErrors()
.stream()
.map(fieldError -> {
return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage());
}).collect(Collectors.joining());
}
}
此时,请求上述代码,结果如下:
$ curl http://localhost:8080/validate/user -X POST
[name: 用户名不能为空]
[password: 请输入密码]
$ curl http://localhost:8080/validate/user -X POST --data "name=Whyn"
[password: 请输入密码]
$ curl http://localhost:8080/validate/user -X POST --data "name=Whyn" --data "password=123456"
add user successfully! User(id=0, name=Whyn, password=123456, email=null, phoneNo=null)
上面是对复杂数据(Java Bean)的校验使用方式,而如果前端传递的是简单基本类型(比如String
)或者是对路径变量(Path Variable)进行校验,可使用如下方式:
@RestController
@RequestMapping("validate")
@Validated
public class ValidationController {
@GetMapping("/user/{id}")
public String getUser(@PathVariable("id") @Min(10) int id) {
return "User id is " + id;
}
@PutMapping("/user")
public String updateUser(@RequestParam("name") @NotBlank String name,
@RequestParam("email") @Email String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
return "update user done: " + user;
}
}
可以看到,对于简单数据类型,我们将约束注解直接注解到相应参数上,然后在Controller
类上使用@Validated
注解,启动数据校验。
对于这种数据校验方式,当校验失败时,会抛出ConstraintViolationException
,而不是我们上面对 Java Bean 校验失败抛出的MethodArgumentNotValidException
异常,因此,可以为我们的全局异常处理器捕获该异常,进行处理。如下所示:
@RestControllerAdvice
public class GlobalExceptionHandler {
...
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handleConstraintViolationException(ConstraintViolationException e) {
return e.getConstraintViolations()
.stream()
.map(constraintViolation -> {
return String.format("[%s: %s]\n",
constraintViolation.getPropertyPath().toString(),
constraintViolation.getMessage());
}).collect(Collectors.joining());
}
}
请求上述代码,如下所示:
$ curl -X GET http://localhost:8080/validate/user/1
[getUser.id: must be greater than or equal to 10]
$ curl -X GET http://localhost:8080/validate/user/10
User id is 10
$ curl http://localhost:8080/validate/user -X PUT --data "name=" --data "email=10"
[updateUser.name: must not be blank]
[updateUser.email: must be a well-formed email address]
$ curl http://localhost:8080/validate/user -X PUT --data "name=Whyn" --data "email=10@qq.com"
update user done: User(id=0, name=Whyn, password=null, email=10@qq.com, extraInfo=null)
Bean Validation 相关注解
-
下面主要介绍下 JSR 中一些常用的相关约束注解,如下所示:
注解 释义 可被注解元素类型 @NotNull
被注解的元素不能为 null
所有类型 @NotBlank
被注解的元素不能为 null
,且至少包含一个非空白字符支持 CharSequence
@NotEmpty
被注解的元素不能为 null
,且不能为空(即不能为空集合)支持 CharSequence
、Collection
、Map
、Array
@Min(value)
被注解的元素值必须大于或等于 @Min
指定的值支持 BigDecimal
、BigInteger
,以及byte
、short
等基本数值类型及其他们相应的包装类型@Max(value)
被注解的元素值必须小于或等于 @Max
指定的值支持 BigDecimal
、BigInteger
,以及byte
、short
等基本数值类型及其他们相应的包装类型@Size(max=, min=)
被注解的元素大小必须在指定的范围内 CharSequence
、Collection
、Map
、Array
以及null
。
注:null
元素会被认为是有效值@Pattern
被注解的元素必须符合指定的正则匹配 CharSequence
注:null
类型元素会被认为是有效值@AssertTrue
被注解的元素值必须为 true
支持 boolean
、Boolean
类型@AssertFalse
被注解的元素值必须为 false
支持 boolean
、Boolean
类型更多 JSR 相关注解内容,请参考:javax.validation.constraints
-
下面介绍下 hibernate-validator 的一些常用特有注解:
注解 释义 可被注解元素类型 @Length(min=,max=)
被注解的字符串长度必须在指定范围内 字符串 @Range(min=,max=)
被注解的元素必须在指定范围内 数值类型或者数值字符串类型 @URL
被注解的字符串匹配 URL 字符串 更多 hibernate-validator 相关注解内容,请参考:org.hibernate.validator.constraints
-
下面介绍下 Spring Bean Validation 的一些常用特有注解:
注解 释义 可被注解元素类型 @Validated
开启数据校验功能,支持分组校验 任何非原子类型 更多 Spring Bean Validation 相关注解内容,请参考:org.springframework.validation.annotation
注:
@Validated
注解是@Valid
注解的一个变种实现,它们都主要用于启动数据校验功能,而不同之处大致有以下几方面:@Valid
是属于 JSR 规范,其位于包javax
内;而@Validated
是属于 Spring Bean Validation,其位于包org.springframework.validation
内。-
@Valid
支持嵌套校验(就是一个 Bean 内嵌套另一个 Bean),而@Validated
不支持。如下所示:@Data public class User { ... @Valid // 嵌套校验 private ExtraInfo extraInfo; @Data public static class ExtraInfo { @Pattern(regexp = "\\b(male|female)\\b", message = "male or female") @NotBlank(message = "性别不能为空") private String sex; @Min(0) @Max(130) private int age; } }
注:嵌套校验只需要求嵌套 Bean 内使用
@Valid
注解,而启动数据校验(即 Controller 层)使用@Valid
或者@Validated
都可以。请求上述代码,如下所示:
$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\",\"extraInfo\": {\"sex\": \"男\"}}" [extraInfo.sex: male or female] $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\",\"extraInfo\": {\"sex\": \"male\"}}" add user successfully! User(id=0, name=Whyn, password=123456, email=null, extraInfo=User.ExtraInfo(sex=male, age=0))
-
@Validated
支持分组校验功能,而@Valid
不支持。启动分组校验步骤如下所示:- 首先创建两个分组接口:
public interface ValidationGroup1 {} public interface ValidationGroup2 {}
- 在实体类中添加分组信息:
@Data public class User { private int id; // 隶属分组 1 @NotBlank(message = "用户名不能为空", groups = ValidationGroup1.class) private String name; // 隶属分组 1 和 2 @NotNull(message = "请输入密码", groups = {ValidationGroup1.class, ValidationGroup2.class}) // 不进行分组 @Length(min = 6, max = 10, message = "密码为 6 到 10 位") private String password; // 不进行分组 @Email private String email; }
- 使用
@Validated
指定分组:
@RestController @RequestMapping("validate") public class ValidationController { @PostMapping("/user") public String addUser(@Validated(ValidationGroup2.class) @RequestBody User user){ return "add user successfully! " + user; } }
上述代码我们指定使用分组
ValidationGroup2
进行数据校验,ValidationGroup2
只对password
进行NotNull
约束,因此,只要我们发送的数据满足password
不为null
,就可以通过校验,如下所示:$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\"}" [password: 请输入密码] $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"\"}" add user successfully! User(id=0, name=Whyn, password=, email=null)
注:分组校验的一个问题就是,对于未指定分组的其他校验,直接忽略,通常这并不是我们想要的结果。对于未指定分组的校验,我们通常期望的是,无论使用哪种分组校验,这些未指定的分组校验均生效。
实际上,未指定分组的校验都归类为 默认分组(Default
),且分组支持继承,子类分组可完全继承父类分组的约束校验,因此,只需让我们的自定义分组继承默认分组,即可完成分组校验以及默认分组生效,代码如下:public interface ValidationGroup1 extends Default {} public interface ValidationGroup2 extends Default {}
综上,一个比较推荐的使用方式就是:启动校验(即 Controller 层)时使用
@Validated
注解,嵌套校验时使用@Valid
注解,这样,就能同时使用分组校验和嵌套校验功能。
自定义Validator
前文讲过,数据校验功能是由Validator
负责开启并校验的,在 SpringMVC 中,如果检测到 Bean Validation(比如,Hibernate Validator)存在于classpath
路径上时,就会默认全局注册了一个Validator
:LocalValidatorFactoryBean
,它会驱动@Valid
和@Validated
开启数据校验。
LocalValidatorFactoryBean
同时实现了javax.validation.ValidatorFactory
、javax.validation.Validator
和org.springframework.validation.Validator
三个接口,所以如果需要手动调用数据校验逻辑,可以通过 IOC 容器获取到这些接口的实例。如下所示:
- 获取
javax.validation.Validator
接口实例:import javax.validation.Validator; @Service public class MyService { @Autowired private Validator validator; }
- 获取
org.springframework.validation.Validator
接口实例:import org.springframework.validation.Validator; @Service public class MyService { @Autowired private Validator validator; }
上述获取的是系统默认的Validator
,而如果我们想注入一个自定义Validator
,有如下几种方法:
-
注入自定义
Validator
到 Spring IOC 容器:import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @Configuration public class AppConfig { @Bean public LocalValidatorFactoryBean validator() { return new LocalValidatorFactoryBean(); } }
-
为 SpringMVC 配置一个全局
Validator
:@Configuration @EnableWebMvc public class WebConfig implements WebMvcConfigurer { @Override public Validator getValidator() { // ... } }
也可以为单独一个 Controller 设置一个局部
Validator
,如下所示:@Controller public class MyController { @InitBinder protected void initBinder(WebDataBinder binder) { binder.addValidators(new FooValidator()); } }
自定义约束注解
如果现存的约束注解无法满足我们的需求,那么我们可以通过自定义约束注解,来定制我们的数据校验逻辑。
在 Spring 中,自定义约束注解主要就是定义一个约束注解及其对应的Validator
,两者通过@Constraint
关联到一起。
默认情况下,全局校验器LocalValidatorFactoryBean
会配置一个SpringConstraintValidatorFactory
实例,SpringConstraintValidatorFactory
实现了接口ConstraintValidatorFactory
,因此它会在遇到自定义约束注解的时候,就会自动实例化@Constraint
指定的关联Validator
,从而完成数据校验过程。
详细过程可参考如下示例:
例子:假设我们想自定义一个约束注解,用于对手机号进行校验,要求满足手机号码的格式为:+86 13699328716
,即以+86
开头,然后中间一个或多个空格,后面是有效的手机号码。
自定义约束注解的步骤如下所示:
-
自定义一个约束注解:
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PhoneNoConstraintValidator.class) public @interface PhoneNoConstraint { String message() default "手机号码格式错误"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
这里通过注解
@Constraint
将自定义注解PhoneNoConstraint
与PhoneNoConstraintValidator
(即一个自定义Validator
)关联到一起。 -
自定义一个
Validator
:public class PhoneNoConstraintValidator implements ConstraintValidator<PhoneNoConstraint, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { String regex = "\\+86\\s+\\d{11}"; Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(value); return matcher.matches(); } }
-
使用自定义约束注解:
@RestController @RequestMapping("validate") @Validated public class ValidationController { @PostMapping("/user/{id}") public String addPhoneNo(@PathVariable("id") int id, @RequestParam("phoneNo") @NotBlank(message = "手机号不能为空") @PhoneNoConstraint(message = "手机号必须以 +86 开头") String phoneNo) { return id + " => add phoneNo done: " + phoneNo; } }
当程序运行时,遇到自定义约束注解
@PhoneNoConstraint
时,SpringConstraintValidatorFactory
就会通过@PhoneNoConstraint
上的@Constraint
注解,获取得到其对应的Valiator
,然后通过 Spring 创建该Validator
实例,进行数据校验。利用这种机制,可以使得我们的自定义Validator
享受到其他 Java Bean 一样的依赖注入功能。请求上述代码,结果如下:
$ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=13699328716" [addPhoneNo.phoneNo: 手机号必须以 +86 开头] $ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=+86 13699328716" 1 => add phoneNo done: +86 13699328716
注:如果 URL 包含
+
、=
、&
等特殊符号时,会被进行转义,比如,+
会被转义为空格,这样后端接收的数据格式就永远是错误的,因此,发送数据前,应先对数据进行编码,所以上述curl
命令使用--data-urlencode
对数据进行编码,以确保特殊字符能成功发送。
其他
- 除了对 Controller 层添加数据校验外,还可以为 Spring 其他组件添加数据校验功能,只需结合
@Validated
和@Valid
这两个注解。
比如,对 Serivce 层添加数据校验功能,如下所示:@Service @Validated class ValidatingService{ void validateInput(@Valid Input input){ // do something } }