文章目录
Bean Validation简介
Bean Validation是Java定义的一套基于注解的数据校验规范,目前已经从JSR 303的1.0版本升级到JSR 349的1.1版本,再到JSR 380的2.0版本(2.0完成于2017.08),目前最新稳定版2.0.2(201909)
大家可能会发现它的pom引用有几个
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.5.Final</version>
</dependency>
它们的关系是,Hibernate Validator 是 Bean Validation 的实现,除了JSR规范中的,还加入了它自己的一些constraint实现,所以点开pom发现Hibernate Validator依赖了validation-api。jakarta.validation是javax.validation改名而来,因为18年Java EE改名Jakarta EE了。
对于spring boot应用,直接引用它提供的starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
spring boot有它的版本号配置,继承了spring boot的pom,所以不需要自己指定版本号了。这个starter它内部也依赖了Hibernate Validator
版本
Bean Validation | Hibernate Validation | JDK | Spring Boot |
---|---|---|---|
1.1 | 5.4 + | 6+ | 1.5.x |
2.0 | 6.0 + | 8+ | 2.0.x |
Bean Validation作用
在平常写接口代码时,相信对下面代码非常眼熟,对接口请求参数进行校验的逻辑,比较常用的做法就是写大量if else来做各种判断
@RestController
public class LoginController {
@PostMapping("/user")
public ResultObject addUserInfo(@RequestBody User params) {
// 参数校验
if(params.getStatus() == null) {
...
} else if(params.getUserName == null || "".equals(params.getUserName())) {
...
} else {
...
}
// 业务逻辑处理
...
}
}
这样显得非常繁琐,代码也显得很臃肿,不便于维护。
而这个bean validation框架能够简化这一步,就最简单的判空来说,直接在传入对象里的属性上加上@NotNull、@NotEmpty、@NotBlank(这三种判空的区别后面讨论)就可以了,对于复杂的场景,比如判断a依赖于b的值,也可以通过自定义校验器得到很好的解决。
基本使用
官方参考文档:
Hibernate Validator: https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single
Jakarta Bean Validation: https://beanvalidation.org/2.0/spec/
Hibernate Validator demo:https://github.com/hibernate/hibernate-validator/tree/master/documentation/src/test
这里简单介绍基于注解的校验方式
常用注解
常用注解如下:
Constraint | 说明 | 支持的数据类型 |
---|---|---|
@AssertFalse | 被注释的元素必须为 false | Boolean |
@AssertTrue | 被注释的元素必须为 true | Boolean |
@DecimalMax | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 | BigDecimal, BigInteger, CharSequence, byte, short, int, long |
@DecimalMin | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 | BigDecimal, BigInteger, CharSequence, byte, short, int, long |
@Max | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 | BigDecimal, BigInteger, byte, short, int, long |
@Min | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 | BigDecimal, BigInteger, byte, short, int, long |
@Digits(integer=, fraction=) | 检查注释的值是否为最多为整数位(integer)和小数位(fraction)的数字 | BigDecimal, BigInteger, CharSequence, byte, short, int, long |
被注释的元素必须是电子邮箱地址,可选参数 regexp和flag允许指定必须匹配的附加正则表达式(包括正则表达式标志)。 | CharSequence | |
@Future | 被注释的元素必须是一个将来的日期 | Date,Calendar,Instant,LocalDate等 |
@FutureOrPresent | 被注释的元素必须是一个将来的日期或现在的日期 | Date,Calendar,Instant,LocalDate等 |
@Past | 被注释的元素必须是一个过去的日期 | Date,Calendar,Instant,LocalDate等 |
@PastOrPresent | 被注释的元素必须是一个过去的日期或现在的日期 | Date,Calendar,Instant,LocalDate等 |
@NotBlank | 被注释的元素不为null,并且去除两边空白字符后长度大于0 | CharSequence |
@NotEmpty | 被注释的元素不为null,并且集合不为空 | CharSequence, Collection, Map, arrays |
@NotNull | 被注释的元素不为null | Any type |
@Null | 被注释的元素为null | Any type |
@Pattern(regex=, flags=) | 被注释的元素必须与正则表达式 regex 匹配 | CharSequence |
@Size(min=, max=) | 被注释的元素大小必须介于最小和最大(闭区间)之间 | CharSequence, Collection, Map,arrays |
直接在Controller层使用
@RestController
@RequestMapping("/app/api")
@Validated
@Slf4j
public class SpringGuaranteeReportController {
@RequestMapping("/sendSpringGuaranteeReport")
public ResultObject<String> sendSpringGuaranteeReport(@Min(value = 1) @Max(value = 2) Integer mmsType,
@Min(value = 1) @Max(value = 2) Integer groupType,
@NotBlank String opTime) {
…………
}
}
主要是在controller上加@Validated注解,这里没有写BindingResult 是因为我这里用了全局异常
作用于成员变量(Field-level constraints)
@RestController
@RequestMapping("/test")
public class TestController {
@PostMapping("/t1")
public void test1(@RequestBody @Valid Person person, BindingResult bindingResult) {
// 当校验失败时,使用
if(bindingResult.hasErrors()) {
List<ObjectError> errors = bindingResult.getAllErrors();
errors.forEach(e -> System.out.println(e.getDefaultMessage()));
System.out.println("校验失败");
} else {
System.out.println("校验成功");
}
}
}
一个简单的接口,传入一个Person对象,加上@Valid启用校验,bindingResult里面就包含了参数校验的结果
@Data
public class Person {
@NotBlank(message = "姓名不能为空")
private String name;
@NotBlank(message = "性别不能为空")
private String sex;
@NotNull(message = "年龄不能为空")
@Max(value = 100, message = "年龄不能超过100")
private Integer age;
@Email(message = "电子邮箱格式错误")
private String email;
@Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$")
private String phone;
@NotEmpty(message = "兴趣不能为空")
private List<String> hobby;
}
这里做了判空和基本格式校验
其中关于@NotEmpty、@NotNull、@NotBlank的区别:
简单来说,在Integer或者自定义对象中使用@NotNull,在String上使用@NotBlank,在集合上使用NotEmpty
运行结果:
输入一个空对象,发现根据我们自定义的message错误消息返回到了bindingResult,这里将错误信息sout到了控制台
姓名不能为空
性别不能为空
兴趣不能为空
年龄不能为空
校验失败
嵌套对象校验
这种需求也是非常常见的,需要在校验的对象里嵌套一个对象并且也校验
@Data
public class Person {
@NotBlank(message = "姓名不能为空")
private String name;
@NotBlank(message = "性别不能为空")
private String sex;
@NotNull(message = "年龄不能为空")
@Max(value = 100, message = "年龄不能超过100")
private Integer age;
@Email(message = "电子邮箱格式错误")
private String email;
@Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$")
private String phone;
@NotEmpty(message = "兴趣不能为空")
private List<String> hobby;
@NotNull(message = "必须有台电脑")
@Valid
private Computer computer;
}
还是上面的类,加了一个Computer类,上面加上@Valid就可以进行嵌套校验了
@Data
public class Computer {
@NotBlank(message = "电脑名称不能为空")
private String name;
@NotBlank(message = "cpu不能没有")
private String cpu;
@NotBlank(message = "内存不能没有")
private String mem;
@PastOrPresent(message = "生产日期不能大于当前时间")
@JsonFormat(pattern = "yyyyMMdd", timezone = "GMT+8")
private Date productionDate;
}
运行结果:
请求:
{
"name": "asd",
"computer": {
"cpu": "i7",
"mem": "256g",
"productionDate": "20990909"
}
}
输出:
兴趣不能为空
性别不能为空
年龄不能为空
校验失败
继承对象校验
如果被校验的对象有继承关系,并且父类有约束条件,那么这些约束条件会被校验
@Data
public class Human {
@NotBlank(message = "---这个不能为空---")
private String common;
}
声明一个父类
@Data
public class Person extends Human {
...
}
还是刚刚的类,增加继承关系
运行结果
年龄不能为空
兴趣不能为空
---这个不能为空---
性别不能为空
校验失败
发现父类的也会被校验
作用于类上,自定义校验(Class-level constraints)
这个就是自定义参数校验的方式,当遇到一些特殊的需求,比如根据类中属性A的值,采取不同策略校验其他值
@Data
@PersonValidator
public class Person {
private String name;
private String sex;
private Integer age;
private String email;
private String phone;
private List<String> hobby;
}
还是这个Person类,我们可以发现加了一个@PersonValidator注解,这是自定义的注解
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {PersonValidatorProcess.class})
public @interface PersonValidator {
/**
* 校验的失败的时候返回的信息,由于这个注解被用于class,我们想返回具体的校验信息
* 所以后面会通过buildConstraintViolationWithTemplate重写返回失败时具体哪些参数校验未通过
*/
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
这个注解重要的地方就在@Constraint(validatedBy = {PersonValidatorProcess.class}),指定了校验的处理器
public class PersonValidatorProcess implements ConstraintValidator<PersonValidator, Person> {
@Override
public boolean isValid(Person value, ConstraintValidatorContext context) {
// 关闭默认消息
context.disableDefaultConstraintViolation();
if(value.getName() == null || "".equals(value.getName())) {
context.buildConstraintViolationWithTemplate("名称不能为空").addConstraintViolation();
return false;
}
if(value.getSex() == null || "".equals(value.getSex())) {
context.buildConstraintViolationWithTemplate("性别不能为空").addConstraintViolation();
return false;
}
return true;
}
}
这个处理器实现ConstraintValidator接口就行了,里面有个isValid方法,就做我们自定义处理的逻辑,注意到context.buildConstraintViolationWithTemplate,这个信息会传递到之前的bindingResult的error里面,这样就可以返回具体的校验错误信息了
使用全局异常处理
在实际项目实践中,发现用全局异常处理去处理bindingResult的error信息是不错的选择
@Slf4j
@RestControllerAdvice
public class GlobalAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultObject<List<String>> parameterExceptionHandler(MethodArgumentNotValidException e,
HttpServletRequest request) {
// 获取异常信息
BindingResult exceptions = e.getBindingResult();
// 这里列出了全部错误参数,这里用List传回
List<String> fieldErrorMsg = new ArrayList<>();
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (exceptions.hasErrors()) {
List<ObjectError> errors = exceptions.getAllErrors();
if (!errors.isEmpty()) {
errors.forEach(msg -> fieldErrorMsg.add(msg.getDefaultMessage()));
return ResultObject.createByErrorMessage("请求参数校验错误", fieldErrorMsg);
}
}
fieldErrorMsg.add("未知异常");
return ResultObject.createByErrorMessage("请求参数校验错误", fieldErrorMsg);
}
}
捕获MethodArgumentNotValidException异常,加到全局异常处理即可,这样就不需要在controller中处理bindingResult了
下面是我这用的一个全局异常处理模板,包含了更多的异常处理,仅供参考
@Slf4j
@RestControllerAdvice
public class GlobalAdvice {
@Autowired
private ObjectMapper objectMapper;
// ---------- 参数校验 ----------
/**
* 忽略参数异常处理器
* @param e 忽略参数异常
* @return ResultObject
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResultObject<String> parameterMissingExceptionHandler(MissingServletRequestParameterException e,
HttpServletRequest request) {
printLog(e, request);
return ResultObject.createByErrorMessage("请求参数 " + e.getParameterName() + " 不能为空");
}
/**
* 媒体类型不支持异常处理器
* @param e 类型不匹配异常
* @return resultObject
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResultObject<String> HttpMediaTypeNotSupportedExceptionHandler(HttpMediaTypeNotSupportedException e,
HttpServletRequest request) {
printLog(e, request);
return ResultObject.createByErrorMessage("请求类型错误,请检查conten-type是否正确");
}
/**
* 缺少请求体异常处理器
* @param e 缺少请求体异常
* @return ResultObject
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResultObject<String> parameterBodyMissingExceptionHandler(HttpMessageNotReadableException e,
HttpServletRequest request) {
printLog(e, request);
return ResultObject.createByErrorMessage("参数体校验错误");
}
/**
* Bean Validation参数校验异常处理器
* @param e 参数验证异常
* @return ResultObject
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultObject<List<String>> parameterExceptionHandler(MethodArgumentNotValidException e,
HttpServletRequest request) {
printLog(e, request);
// 获取异常信息
BindingResult exceptions = e.getBindingResult();
// 这里列出了全部错误参数,这里用List传回
List<String> fieldErrorMsg = new ArrayList<>();
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (exceptions.hasErrors()) {
List<ObjectError> errors = exceptions.getAllErrors();
if (!errors.isEmpty()) {
errors.forEach(msg -> fieldErrorMsg.add(msg.getDefaultMessage()));
return ResultObject.createByErrorMessage("请求参数校验错误", fieldErrorMsg);
}
}
fieldErrorMsg.add("未知异常");
return ResultObject.createByErrorMessage("请求参数校验错误", fieldErrorMsg);
}
/**
* 参数校验过程中发生的异常
* @param e 参数校验异常
* @return resultObject
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(ValidationException.class)
public ResultObject<String> validationExceptionHandler(ValidationException e,
HttpServletRequest request) {
printLog(e, request);
String message = e.getMessage();
if(message != null) {
return ResultObject.createByErrorMessage(message);
}
return ResultObject.createByErrorMessage("请求参数校验错误");
}
// --------- 业务逻辑异常 ----------
/**
* 自定义异常,捕获程序逻辑中的错误,业务中出现异常情况直接抛出异常即可
* @param e 自定义异常
* @return ResultObject
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler({GlobalException.class})
public ResultObject<String> paramExceptionHandler(GlobalException e,
HttpServletRequest request) {
printLog(e, request);
// 判断异常中是否有错误信息,如果存在就使用异常中的消息,否则使用默认消息
if (!StringUtils.isEmpty(e.getMessage())) {
return ResultObject.createByErrorMessage(e.getMessage());
}
return ResultObject.createByErrorMessage("程序出错,捕获到一个未知异常");
}
// ---------- 全局通用异常 ----------
/**
* 通用异常处理
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(value = Throwable.class)
public ResultObject<String> exceptionHandler(Throwable e,
HttpServletRequest request,
HttpServletResponse response) {
printLog(e, request);
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
return ResultObject.createByErrorMessage("服务器异常");
}
/**
* 打印日志
* @param e Throwable
* @param request HttpServletRequest
*/
private void printLog(Throwable e, HttpServletRequest request) {
log.error("【method】: {}【uri】: {}【errMsg】: {}【params】:{}",
request.getMethod(), request.getRequestURI(), e.getMessage(), buildParamsStr(request), e);
}
/**
* 请求的参数拼接str
* @param request HttpServletRequest
* @return 请求参数
*/
private String buildParamsStr(HttpServletRequest request) {
try {
return objectMapper.writeValueAsString(request.getParameterMap());
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}
实战自定义参数校验
业务背景: 传入一个指标对象,里面有个status字段,根据这个字段数据表示不同类型的指标(大概有4种),然后根据不同类型有不同校验规则(规则相差比较大)去校验,然后落库。
如果按照最简单的方式,在service里先用if else判断不同status,然后调用不同的方法去用if else校验每种类型指标,再执行后面的业务逻辑,显得比较繁琐,耦合性也比较强。
考虑到不同校验方法相差比较大,这不就是不同的校验策略嘛,就想到了策略模式,由于新增和编辑都是不同的策略,可能会导致策略类膨胀,以后可以考虑用混合模式,比如模板方法模式+策略模式,减少重复代码。
这里使用Bean Validation+策略模式解决这繁琐的业务
@Data
@NoArgsConstructor
@AllArgsConstructor
@KpiCreateValidator
public class IndexDeployEditionVO {
@NotBlank(message = "指标域不能为空")
private String indexArea;
@NotBlank(message = "指标组不能为空")
private String indexGroup;
@NotBlank(message = "指标编码不能为空")
private String indexId;
@NotBlank(message = "指标名称不能为空")
private String indexDesc;
@NotBlank(message = "指标周期不能为空")
private String indexCycle;
private Integer startCondition;
@NotNull(message = "状态不能为空")
private Integer status;
private String endPerson;
private String operDate;
......
}
首先看需要校验的参数对象,加了一个@KpiCreateValidator表示自定义注解,注意到它可以与成员变量上的校验混用
再就是写校验逻辑了,我这里的目录结构是
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {KpiCreateValidatorProcess.class})
public @interface KpiCreateValidator {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
还是老套路,自定义注解上写一个自定义校验器
public class KpiCreateValidatorProcess implements ConstraintValidator<KpiCreateValidator, IndexDeployEditionVO> {
private KpiDao kpiDao = ApplicationContextProvider.getBean(KpiDao.class);
// 此为策略模式中的context
private KpiCreateContext kpiCreateContext;
@Override
public void initialize(KpiCreateValidator constraintAnnotation) {
}
@Override
public boolean isValid(IndexDeployEditionVO value, ConstraintValidatorContext context) {
// 如果是a类型指标
if (value.getStatus().equals(IndexStatusEnum.BASIC_KPI.getCode())) {
kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateBasicStrategy.class));
return kpiCreateContext.doValid(value, context);
}
// 如果是b类型指标
if (value.getStatus().equals(IndexStatusEnum.CONVERT_KPI.getCode())) {
kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateConvertStrategy.class));
return kpiCreateContext.doValid(value, context);
}
// 如果是c类型指标
if (value.getStatus().equals(IndexStatusEnum.COMPUTE_KPI.getCode())) {
kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateComputeStrategy.class));
return kpiCreateContext.doValid(value, context);
}
return false;
}
}
可以发现,根据不同类型的指标采取了不同的校验策略,如果要修改某一策略也非常方便容易。其他的,ApplicationContextProvider是实现ApplicationContextAware接口的一个类,主要用于获取ApplicationContext,从而从Spring容器中获取想要的bean,因为这个类没有加@Component注解,所以采用的这样的方式获取bean
下面就是一个典型的策略模式实现了
/**
* context类,用于接纳不同的校验策略
* @author Created by 0x on 2020/6/5
**/
public class KpiCreateContext {
private KpiCreateStrategy strategy;
public KpiCreateContext(KpiCreateStrategy strategy) {
this.strategy = strategy;
}
public boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context) {
return strategy.doValid(params, context);
}
}
策略模式的context类
/**
* 指标创建校验器的策略类
* @author Created by 0x on 2020/6/5
**/
public interface KpiCreateStrategy {
/**
* 新增指标校验的方法
* @param params 需要校验的对象
* @param context 校验器context
* @return bool
*/
boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context);
}
各个校验策略需要实现的接口
@Component
public class KpiCreateBasicStrategy implements KpiCreateStrategy {
private final KpiDao kpiDao;
public KpiCreateBasicStrategy(KpiDao kpiDao) {
this.kpiDao = kpiDao;
}
@Override
public boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context) {
// 关闭默认消息
context.disableDefaultConstraintViolation();
boolean ret = true;
if (params.getIndexDelay() == null) {
ret = false;
context.buildConstraintViolationWithTemplate("延迟天数不能为空").addConstraintViolation();
}
if (params.getIndexDependentModel() == null) {
ret = false;
context.buildConstraintViolationWithTemplate("指标依赖方式不能为空").addConstraintViolation();
}
......
return ret;
}
}
这里列举其中一个策略,其他的策略类根据业务需求来实现就行,全局异常处理也还是用上面的方式,然后就完成这个需求了
/**
* 新增单指标
*
* @param params 指标所有信息
*/
@PostMapping("addSingleKpi")
@SuccessWrapper
public void addSingleKpi(@RequestBody @Valid IndexDeployEditionVO params) {
kpiCreateService.addSingleKpiToEdition(params, "1");
}
最后一看controller,是不是很干净,service也是直接使用数据就行了,校验器已经帮我们校验好数据了