开发系统的时候,为了保证数据完整性、合法性、有效性,一般都会对进入系统的业务数据进行效验。除了前端必须要进行效验之外,后端也要对提交的数据进行效验。其他场景如:开发对外的API、编写一个工具类等更要做好数据效验工作。
以前在后端编写数据效验代码我都是用IF-ELSE来判断,什么字段不能为空啦、长度不能超过啦、值不在字典范围里面啦…等等。代码里面充斥着大量IF-ELSE语句,非常的不好维护。
Java 规范提案(JSR) 提交了JSR-303、JSR-349以及JSR-380等来规范数据效验机制。这3个JSR统一被称为Bean Validation。Bean Validation为Java数据校验提供了更加规范化、通用化、灵活度更高的校验方法。
JSR只是制定了一个规范,具体的数据效验功能是由各种技术框架实现的。其中Hibernate Validator就是根据JSR规范实现校验功能的一个框架。Spring Boot2中的 spring-boot-starter-web启动器 已经默认关联依赖了Hibernate Validator 6.X支持,本文例子就是基于这个效验框架来编写。
Java数据效验主要是用注解在JavaBean的属性字段上声明数据效验准则,数据效验主要包括注解、效验器以及效验工厂。
Bean Validation:https://beanvalidation.org/
在Spring Boot2项目中编写简单测试用例,数据效验主要是围绕实体类展开的,示例代码如下:
**
* 雇员信息对象Vo
*
* @author David Lin
* @version: 1.0
* @date 2020-03-21 17:03
*/
@Getter
@Setter
public class EmpVo {
/**
* 员工编号
*/
@NotNull(message = "用来区分员工的标识不能为空!!")
private Long empno;
/**
* 员工姓名
*/
@NotBlank
@Length(min = 3,max = 10)
private String empname;
/**
* 工作岗位
*/
private String job;
/**
* 领导者编号
*/
private int mgr;
/**
* 入职时间
* 自定义输出格式
*/
@Past
@JsonFormat(locale = "zh", timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
private Timestamp hiredate;
/**
* 薪水
*/
@Min(0)
private double sal;
/**
* 奖金
*/
private double comm;
/**
* 所在部门编号
*/
private int deptno;
/**
* 年龄
*/
@Min(1)
@Max(value = 120 ,message = "大于120岁不存在的!!")
private int age;
/**
* 部门名称
*/
private String dname;
/**
* 创建时间
*/
private Timestamp createTime;
/**
* 更新时间
*/
private Timestamp updateTime;
}
使用效验器进行效验 代码如下:
@Test
public void testEmpvo() {
EmpVo empVo = new EmpVo();
empVo.setEmpname("smith");
empVo.setAge(200);
empVo.setSal(20000);
//引入校验工具
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
//获取校验器
Validator validator = factory.getValidator();
//执行校验 获取效验的结果
Set<ConstraintViolation<EmpVo>> validateResult = validator.validate(empVo);
//如果校验通过则返回的Set 对象集合长度为0
if (validateResult.size() == 0) {
log.info("效验通过啦!!!");
} else {
//遍历效验结果
Iterator<ConstraintViolation<EmpVo>> iterator = validateResult.iterator();
while (iterator.hasNext()) {
ConstraintViolation<EmpVo> next = iterator.next();
log.info("验证失败的字段是:{}, 错误信息为:{}", next.getPropertyPath(), next.getMessage());
}
}
}
输出如下:
验证失败的字段是:empno, 错误信息为:用来区分员工的标识不能为空!!
验证失败的字段是:age, 错误信息为:大于120岁不存在的!!
执行结束之后 ,validateResult就是效验结果,如果效验通过这个Set集合对象就是空的。
遍历效验结果也可以使用Java8的 Lambda 表达式进行简化,输出结果都是一样的,代码如下:
if (validateResult.size() == 0) {
log.info("效验通过啦!!!");
}else{
//使用Java8的 Lambda 表达式 简化
validateResult.forEach(validate -> {
log.info("验证失败的字段是:{}, 错误信息为:{}", validate.getPropertyPath(), validate.getMessage());
});
}
Hibernate Validator内置效验注解如下( Built-in Constraint definitions):
@Null |
被注释的元素必须为 null |
---|---|
@NotNull |
被注释的元素必须不为 null |
@NotEmpty |
被注释的字符串的必须非空 must not be {@code null} nor empty. |
@NotBlank |
验证字符串非null,且长度必须大于0 |
@AssertTrue |
被注释的元素必须为 true |
@AssertFalse |
被注释的元素必须为 false |
@Min |
被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max |
被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin |
被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@DecimalMax |
被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Negative |
被注释的元素必须是一个严格意义上的负数 |
@NegativeOrZero |
被注释的元素必须是一个负数或者0 |
@Positive |
被注释的元素必须是一个严格意义上正数 |
@PositiveOrZero |
被注释的元素必须是一个正数或者0 |
@Size |
被注释的元素的大小必须在指定的范围内(included) |
@Digits |
被注释的元素必须是一个数字,其值必须在可接受的范围内 |
@Past |
被注释的元素必须是一个过去的日期时间 |
@PastOrPresent |
被注释的元素必须是一个过去的日期时间或者是当前日期世纪 |
@Future |
被注释的元素必须是一个将来的日期时间 |
@FutureOrPresent |
被注释的元素必须是一个将来的日期时间或者当前日期时间 |
@Pattern |
被注释的元素必须符合指定的正则表达式 |
@Email |
被注释的元素必须是电子邮箱地址 |
@Length | 被注释的字符串的大小必须在指定的范围内 |
@Range(min=,max=,message=) | 被注释的元素必须在合适的范围内 |
可以通过组合已有的效验规则来实现新的效验规则
例如 定义新的效验注解 @MyAge
@Min(value = 1,message = "年龄最小不能小于1")
@Max(value = 120,message = "年龄最大不能超过120")
@Constraint(validatedBy = {}) //不指定效验器
@Documented
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAge {
String message() default "年龄大小必须大于1并且小于120";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
在实体类属性字段上使用 自定义组合注解
@Getter
@Setter
public class EmpVo {
/**
* 年龄
*/
@MyAge
private int age;
Bean Validation支持自定义效验规则,对属性字段的一个效验被称为Constraint,一个Constraint由一个Annotation(注解)绑定1~N个Validator(效验器)组成。因此通过新增注解和效验器来定义新的效验规则。
@Constraint(validatedBy = { DeptNoTypeValidator.class }) //指定校验器
@Documented
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD ,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyDeptNo {
//自定义提示信息
String message() default "部分编号只能是10、20、30、40等几个编号!!";
//自定义分组 将 validator 进行分类,不同的类 group 中会执行不同的 validator 操作
Class<?>[] groups() default {};
//指定效验问题的级别
Class<? extends Payload>[] payload() default {};
}
这个注解是作用在 Field 字段、Method方法、类型声明、注解声明上,运行时生效,触发的是DeptNoTypeValidator 这个效验器。
自定义效验器是真正进行验证的逻辑代码
/**
* 部门编号效验器
*
* @author David Lin
* @version: 1.0
* @date 2020-03-22 10:21
*/
public class DeptNoTypeValidator implements ConstraintValidator<MyDeptNo, EmpVo> {
/**
* 部门编号字典 ,可以改成枚举
*/
private final List<Integer> deptNoList = Arrays.asList(new Integer[]{10, 20, 30, 40});
@Override
public void initialize(MyDeptNo constraintAnnotation) {
}
@Override
public boolean isValid(EmpVo empVo, ConstraintValidatorContext constraintValidatorContext) {
return deptNoList.contains(empVo.getDeptno());
}
}
自定义的效验器必须实现ConstraintValidator这个接口,并在范型中声明对应的自定义校验注解和数据类型(ConstraintValidator,A是绑定的注解类型、T是数据类型)。本文的@MyDeptNo注解是作用在类声明上,所以数据类型是实体类。
@Getter
@Setter
@MyDeptNo
public class EmpVo {
/**
* 员工编号
*/
@NotNull(message = "用来区分员工的标识不能为空!!")
private Long empno;
/**
* 员工姓名
*/
@NotBlank
@Length(min = 3,max = 10)
private String empname;
/**
* 薪水 查询的时候不返回这个字段值
*/
@Range(min = 0,max = 20000,message = "薪水不在范围内的啊")
private double sal;
/**
* 所在部门编号
*/
private int deptno;
/**
* 年龄
*/
@MyAge
private int age;
......
}
在实际业务场景中数据效验规则并不是一成不变的,有时候需要根据某些状态来对单个或者一组属性字段进行效验。这个时候就可以用到分组效验功能,即 根据状态启用一组约束。注解里面有个groups参数,就是用来指定分组的,如果groups没有指定,则效验都属于javax.validation.groups.Default默认分组。
假设有这样的场景:保存和更新的API共用一个VO/DTO,保存的API需要效验empname(姓名),createTime(创建时间),更新的API需要效验empno(主键标识)、empname(姓名)、updateTime(更新时间)。也就是说保存的API不需要效验empno、和updateTime,更新的API不需要效验createTime字段,保存和更新同时都要效验empname。
这里用没有任何功能的类或者接口定义分组都可以的
import javax.validation.groups.Default;
/**
* 保存效验分组
* @author David Lin
* @version: 1.0
* @date 2020-03-22 14:38
*/
public interface CreateGroup extends Default {}
import javax.validation.groups.Default;
/**
* 更新效验分组
* @author David Lin
* @version: 1.0
* @date 2020-03-22 14:40
*/
public interface UpdateGroup extends Default {}
@Getter
@Setter
@MyDeptNo
public class EmpVo {
/**
* 员工编号
*/
@NotNull(groups = {UpdateGroup.class}, message = "用来区分员工的标识不能为空!!")
private Long empno;
/**
* 员工姓名
*/
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
@Length(groups = {CreateGroup.class, UpdateGroup.class}, min = 3, max = 10)
private String empname;
/**
* 创建时间
*/
@NotNull(groups = {CreateGroup.class})
@FutureOrPresent(groups = {CreateGroup.class}, message = "创建时间必须是当前或者将来时间!!")
private Timestamp createTime;
/**
* 更新时间
*/
@NotNull(groups = {UpdateGroup.class})
@FutureOrPresent(groups = {UpdateGroup.class}, message = "更新时间必须是当前或者将来时间!!")
private Timestamp updateTime;
.......
}
EmpVo empVo = new EmpVo();
empVo.setEmpname("sm");
empVo.setAge(29);
empVo.setSal(20000);
empVo.setDeptno(20);
empVo.setCreateTime(new Timestamp(1314));
.......
//指定分组效验
Set<ConstraintViolation<EmpVo>> validateResult = validator.validate(empVo,CreateGroup.class);
validateResult.forEach(validate -> {
log.info("验证失败的字段是:{}, 错误信息为:{}", validate.getPropertyPath(), validate.getMessage());
});
输出结果为:
验证失败的字段是:createTime, 错误信息为:创建时间必须是当前或者将来时间!!
验证失败的字段是:empname, 错误信息为:长度需要在3和10之间
这里指定分组效验之后,只会执行groups = {CreateGroup.class}注解的校验。
如果想使用默认分组效验,则需要在效验注解上指定默认分组:
/**
* 创建时间
*/
@NotNull(groups = {CreateGroup.class,Default.class})
@FutureOrPresent(groups = {CreateGroup.class,Default.class}, message = "创建时间必须是当前或者将来时间!!")
private Timestamp createTime;
//使用默认分组校验
Set<ConstraintViolation<EmpVo>> validateResult = validator.validate(empVo);
注解里面的payload参数是用来标识 "效验问题"级别的,就是在效验数据时对"效验问题"进行分类。
**
* 效验问题级别
*
* @author David Lin
* @version: 1.0
* @date 2020-03-22 15:27
*/
public class PayLoadLevel {
//信息提示级别
public static interface INFO extends Payload {}
// 警告级别
public static interface WARN extends Payload{}
}
/**
* 薪水
*/
@Range(min = 0, max = 20000, message = "薪水不在范围内的啊",payload = PayLoadLevel.WARN.class)
private double sal;
log.info("问题级别为:{}",validate.getConstraintDescriptor().getPayload().toString());
Spring MVC对Hibernate Validation进行了二次封装,添加了自动效验功能,并将效验结果信息封装进了BindingResult类中。
/**
* 保存雇员信息
* @param empVo
* @return
*/
@PostMapping("/saveemp")
public Integer saveEmp(@RequestBody @Validated EmpVo empVo) {
Emp emp = new Emp();
BeanUtils.copyProperties(empVo,emp);
return empService.saveEmp(emp);
}
用Postman工具模拟请求,Headers设置Content-Type为application/json
可以看到效验不通过自动抛出了MethodArgumentNotValidException异常,返回的结果不太友好,直接将整个错误对象相关信息都响应给客户端了,可以采用全局异常处理器来统一处理效验异常和业务异常,然后统一返回特定格式数据给客户端。
@RestController
@Validated
public class EmpController {
/**
* 日志操作对象
*/
Logger logger = LoggerFactory.getLogger(EmpController.class);
/**
* 员工业务操作对象
*/
@Resource
private EmpService empService;
@RequestMapping("/auth")
public String authorization(@Length(min = 11,message = "Clientid长度至少11位") @RequestParam("clientid") String clientid,
@NotBlank(message = "口令不能为空") @RequestParam("pass") String pass) {
if ("admin".equals(clientid) && "11111111".equals(pass)) {
return "success";
}
return "error";
}
可以看到自动抛出了ConstraintViolationException。@RequestParam注解有一个required参数,默认值为true,指示参数是否必须绑定,如果参数少了其中一个或者都没带参数请求,则抛出MissingServletRequestParameterException。
注意点:单个参数效验是在Controller类上面增加@Validated
注解,而不是在方法参数上加。
参数校验失败会自动抛出异常,推荐使用全局异常拦截处理的方式去处理效验失败后的处理流程,这样能能减少Controller层或Services层的代码逻辑处理,然后统一响应格式给客户端。
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 全局处理 MethodArgumentNotValidException 异常
* @param methodArguException
* @return
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map handleMethodArgumentNotValidException(MethodArgumentNotValidException methodArguException) {
BindingResult bindingResult = methodArguException.getBindingResult();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
StringBuilder messBuilder = new StringBuilder(128);
for (FieldError fieldError : fieldErrors) {
messBuilder.append(fieldError.getDefaultMessage()).append(";");
}
//响应格式用Map, 比较推荐的是定义一个统一响应对象
Map result = new HashMap();
result.put("code", "10001");
result.put("msg", "参数效验失败!" + messBuilder.toString());
return result;
}
/**
* 全局处理ConstraintViolationException异常
*
* @param constraintViolationEx
* @return
*/
@ExceptionHandler(ConstraintViolationException.class)
public Map handleConstraintViolationException(ConstraintViolationException constraintViolationEx) {
StringBuilder messBuilder = new StringBuilder(64);
Set<ConstraintViolation<?>> constraintViolations = constraintViolationEx.getConstraintViolations();
//使用Java8的 Lambda 表达式 简化
constraintViolations.forEach(validate -> {
messBuilder.append(validate.getMessage());
});
Map result = new HashMap();
result.put("code", "10001");
result.put("msg", "参数效验失败!" + messBuilder.toString());
return result;
}
/**
* 全局处理MissingServletRequestParameterException异常
*
* @param missEx
* @return
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public Map handleMissingServletRequestParameterException(MissingServletRequestParameterException missEx) {
StringBuilder messBuilder = new StringBuilder(64);
messBuilder.append(missEx.getParameterName()).append(missEx.getMessage());
Map result = new HashMap();
result.put("code", "10001");
result.put("msg", messBuilder.toString());
return result;
}
}
数据校验不会在第一次碰到参数错误时就返回,而是会校验完成所有的参数。
使用@Valid
注解进行级联效验…
1.@Valid:标准JSR-303规范的标记型注解,用来标记验证属性和方法返回值,进行级联和递归校验
2.@Validated:是Spring提供的注解,是标准JSR-303的一个变种,提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制。
不同点:
@Validated
只能用在类、方法和参数上,而@Valid
可用于方法、字段、构造器和参数上@Validated
支持分组,而@Valid
不支持在校验数据时,使用@Valid
和@Validated
注解并没有特别的差异,@Validated
注解可以用于类级别,而且支持分组,而@Valid注解可以用在属性级别约束,用来表示级联校验。关于@Valid
和@Validated
的区别可以参考别人的博客
…