spring boot中使用Bean Validation做优雅的参数校验

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
@Email 被注释的元素必须是电子邮箱地址,可选参数 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

作用于成员变量(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.getCause().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也是直接使用数据就行了,校验器已经帮我们校验好数据了

猜你喜欢

转载自blog.csdn.net/w57685321/article/details/106783433
今日推荐