10、SpringBoot参数校验
1、概述
JSR相关的概念就不赘述了,网上一搜一大把。只要知道以下内容的区别即可:
- Bean Validation,(javax.validation)包下的接口规范。
- Hibernate Validation,Hibernate对于上述规范的具体实现。
- Spring Validation,是对Hibernate的二次封装,在Spring环境中使用起来更为方便。
今天主要总结SpringBoot中进行参数校验的几种方案。
2、注解说明
常用的注解有:@NotNull、@NotEmpty、@NotBlank、@Email、@Pattern、@Past、@Size、@Min、@Max
- @NotNull
可以注释在任何类型的变量上,而且不能是基本类型,必须是基本类型对应的包装类,否则不起作用
例如:
@NotNull(message = "不能为空")
private int times;
虽然程序可以正常运行,不报错,但是@NotNull注解并不起作用,因为times是基本类型,且是成员变量,默认值为0。
修改成:
@NotNull(message = "不能为空")
private Integer times;
即可生效。
- @NotEmpty和@NotBlank
这两个注解需要放在一起理解,都是表示变量不为空
这两个注解都是架在字符串类型的变量上,且不能用于其他类型,否则运行时会报错。
区别在于:
@NotEmpty不会去掉空白字符, 例如变量为 “”会报错,而 “ ”就不会报错
@NotBlank会自动去掉空白字符,不管是“ ”还是“ ”,在它眼里都是“”,都会报错。
- @Email、@Pattern、@Past、@Size、@Min 和 @Max
这几个注解可以放在一起看,在不同的场景下,使用不同的注解
1、@Email:加在字符串上,用来校验是否满足邮箱格式。
2、@Pattern:按照正则表达式来校验
3、@Past:加在日期格式上,用来表示不能是未来的日期,只能是过去的日期
4、@Size:表示集合的数量大小
例如
@Size(min = 1, message = "不能少于1个好友")
private List<UserInfo>friends;
5、@Min和@Max
加在整形上,表示数值的上下限,对于基本类型(int),也是有效的
注意点:
上述的几个注解并不能保证数据不为null,如果需要保证数据不为null,必须配合@NotNull标签
例如
@Past(message = "时间不能是未来的时间")
@NotNull(message = "日期不能不填")
private Date birthday;
- 嵌套校验
@Size(min = 1, message = "不能少于1个好友") //为空时不会报错 private List<@Valid UserInfo>friends;
3、非Spring环境测试Demo
3.1、依赖
<dependencies> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> <scope>test</scope> </dependency> <dependency> <groupId>javax.validation</groupId> <artifactId>validation-api</artifactId> <version>2.0.1.Final</version> </dependency> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>6.1.5.Final</version> </dependency> <dependency> <groupId>javax.el</groupId> <artifactId>javax.el-api</artifactId> <version>3.0.0</version> </dependency> <dependency> <groupId>org.glassfish.web</groupId> <artifactId>javax.el</artifactId> <version>2.2.6</version> </dependency>
3.2、pojo类
public class UserInfo { public interface UpdateGroup {} public interface InsertGroup {} @NotNull(message = "用户id不能为空", groups = {UpdateGroup.class}) private String userId; //依次验证每个组 @GroupSequence({ UpdateGroup.class, InsertGroup.class, Default.class }) public interface group {} @NotEmpty(message = "用户名不能为空") //不会自动去掉前后空格 private String userName; //有多个条件时,如果都不满足,会打印所有信息 @NotBlank(message = "用户密码不能为空") //会自动去掉字符串中的空格 @Length(min = 6, max = 20, message = "密码不能少于6位,多于20位") private String password; @Email(message = "必须为有效邮箱") //为空时不会报错 private String email; @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "手机号格式有误") private String phone; @Min(value = 18, message = "年龄必须大于18岁") @Max(value = 68, message = "年龄必须小于68岁") //为空时不会报错 private Integer age; @Past(message = "时间不能是未来的时间") @NotNull(message = "日期不能不填") private Date birthday; @Size(min = 1, message = "不能少于1个好友") //为空时不会报错 private List<UserInfo>friends; public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public Date getBirthday() { return birthday; } public void setBirthday(Date birthday) { this.birthday = birthday; } public List<UserInfo> getFriends() { return friends; } public void setFriends(List<UserInfo> friends) { this.friends = friends; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } @Override public String toString() { return "UserInfo{" + "userId='" + userId + '\'' + ", userName='" + userName + '\'' + ", password='" + password + '\'' + ", email='" + email + '\'' + ", phone='" + phone + '\'' + ", age=" + age + ", birthday=" + birthday + ", friends=" + friends + '}'; } }
3.3 分组
以前一直有一个疑惑,validation参数校验确实很方便,但是某些情况下并不能满足开发需求。
比如添加用户信息和更新用户信息这两个业务场景下,添加用户信息不需要传入用户id,一般后台生成,而更新用户信息时必须传入用户id。这两种情况对userId这个字段的要求不一样。
validation校验框架提供了一种策略——分组,将不同的校验方式划分在不同的分组下。例如:
@NotNull(message = "用户id不能为空", groups = {UpdateGroup.class}) private String userId;
该标签只有在分组为 UpdateGroup.class时,才生效。这里的字节码仅仅时用来区分不同的分组、表示唯一性,没有特殊的含义。(可以联想到synchronized)。
因为没什么含义,为了方便,在上述pojo类里面添加了两个接口
public interface UpdateGroup {}
public interface InsertGroup {}
为了减少耦合,也可以将这两个接口定在外面。
其实validation已经的默认分组为Default.class,所以就算是不指定分组,也会被自动的划分到Default.class分组下(联想到Object类)。
在使用的时候,可以指定一个或者多个分组:
@Test public void groupValidation(){ set = validator.validate(userInfo, UserInfo.UpdateGroup.class, Default.class); }
完整的测试Demo见下方的测试类。这种写法表示按照 UpdateGroup和Default分组进行校验。
当涉及多个分组的时候,就会出现一个校验顺序的问题,我们可以指定分组顺序,让框架按照我们自定义的顺序依次校验:
//依次验证每个组 @GroupSequence({ UpdateGroup.class, InsertGroup.class, Default.class }) public interface group {}
3.4 测试类
/** * 验证测试类 */ public class ValidationTest { private Validator validator; private UserInfo userInfo; private Set<ConstraintViolation<UserInfo>> set; /** * 初始化操作 */ @Before public void init() { validator = Validation.buildDefaultValidatorFactory().getValidator(); userInfo = new UserInfo(); userInfo.setUserId("123"); userInfo.setUserName(" "); userInfo.setPassword("1234576"); userInfo.setEmail("hello@gamil.com"); userInfo.setAge(70); Calendar calendar = Calendar.getInstance(); calendar.set(1968, Calendar.FEBRUARY,1); userInfo.setBirthday(calendar.getTime()); userInfo.setFriends(new ArrayList() {{add(new UserInfo());}}); } /** * 结果打印 */ @After public void print(){ set.forEach(item -> { //输出错误信息 System.out.println(item.getMessage()); }); } @Test public void nullValidation(){ //用验证器对对象进行验证 set = validator.validate(userInfo); } @Test public void groupValidation(){ set = validator.validate(userInfo, UserInfo.UpdateGroup.class, Default.class); } }
4、Spring环境Demo
4.1 环境说明
SpringBoot版本:
<version>2.3.4.RELEASE</version>
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--validation相关-->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.5.Final</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
4.2 pojo类
public class UserInfo {
@NotNull(message = "用户id不能为空", groups = {UpdateGroup.class})
private String userId;
@NotEmpty(message = "用户名不能为空") //不会自动去掉前后空格
private String userName;
//有多个条件时,如果都不满足,会打印所有信息
@NotBlank(message = "用户密码不能为空") //会自动去掉字符串中的空格
@Length(min = 6, max = 20, message = "密码不能少于6位,多于20位")
private String password;
@Email(message = "必须为有效邮箱") //为空时不会报错
@NotNull
private String email;
@Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "手机号格式有误")
private String phone;
@Min(value = 18, message = "年龄必须大于18岁")
@Max(value = 68, message = "年龄必须小于68岁") //为空时不会报错
private Integer age;
@Past(message = "时间不能是未来的时间") //为空时不会报错
private Date birthday;
@Size(min = 1, message = "不能少于1个好友") //为空时不会报错
private List<UserInfo>friends;
@GenderValue(message = "性别输入不合法", genders = {"男","女"})
private String gender;
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Date getBirthday() {
return birthday;
}
public void setBirthday(Date birthday) {
this.birthday = birthday;
}
public List<UserInfo> getFriends() {
return friends;
}
public void setFriends(List<UserInfo> friends) {
this.friends = friends;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "UserInfo{" +
"userId='" + userId + '\'' +
", userName='" + userName + '\'' +
", password='" + password + '\'' +
", email='" + email + '\'' +
", phone='" + phone + '\'' +
", age=" + age +
", birthday=" + birthday +
", friends=" + friends +
'}';
}
}
4.3 分组
public interface InsertGroup { }
public interface UpdateGroup { }
注意:分组校验的时候,必须要用Hibernate提供的@Valided注解。
4.4 非全局异常处理
一般我们会在controller进行传参校验,校验不通过的时候会抛出异常,需要对异常信息进行有效的提取,并返回给前端。先看一种非全局的处理方法:
@RestController public class UserController { @Autowired UserInfoService userInfoService; @RequestMapping("/register") public CommonRes register(@Valid UserInfo userInfo, BindingResult bindingResult){ userInfoService.register(userInfo); if(bindingResult.hasErrors()){ Map<String, String> map = new HashMap<>(); bindingResult.getFieldErrors().forEach((item) -> { String message = item.getDefaultMessage(); String filed = item.getField(); map.put(filed, message); }); return CommonRes.fail(map.toString()); } return CommonRes.success(null); } @RequestMapping("/update") public CommonRes updateUserInfo(@Valid UserInfo userInfo, BindingResult bindingResult){ userInfoService.update(userInfo); if(bindingResult.hasErrors()){ return CommonRes.fail(bindingResult.getAllErrors().toString()); } return CommonRes.success(null); } }
优点:较为灵活
缺点:每次校验都需要写一遍bindingResult的处理代码,比较繁琐
4.5 全局异常处理
结合Spring中的全局异常处理方案,我们可以对校验出来的结果进行全局处理。
异常处理类:
@ControllerAdvice @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class}) public CommonRes handleValidException(Exception e) { BindingResult bindingResult = null; if(e instanceof MethodArgumentNotValidException) { bindingResult = ((MethodArgumentNotValidException)e).getBindingResult(); } else if (e instanceof BindException) { bindingResult = ((BindException)e).getBindingResult(); } Map<String, String>errorMap = new HashMap<>(16); assert bindingResult != null; bindingResult.getFieldErrors().forEach(fieldError -> errorMap.put(fieldError.getField(), fieldError.getDefaultMessage())); return CommonRes.fail(errorMap.toString()); } }
将Controller层代码改为
@RestController public class UserController { @Autowired UserInfoService userInfoService; @RequestMapping("/register") public CommonRes register(UserInfo userInfo){ userInfoService.register(userInfo); // if(bindingResult.hasErrors()){ // Map<String, String> map = new HashMap<>(); // bindingResult.getFieldErrors().forEach((item) -> { // String message = item.getDefaultMessage(); // String filed = item.getField(); // map.put(filed, message); // }); // return CommonRes.fail(map.toString()); // } return CommonRes.success(null); } @RequestMapping("/update") public CommonRes updateUserInfo(UserInfo userInfo){ userInfoService.update(userInfo); // if(bindingResult.hasErrors()){ // return CommonRes.fail(bindingResult.getAllErrors().toString()); // } return CommonRes.success(null); } }
优点:简化了编程,注释掉的代码就可以去掉了。
缺点:某些情况下不够灵活,比如不同参数的校验需要返回不同的状态码时,粗糙的全局处理就无法满足需求了。
但是一般来说,参数错误异常会使用统一的状态码,所以这种写法已经可以满足大多数人的校验需求了。
4.7 自定义注解
有时候现有的注解可能不能满足我们的需求,这个时候就用到自定义的注解了。
例如现在想要对gender字段进行校验,只能为“男”,或者为“女”。可以这么做
- 定义注解
@Documented @Constraint(validatedBy = {GenderValueConstraintValidator.class}) //交给哪个类校验 @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface GenderValue { String message() default ""; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String[] genders()default {}; }
- 校验类
public class GenderValueConstraintValidator implements ConstraintValidator<GenderValue, String> { private Set<String> set = new HashSet<>(); @Override public void initialize(GenderValue constraintAnnotation) { String[]genders = constraintAnnotation.genders(); for (String gender : genders) { set.add(gender); } }
//校验的结果由这个类决定 @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { return set.contains(value); } }
- 使用方法
@GenderValue(message = "性别输入不合法", genders = {"男","女"}) private String gender;
4.8、参数校验放在controller层还是service层?
自从看到一个大神把所有的参数校验放在service层,我就有点怀疑人生了。参数校验究竟是放在哪一层呢?
花了点时间特地去查了下,最后得出的结论是:
controller层和service层都需要做校验,但是侧重点不同。
controller层偏向于参数形式上的合法性,与业务无关,比如这个字段是否能为空,邮箱格式是否正确,手机格式是否正确等等。
service层偏向于校验逻辑上的合法性,比如这个同名的用户是否已经存在,比如这个邮箱是不是已经被其他用户注册了等等。
4.9、其他方法
之前看到一种校验方式,将处理结果进行一定的封装,使用起来也比较方便:
- 定义ValidationResult存放校验结果
/** * @author peng */ public class ValidationResult { /** * 结果校验是否有错 */ private boolean hasErrors; /** * 存放错误信息 */ private Map<String,String>errorMsgMap = new HashMap<>(); public boolean isHasErrors() { return hasErrors; } public void setHasErrors(boolean hasErrors) { this.hasErrors = hasErrors; } public Map<String, String> getErrorMsgMap() { return errorMsgMap; } public void setErrorMsgMap(Map<String, String> errorMsgMap) { this.errorMsgMap = errorMsgMap; } /** * 实现通用的格式化字符串信息获取错误结果的msg方法。 * @return 错误参数 */ public String getErrMsg(){ return StringUtils.join(errorMsgMap.values().toArray(), ","); } }
- 校验类
/** * @author peng */ @Component public class ValidatorImpl implements InitializingBean { private Validator validator; /** * 实现校验方法并返回校验结果 */ public ValidationResult validate(Object bean){ ValidationResult result = new ValidationResult(); Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(bean); if(constraintViolationSet.size() > 0 ){ result.setHasErrors(true); constraintViolationSet.forEach(constraintViolation->{ String errMsg = constraintViolation.getMessage(); String propertyName = constraintViolation.getPropertyPath().toString(); result.getErrorMsgMap().put(propertyName, errMsg); }); } return result; } @Override public void afterPropertiesSet() throws Exception { //将hibernate validator通过工厂的初始化方式使其实例化 this.validator = Validation.buildDefaultValidatorFactory().getValidator(); } }
- 使用方法
注入验证类、返回结果
@Service public class UserServiceImpl implements UserService, UserDetailsService { @Autowired UserDOMapper userDOMapper; @Autowired ValidatorImpl validator;
/** * * @param userModel 用户信息 * @return 0 失败,大于0 成功 * @throws BusinessException 管理员无法删除 */ @Override public int registerUser(UserModel userModel) throws BusinessException { ValidationResult validate = validator.validate(userModel); if(validate.isHasErrors()){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, validate.getErrMsg()); } UserDO userDO = userDOMapper.selectByLoginName(userModel.getLoginName()); if(userDO != null){ throw new BusinessException(EmBusinessError.USER_IS_EXIST); } return userDOMapper.insertSelective(convertFromModel(userModel)); } private UserDO convertFromModel(UserModel userModel){ if(userModel == null){ return null; } UserDO userDO = new UserDO(); BeanUtils.copyProperties(userModel, userDO); return userDO; } private UserModel convertFromDO(UserDO userDO){ if(userDO == null){ return null; } UserModel userModel = new UserModel(); BeanUtils.copyProperties(userDO, userModel); if(userDO.getUserId() != null){ List<RightModel> rightModelList = permissionService.getRightListByUserId(userDO.getUserId()); userModel.setRightModelList(rightModelList); } return userModel; } private List<UserModel> convertFromDOList(List<UserDO>userDOList){ if(userDOList == null){ return null; } return userDOList.stream().map(this::convertFromDO).collect(Collectors.toList()); } }
优缺点:算是一种比较这种的方案,可以自定义错误类型,使用起来也比较方便。
不过个人还是倾向于4.5的做法,已经可以满足大部分项目的需求了。
如有错误,欢迎批评指正!