接口参数校验
1. 需求
1.1. 什么是 JSR-303
在 JavaWeb 项目开发中,常常需要进行接口参数校验,这个需求在 JSR-303规范中被提到,JSR是Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation,官方参考实现是 Hibernate Validator。
1.2. hibernate-validator 简介
hibernate-validator
是 JSR-303 规范的一个实现,也是 Java 开发中使用最广泛的参数校验框架,而它也被集成到了 Spring 家族中 spring-boot-starter-validation
,你可以轻易的使用它来完成接口参数校验。使用文档参见 Hibernate Validator。
2. 使用前置
2.1. 引入依赖
Maven 项目中引入以下任一坐标即可:
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.2.0.Final</version>
</dependency>
或
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.5.5</version>
</dependency>
2.2. 常用注解
注解 | 可验证的数据类型 | 说明 |
---|---|---|
@AssertFalse | Boolean,boolean | 验证注解的元素值是false |
@AssertTrue | Boolean,boolean | 验证注解的元素值是true |
@NotNull | 任意类型 | 验证注解的元素值不是null |
@Null | 任意类型 | 验证注解的元素值是null |
@NotEmpty | CharSequence子类型、Collection、Map、数组 | 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0) |
@NotBlank | CharSequence子类型 | 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格 |
@Min(value=值) | BigDecimal,BigInteger, byte,short, int, long,等任何Number或CharSequence(存储的是数字)子类型 | 验证注解的元素值大于等于@Min指定的value值 |
@Max(value=值) | 和@Min要求一样 | 验证注解的元素值小于等于@Max指定的value值 |
@DecimalMin(value=值) | 和@Min要求一样 | 验证注解的元素值大于等于@ DecimalMin指定的value值 |
@DecimalMax(value=值) | 和@Min要求一样 | 验证注解的元素值小于等于@ DecimalMax指定的value值 |
@Digits(integer=整数位数, fraction=小数位数) | 和@Min要求一样 | 验证注解的元素值的整数位数和小数位数上限 |
@Size(min=下限, max=上限) | 字符串、Collection、Map、数组等 | 验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小 |
@Past | java.util.Date,java.util.Calendar;Joda Time类库的日期类型 | 验证注解的元素值(日期类型)比当前时间早 |
@Future | 与@Past要求一样 | 验证注解的元素值(日期类型)比当前时间晚 |
@Length(min=下限, max=上限) | CharSequence子类型 | 验证注解的元素值长度在min和max区间内 |
@Range(min=最小值, max=最大值) | BigDecimal,BigInteger,CharSequence, byte, short, int, long等原子类型和包装类型 | 验证注解的元素值在最小值和最大值之间 |
@Email(regexp=正则表达式,flag=标志的模式) | CharSequence子类型(如String) | 验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式 |
@Pattern(regexp=正则表达式,flag=标志的模式) | String,任何CharSequence的子类型 | 验证注解的元素值与指定的正则表达式匹配 |
@Valid | 任何非原子类型 | 指定递归验证关联的对象;如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可级联验证 |
@CreditCardNumber | 数字 | 对信用卡号进行一个大致的验证 |
@URL (protocol=,host,port) | 网址 | 检查是否是一个有效的URL,如果提供了protocol,host等,则该URL还需满足提供的条件 |
3. 开始使用
3.1. 快速开始
3.1.1. 创建模型 User
在需要校验的属性上添加合适的注解,方可校验。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@NotEmpty
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
}
3.1.2. 创建 Handler Method get
和 post
在请求参数前加 @Valid
或 @Validated
注解任一即可。 区别在于 @Valid
是 Java 的注解,@Validated
是 Spring 的注解,是对 @Valid
注解的增强,兼容 @Valid
注解。
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public String get(@Valid User user) {
return ObjUtils.toJsonStr(user);
}
@PostMapping
public String post(@Validated @RequestBody User user) {
return ObjUtils.toJsonStr(user);
}
}
3.1.3. 统一异常处理,统一响应
当实体类中的注解校验不通过时,会抛出 BindException
异常,这里捕获到异常,获取到属性名和错误提示,组装返回。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BindException.class)
public R bindExceptionHandler(BindException e) {
log.debug("bindExcept: {}", e.getMessage());
FieldError fieldError = e.getBindingResult().getFieldError();
assert fieldError != null;
return R.fail(String.format("%s %s", fieldError.getField(), fieldError.getDefaultMessage()));
}
}
GET 请求 http://127.0.0.1:8080 ,响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "name must not be empty"
}
3.2. 自定义校验
有时候内置注解不能满足需求,我们可以自定义校验方式,参照文档 Creating custom constraints 一节。
如,前端传来一个值,我们需要验证值是否被包含在我们指定的值中。
3.2.1. 创建自定义注解
strValues
指定字符串类型数组;intValues
指定数字类型数组;enumClass
指定一个枚举类。当指定的数组元素较多时,可以将其封装到枚举类中,简化编码。为了规范使用,这里定义了一个接口,枚举类必须实现这个接口才可以实现校验。
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {MatchAnyValidator.class})
@Repeatable(MatchAny.List.class)// 使注解支持重复定义
public @interface MatchAny {
String message() default "not match any one";
String[] strValues() default {};
int[] intValues() default {};
Class<? extends ValidateAble> enumClass() default EmptyValidateEnum.class;
//分组
Class<?>[] groups() default {};
//负载
Class<? extends Payload>[] payload() default {};
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@interface List {
MatchAny[] value();
}
}
public interface ValidateAble {
String getValidateValue();
}
3.2.2. 实现校验逻辑
这里需要实现 ConstraintValidator<MatchAny, Object>
接口,泛型的第一个参数是自定义注解类,第二个参数是可以校验的数据类型,因为我们自定义的注解可以校验 String 和 int 类型,所以这里使用了 Object。initialize
方法用来获取注解中的值,isValid
方法是校验逻辑,校验通过返回 true
,否则返回 false
。
// 该类在 Spring 容器启动时即加载,无需添加 @Component 注解
public class MatchAnyValidator implements ConstraintValidator<MatchAny, Object> {
private String[] strValues;
private int[] intValues;
private Class<? extends ValidateAble> clazz;
@Override
public void initialize(MatchAny constraintAnnotation) {
strValues = constraintAnnotation.strValues();
intValues = constraintAnnotation.intValues();
clazz = constraintAnnotation.enumClass();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (Objects.isNull(value)) {
return true;
}
boolean pass;
// 集合
if (value instanceof Collection) {
pass = ((Collection<?>) value).stream().allMatch(this::validValue);
// 数组
} else if (value.getClass().isArray()) {
pass = Arrays.stream(((Object[]) value)).allMatch(this::validValue);
// 单值
} else {
pass = validValue(value);
}
if (!pass) {
// 禁用默认的错误提示
context.disableDefaultConstraintViolation();
String valueStr = getValueStr();
context.buildConstraintViolationWithTemplate(String.format("must be one of [%s]", valueStr)).addConstraintViolation();
return false;
}
return true;
}
/**
* @param value
* @Description: 校验单个值,值可能是数字或字符串
* @return: boolean
* @Author: Bobby.Ma
* @Date: 2021/12/15 15:06
*/
private boolean validValue(Object value) {
if (clazz != EmptyValidateEnum.class) {
return Arrays.stream(clazz.getEnumConstants()).map(ValidateAble::getValidateValue).anyMatch(o -> Objects.equals(o, value));
} else if (value instanceof String) {
return Arrays.asList(strValues).contains(value);
} else if (value instanceof Integer) {
return IntStream.of(intValues).anyMatch(i -> Objects.equals(i, value));
} else {
// 只支持校验 数字或字符串
return false;
}
}
private String getValueStr() {
if (clazz.isEnum() && clazz != EmptyValidateEnum.class) {
ValidateAble[] validateAbles = clazz.getEnumConstants();
return Arrays.stream(validateAbles).map(ValidateAble::getValidateValue).map(Object::toString).collect(Collectors.joining(","));
} else {
if (ArrayUtils.isNotEmpty(strValues)) {
return String.join(",", strValues);
} else {
return Arrays.stream(intValues).mapToObj(Objects::toString).collect(Collectors.joining(","));
}
}
}
}
3.2.3. 定义一个枚举类
枚举类要实现 ValidateAble
接口。
@AllArgsConstructor
public enum PermissionEnum implements ValidateAble {
ADMIN("admin"),
STAFF("staff"),
BOSS("boss");
private final String value;
@Override
public String getValidateValue() {
return this.value;
}
}
3.2.4. 使用自定义注解
在模型 User 中新增 permission
属性,并添加 @MatchAny
注解。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@NotEmpty
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
@MatchAny(enumClass = PermissionEnum.class)
private String permission;
}
GET 请求 http://127.0.0.1:8080?name=bobby&permission=undefine ,响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "permission must be one of [admin,staff,boss]"
}
3.3. PathVariable 数据校验
如果参数是在 uri 中该如何校验呢?
3.3.1. 新增 Handler Method
在 DemoController
中新增一个 path
方法,其参数注解有 @PathVariable("age")
。注意,需要在当前类上注解 @Validated
才生效,@Valid
无效,因此尽量使用 @Validated
注解。@Validated
注解在类型和注解在方法参数上并不冲突,如 post
方法中的 @Validated
只对 User
实体中的校验注解生效,而 DemoController
类上的 @Validated
只对 path
方法中的参数生效。
@Validated
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public String get(@Valid User user) {
return ObjUtils.toJsonStr(user);
}
@PostMapping
public String post(@Validated @RequestBody User user) {
return ObjUtils.toJsonStr(user);
}
@GetMapping("/{age}")
public String path(@PathVariable("age") @Min(1) Integer age) {
return age.toString();
}
}
3.3.2. 增加异常捕获
方法中校验的参数若不是一个实体类,则用上述方式校验,此时抛出的异常为 ConstraintViolationException
,因此新增异常捕获。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BindException.class)
public R bindExceptionHandler(BindException e) {
log.debug("bindExcept: {}", e.getMessage());
FieldError fieldError = e.getBindingResult().getFieldError();
assert fieldError != null;
return R.fail(String.format("%s %s", fieldError.getField(), fieldError.getDefaultMessage()));
}
@ExceptionHandler(ConstraintViolationException.class)
public R validationExceptionHandler(ConstraintViolationException e) {
log.debug("validationException: {}", e.getMessage());
ConstraintViolation<?> constraintViolation = e.getConstraintViolations().stream().findFirst().orElse(null);
assert constraintViolation != null;
String[] path = constraintViolation.getPropertyPath().toString().split("\\.");
String field = path[path.length - 1];
String message = constraintViolation.getMessage();
return R.fail(String.format("%s %s", field, message));
}
@ExceptionHandler(Exception.class)
public R exceptionHandler(Exception e) {
log.debug("unknown error: {}", e.getMessage());
return R.fail();
}
}
GET 请求 http://127.0.0.1:8080/0 ,响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "age must be greater than or equal to 1"
}
3.4. 嵌套校验
当模型中的属性嵌套了另一个模型,需要对嵌套的模型进行校验呢?
3.4.1. 新增模型 Information
@Data
public class Information implements Serializable {
private static final long serialVersionUID = -3128793162502773246L;
@NotEmpty
private String address;
private String telPhone;
}
3.4.2. 在模型 User
中增加模型 Information
属性
注意,Information
属性上注解的是 @Valid
,@Validated
不支持注解在这里。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@Null(groups = {ValidAddGroup.class})
@NotNull(groups = {ValidUpdateGroup.class})
private Integer id;
@NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
@MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String permission;
@MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
private String learning;
@MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
private String working;
@Valid
private Information information;
}
POST 请求 http://127.0.0.1:8080/condition ,请求体:
{
"age":20,
"name":"xxx",
"information":{
}
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "information.address must not be empty"
}
3.5. 分组校验
有的时候同一个字段,在不同场合下的校验方式不同。例如:新增时我们需要校验 id 字段为空,但更新时需要校验 id 字段不为空,该如何做呢?
3.5.1. 定义两个空接口
接口只需要定义,作为标记,无需任何方法实现.
// 新增时的校验组
public interface ValidAddGroup {
}
// 更新时的校验组
public interface ValidUpdateGroup {
}
3.5.2. 在被校验字段的注解中加上分组
现在新增一个属性 id,并添加注解同时标记分组 @Null(groups = {ValidAddGroup.class})
和 @NotNull(groups = {ValidUpdateGroup.class})
。
表示,当指定了 ValidAddGroup
组时需要校验 @Null
;当指定了 ValidUpdateGroup
组时需要校验 @NotNull
。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@Null(groups = {ValidAddGroup.class})
@NotNull(groups = {ValidUpdateGroup.class})
private Integer id;
@NotEmpty
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
@MatchAny(enumClass = PermissionEnum.class)
private String permission;
}
3.5.3. 新增两个 Handler Method
在 DemoController
中新增两个方法:add()
和 update()
。在 add()
方法中我们指定了 ValidAddGroup
组,在 update()
中我们指定了 ValidUpdateGroup
。
注意这里要使用 @Validated
注解,而 @Valid
不支持分组校验。
@Validated
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public R get(@Valid User user) {
return R.data(user);
}
@PostMapping
public R post(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/{age}")
public R path(@PathVariable("age") @Min(1) Integer age) {
return R.data(age);
}
@PostMapping("/add")
public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
return R.data(user);
}
@PutMapping("/update")
public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
return R.data(user);
}
}
POST 请求 http://127.0.0.1:8080/add ,请求体:
{
"id":1
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "id must be null"
}
PUT 请求 http://127.0.0.1:8080/update ,请求体:
{
"permission": "sudo"
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "id must not be null"
}
这时候有些长得帅的朋友可能要问了,model 中那些没有标记分组的注解还生效吗?
我们来试一试,当给 update()
方法请求体中给出 id 的值:
PUT 请求 http://127.0.0.1:8080/update ,请求体:
{
"id":1,
"permission": "sudo"
}
响应体:
{
"code": 0,
"data": {
"id": 1,
"name": null,
"age": null,
"email": null,
"phone": null,
"permission": "sudo"
},
"success": true,
"message": "success"
}
我们可以看到成功响应了,但我们明明是有校验 name
属性 @NotEmpty
的,而且请求体中的 permission
属性值也不对,却还是通过了校验。这就说明,当我们在方法参数中指定了校验分组时,只有符合分组标记的校验才会生效,不符合或者没标记的一律不生效,因此就需要给 name
和 permission
注解上指定校验分组。但 name
和 permission
在新增和更新时都需要校验,此时可以同时添加 ValidAddGroup
和 ValidUpdateGroup
两种标记,因为校验注解中的 groups()
属性值是 一个数组 Class<?>[]
,@Validated
注解中的 value()
属性值同样也是一个数组 Class<?>[]
,因此也可以指定多个分组。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@Null(groups = {ValidAddGroup.class})
@NotNull(groups = {ValidUpdateGroup.class})
private Integer id;
@NotEmpty(groups = {ValidAddGroup.class,ValidUpdateGroup.class})
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
@MatchAny(enumClass = PermissionEnum.class,groups = {ValidAddGroup.class,ValidUpdateGroup.class})
private String permission;
}
PUT 请求 http://127.0.0.1:8080/update ,请求体:
{
"id":1,
"permission": "sudo"
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "permission must be one of [admin,staff,boss]"
}
3.6. 逻辑校验
上面的校验方式仍然比较简单,当我们的需要自定义校验逻辑又需要怎么做呢?hibernate-validator
为我们提供了一个接口 DefaultGroupSequenceProvider
,这个接口不是 JSR-303标准的,没有默认实现类。 例如,我们需要判断模型 User
中的 age
属性值,当 age <= 22
时,校验 learning
属性值,否则校验 working
属性值。其实,核心思想就是动态指定校验分组。
3.6.1. User
模型中新增属性 learning
和 working
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@Null(groups = {ValidAddGroup.class})
@NotNull(groups = {ValidUpdateGroup.class})
private Integer id;
@NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String name;
@Min(1)
private Integer age;
@Email
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}")
private String phone;
@MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String permission;
@MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
private String learning;
@MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
private String working;
}
3.6.2. 定义两个校验组 ValidLearningGroup
和 ValidWorkingGroup
public interface ValidLearningGroup {
}
public interface ValidWorkingGroup {
}
3.6.3. 定义一个 DefaultGroupSequenceProvider
的实现
在模型 User
中加上 @GroupSequenceProvider(UserGroupSequenceProvider.class)
。
public class UserGroupSequenceProvider implements DefaultGroupSequenceProvider<User> {
@Override
public List<Class<?>> getValidationGroups(User user) {
List<Class<?>> defaultGroupSequence = new ArrayList<>();
defaultGroupSequence.add(User.class); // 这一步不能省,否则Default分组都不会执行了,会抛错的
if (Objects.nonNull(user)) { // 这块判空请务必要做
Integer age = user.getAge();
if (age <= 22) {
defaultGroupSequence.add(ValidLearningGroup.class);
} else {
defaultGroupSequence.add(ValidWorkingGroup.class);
}
}
return defaultGroupSequence;
}
}
3.6.4. 新增一个 Handler Method condition
@Validated
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public R get(@Valid User user) {
return R.data(user);
}
@PostMapping
public R post(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/{age}")
public R path(@PathVariable("age") @Min(1) Integer age) {
return R.data(age);
}
@PostMapping("/add")
public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
return R.data(user);
}
@PutMapping("/update")
public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
return R.data(user);
}
@PostMapping("/condition")
public R condition(@Validated @RequestBody User user) {
return R.data(user);
}
}
POST 请求 http://127.0.0.1:8080/condition ,请求体:
{
"age":50,
"learning":"xxx",
"working":"xxx"
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "working must be one of [screw,brick,coding]"
}
这个时候那些长得帅的朋友可能又会问了,其他属性值还会校验吗?
我们来实测一下:
POST 请求 http://127.0.0.1:8080/condition ,请求体:
{
"age":20,
"name":"",
"email":"xxx"
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "email must be a well-formed email address"
}
我们可以看到,虽然 age
有值,但没有给 learning
属性,因此不会去校验。而 name
属性值虽然不符合,但由于其注解上有标记分组,而我们 handler 方法中没有指定分组(其实是默认分组),因此也不会校验。剩下的 email
属性,由于其注解上没有标记分组,即为默认分组,因此执行校验。
综上,对于分组校验,我们可以总结以下2点:
- 当 handler 中没有指定校验分组(即默认分组)时,模型中的没有指定分组(即默认分组)的属性,以及
DefaultGroupSequenceProvider
指定的分组(包含了默认分组)才会被校验; - 当 handler 中指定了校验分组时,此时仅指定的分组才会被校验。
因此,当需要分组校验时,需要格外注意。
3.7. 校验排序
若有多个校验分组时,默认校验是无序的。
3.7.1. 新增一个 Handler Method sequence
,此时并未指定任何分组
@Validated
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public R get(@Valid User user) {
return R.data(user);
}
@PostMapping
public R post(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/{age}")
public R path(@PathVariable("age") @Min(1) Integer age) {
return R.data(age);
}
@PostMapping("/add")
public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
return R.data(user);
}
@PutMapping("/update")
public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
return R.data(user);
}
@PostMapping("/condition")
public R condition(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/sequence")
public R sequence(@Validated User user) {
return R.data(user);
}
}
GET 请求 http://127.0.0.1:8080/sequence?email=xxx&phone=123
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "phone must match \"[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}\""
}
可以看到,虽然在模型 User
中 email
定义在前,请求参数也是 email
在前,但还是先校验的 phone
。
如果我们需要指定分组的校验顺序,先校验 email
呢,可以使用 @GroupSequence
注解,这是 JSR-303 提供的。
3.7.2. 新增两个分组 EmailSeq
和 PhoneSeq
public interface EmailSeq {
}
public interface PhoneSeq {
}
3.7.3. 定义一个分组序列
当指定校验分组为 UserSequence
时,会依次按照 @GroupSequence
指定的顺序执行校验。
@GroupSequence({EmailSeq.class,PhoneSeq.class})
public interface UserSequence {
}
3.7.4. 指定模型 User
中的 email
和 phone
属性的校验分组,并给 Handler Method sequence
增加 UserSequence
校验组
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@GroupSequenceProvider(UserGroupSequenceProvider.class)
public class User implements Serializable {
private static final long serialVersionUID = -7242397021777229674L;
@Null(groups = {ValidAddGroup.class})
@NotNull(groups = {ValidUpdateGroup.class})
private Integer id;
@NotEmpty(groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String name;
@Min(1)
private Integer age;
@Email(groups = {EmailSeq.class})
private String email;
@Pattern(regexp = "[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}", groups = {PhoneSeq.class})
private String phone;
@MatchAny(enumClass = PermissionEnum.class, groups = {ValidAddGroup.class, ValidUpdateGroup.class})
private String permission;
@MatchAny(strValues = {"language", "math", "english"}, groups = {ValidLearningGroup.class})
private String learning;
@MatchAny(strValues = {"screw", "brick", "coding"}, groups = {ValidWorkingGroup.class})
private String working;
@Valid
private Information information;
}
@Validated
@RestController
@RequestMapping
public class DemoController {
@GetMapping
public R get(@Valid User user) {
return R.data(user);
}
@PostMapping
public R post(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/{age}")
public R path(@PathVariable("age") @Min(1) Integer age) {
return R.data(age);
}
@PostMapping("/add")
public R add(@RequestBody @Validated({ValidAddGroup.class}) User user) {
return R.data(user);
}
@PutMapping("/update")
public R update(@RequestBody @Validated({ValidUpdateGroup.class}) User user) {
return R.data(user);
}
@PostMapping("/condition")
public R condition(@Validated @RequestBody User user) {
return R.data(user);
}
@GetMapping("/sequence")
public R sequence(@Validated(UserSequence.class) User user) {
return R.data(user);
}
}
GET 请求 http://127.0.0.1:8080/sequence?phone=123&email=xxx
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "email must be a well-formed email address"
}
可以看到,email
先被校验。
4. 扩展
4.1. 接口参数多态+参数校验
若有一个 Handler Method ,它的参数可能有多种对象,每种对象的校验方式又不相同,但是参数只定义一个对象接收,该如何做呢?
有两种方式实现:
- 方法一:将所有的子类属性定义在一个对象中,给属性校验添加分组,然后结合逻辑校验来动态指定不同的校验分组。这种方式的缺点在于,将所有子类对象属性揉在一起可能会很多,再添加上一堆校验分组,校验逻辑就很不直观,不便于维护。
- 方法二:实现方法请求参数的多态。定义一个父类,让所有子类都继承它,然后根据一个属性值来判断反序列化为哪种子类,然后在子类中完成各自的属性校验。这种方式就非常直观。
这里我将介绍方法二的实现步骤。
4.1.1. 定义一个父类 Person
,两个子类 Teacher
和 Student
父类中重写了子类中的所有方法,主要是为了能够不用强转为子类也可以获取子类的值,当然你可以不实现这些方法,使用的时候强转为目标类型即可。
说明:注解 @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true, defaultImpl = Person.class)
其中 property = "type"
表示判断的属性为 type
,注意 defaultImpl = Person.class
,这个如果不指定会反序列化失败,直接抛异常。而指定后表示当 type
不存在或者不是给定的值时,默认转为 Person
类型,然后就可以校验对象中的 type
属性值,@NotEmpty
和 @MatchAny
会生效。
注解 @JsonSubTypes
中的属性表示当 type
值为 teacher 时实例化为 Teacher
类型,当type
值为 student 时实例化为 Student
类型。
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true, defaultImpl = Person.class)
@JsonSubTypes({
@JsonSubTypes.Type(value = Teacher.class, name = "teacher"),
@JsonSubTypes.Type(value = Student.class, name = "student")
})
public class Person {
@NotEmpty
@MatchAny(strValues = {"teacher", "student"})
private String type;
public Integer getStuId() {
return null;
}
public Integer getTeaId() {
return null;
}
public String getName() {
return null;
}
public Integer getAge() {
return null;
}
public String getClassNo() {
return null;
}
}
@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Teacher extends Person {
/**
* 工号
*/
private Integer teaId;
private String name;
@Min(28)
private Integer age;
}
@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Student extends Person {
/**
* 学号
*/
private Integer stuId;
private String name;
@Min(7)
private Integer age;
/**
* 班号
*/
private String classNo;
}
4.1.2. 新增一个 Handler Method extend
实测,GET
和 POST
请求都可以完成类型映射,但只有为 POST
请求时才会校验子类中的属性。
@RestController
@RequestMapping("/extend")
public class ExtendController {
@PostMapping
public R extend(@RequestBody @Validated Person person) {
return R.data(person);
}
}
POST 请求 http://127.0.0.1:8080/extend ,请求体:
{
"type":"teacher",
"age":15
}
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "age must be greater than or equal to 28"
}
4.2. 请求参数解析+参数校验
最近在把 Node.js 项目重构为 Java 项目,其中一个接口为 GET https://host:port/uri?list=["aa","bb"]
,Node.js 的框架可以将 list 参数解析为数组类型;而在 Java 中,即使将 list 参数定义为集合或数组类型,Srpingmvc 框架默认还是会把 ["aa","bb"]
整个解析为集合中的一个元素。参数未能以预期的方式接收,亦无法通过参数校验。
我们知道 Springmvc 可以将 GET https://host:port/uri?list=aa,bb
形式的参数解析为集合或数组,但 Node.js 的框架无法解析这种传参方式。因为是重构项目,需要兼容 Node.js ,故不能修改传参方式。而一时间没有找到可以让 Springmvc 正确解析参数的方法,因此决定实现自定义参数解析器。
4.2.1. 自定义参数解析注解
用来标记需要解析的参数。
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ArrayResolver {
}
4.2.2. 自定义参数解析器
实现 HandlerMethodArgumentResolver
接口,自定义参数解析器
public class ArrayHandlerMethodArgumentResolver extends AbstractCustomizeResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(ArrayResolver.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Object obj = BeanUtils.instantiateClass(parameter.getParameterType());
BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(obj);
Iterator<String> parameterNames = webRequest.getParameterNames();
while (parameterNames.hasNext()) {
// name 从 request 中获取,可能是下划线格式
String name = parameterNames.next();
// 下划线转驼峰
String camelName = underLineToCamel(name);
Class<?> propertyType = wrapper.getPropertyType(camelName);
// 传参在对象中不存在时
if (Objects.isNull(propertyType)) {
continue;
}
// 属性值(从请求中获取)
Object o = webRequest.getParameter(name);
if (Objects.nonNull(o)) {
// 数组
if (propertyType.isArray()) {
wrapper.setPropertyValue(camelName, value2Array(o));
} else if (Collection.class.isAssignableFrom(propertyType)) {// 集合
wrapper.setPropertyValue(camelName, array2Collection(propertyType, value2Array(o)));
} else {//其他类型
wrapper.setPropertyValue(camelName, o);
}
}
}
// 参数校验
valid(parameter, mavContainer, webRequest, binderFactory, obj);
return obj;
}
private Object[] value2Array(Object o) {
assert o != null;
// ["a","b","c"] 格式
if (StringUtils.containsAny(o.toString(), "[", "]")) {
return JSON.parseArray(o.toString()).toArray();
} else {
// a,b,c 格式
return JSON.parseArray(Arrays.toString(o.toString().split(","))).toArray();
}
}
private Collection<Object> array2Collection(Class<?> propertyType, Object[] array) {
Collection<Object> collection = CollectionFactory.createCollection(propertyType, array.length);
Collections.addAll(collection, array);
return collection;
}
}
4.2.3. 将参数解析器添加到 Spring 容器中
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new ArrayHandlerMethodArgumentResolver());
}
}
4.2.4. 在模型 Teacher
中新增 lessons
属性
@EqualsAndHashCode(callSuper = true)
@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Teacher extends Person {
/**
* 工号
*/
private Integer teaId;
private String name;
@Min(28)
private Integer age;
/**
* 教授课程
*/
@MatchAny(enumClass = LessonEnum.class)
private List<String> lessons;
}
4.2.5. 新增 Handler Method arrayResolve
参数前标记 @ArrayResolver
注解。
@RestController
@RequestMapping("/extend")
public class ExtendController {
@PostMapping
public R extend(@RequestBody @Validated Person person) {
return R.data(person);
}
@GetMapping("/arrayResolve")
public R arrayResolve(@ArrayResolver @Validated Teacher teacher) {
return R.data(teacher);
}
}
GET 请求 http://127.0.0.1:8080/extend/arrayResolve?lessons=["math","xxx"]
响应体:
{
"code": 500,
"data": null,
"success": false,
"message": "lessons must be one of [language,math,english]"
}
可以看到,参数被正确解析且被校验。
Tip:本文完整示例代码已上传至 Gitee