spring boot 优雅的参数校验

14,728 阅读8分钟

spring boot 优雅的参数校验

最近在公司其它业务组的项目中发现地方很多用if else校验请求参数,几乎每个接收参数的地方都有一大堆参数校验。

这篇文章介绍在spring boot项目中,如何使用validation优雅的校验请求参数

假设我们已经做好了所有的前期工作,这时候有个新接口需要校验参数

先在接口参数前面增加@Valid注解表示需要校验,controller如下:

@PostMapping
public HttpResponse demo(@Valid @RequestBody DemoDTO dto) {
    return dto.getName();
}

再增加validation注解:

@NotNull(message = "请输入姓名")
private String name;

好,我们调用输出如下:

{
    "code": 500,
    "msg": "请输入姓名"
}

是不是很简单,只需要增加两个注解,一个注解表示需要校验,一个注解表示怎么校验,所有的一切就搞定了。

不过在这之前,还需要做点其他工作。

  1. 定义统一返回响应类
  2. 拦截异常

我们先定义一个封装响应的类HttpResponse

@Setter
@Getter
@Builder
public class HttpResponse {
    private Integer code;
    private String msg;
}

再全局拦截异常,返回这个对象,完整的异常处理以后会讲,这里只给大家看怎么配置:

@ControllerAdvice
public class BaseExceptionHandler {
    @ResponseBody
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public HttpResponse handle(MethodArgumentNotValidException e) {
        FieldError fieldError = e.getBindingResult().getFieldError();
        return HttpResponse.builder().code(500).msg(fieldError.getDefaultMessage()).build();
    }
}

这里的MethodArgumentNotValidException是在校验时抛出的,实际情上因为校验失败还会抛出其它异常,不过一般都是由于开发过程中导致的问题,与用户的输入无关,所以可以统一响应"系统异常",无需单独处理。

异常的处理还有其它方式,但统一拦截是最轻松的一种,开发过程中,无需考虑校验失败怎么处理,只需要写几个注解定规则。

Bean Validation 介绍

Bean Validation是一整套关于数据验证的规范,JSR 303 – Bean Validation 规范 jcp.org/en/jsr/deta…

Bean Validation定义了一系列元数据模型和 API。Hibernate Validator 是 Bean Validation 的参考实现,除了JSR 303 规范中内置约束,还额外定义一些常用约束实现,www.hibernate.org/subprojects…

Bean Validation 中的约束

约束 说明
@Null 被注释的元素必须为 null
@NotNull 被注释的元素必须不为 null
@AssertTrue 被注释的元素必须为 true
@AssertFalse 被注释的元素必须为 false
@Min(value) 被注释的元素必须是一个数字,
其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,
其值必须小于等于指定的最大值
@DecimalMin(value) 被注释的元素必须是一个数字,
其值必须大于等于指定的最小值
@DecimalMax(value) 被注释的元素必须是一个数字,
其值必须小于等于指定的最大值
@Size(max, min) 被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction) 被注释的元素必须是一个数字,
其值必须在可接受的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(value) 被注释的元素必须符合指定的正则表达式

Hibernate Validator 附加的约束

约束 说明
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range 被注释的元素必须在合适的范围内

一个约束通常由注解和相应的约束验证器组成,它们是一对多的关系。也就是说可以有多个约束验证器对应一个 注解。在运行时,Bean Validation 框架本身会根据被注释元素的类型来选择合适的约束验证器对数据进行验证。

有些时候,在用户的应用中需要一些更复杂的约束。Bean Validation 提供扩展约束的机制。可以通过两种方法去实现,一种是组合现有的约束来生成一个更复杂的约束,另外一种是开发一个全新的约束。后面我会演示如何增加自定义验证器。

约束的适用范围

  • @Null

    • 说明:被注释的元素必须为 null

    • 适用范围:Object

  • @NotNull

    • 说明:被注释的元素必须不为 null

    • 适用范围:Object

  • @AssertTrue

    • 说明:被注释的元素必须为 true

    • 适用范围:booleanBoolean

  • @AssertFalse

    • 说明:被注释的元素必须为 false

    • 适用范围:booleanBoolean

  • @Min(value)

    • 说明:被注释的元素必须是一个数字,其值必须大于等于指定的最小值

    • 适用范围:BigDecimalBigIntegerbyteByteshortShortintIntegerlongLong

  • @Max(value)

    • 说明:被注释的元素必须是一个数字,其值必须小于等于指定的最大值

    • 适用范围:BigDecimalBigIntegerbyteByteshortShortintIntegerlongLong

  • @DecimalMin(value)

    • 说明:被注释的元素必须是一个数字,其值必须大于等于指定的最小值

    • 适用范围:BigDecimalBigIntegerCharSequencebyteByteshortShortintIntegerlongLong

  • @DecimalMax(value)

    • 说明:被注释的元素必须是一个数字,其值必须小于等于指定的最大值

    • 适用范围:BigDecimalBigIntegerCharSequencebyteByteshortShortintIntegerlongLong

  • @Size(max, min)

    • 说明:被注释的元素的大小必须在指定的范围内

    • 适用范围:CharSequenceCollectionMapArray

  • @Digits (integer, fraction)

    • 说明:被注释的元素必须是一个数字,其值必须在可接受的范围内

    • 适用范围:BigDecimalBigIntegerCharSequencebyte Byteshort Shortint Integerlong Long

  • @Past

    • 说明:被注释的元素必须是一个过去的日期

    • 适用范围:DateCalendarInstantLocalDateLocalDateTimeLocalTimeMonthDayOffsetDateTimeOffsetTimeYearYearMonthZonedDateTimeHijrahDateJapaneseDateMinguoDateThaiBuddhistDate

  • @Future

    • 说明:被注释的元素必须是一个将来的日期

    • 适用范围:DateCalendarInstantLocalDateLocalDateTimeLocalTimeMonthDayOffsetDateTimeOffsetTimeYearYearMonthZonedDateTimeHijrahDateJapaneseDateMinguoDateThaiBuddhistDate

  • @Pattern(value)

  • 说明:被注释的元素必须符合指定的正则表达式

    • 适用范围:CharSequencenull
  • @Email

    • 说明:被注释的元素必须是电子邮箱地址
    • 适用范围:CharSequence
  • @Length

    • 说明:被注释的字符串的大小必须在指定的范围内
    • 适用范围:
  • @NotEmpty

    • 说明:被注释的字符串的必须非空
    • 适用范围:
  • @Range

    • 说明:被注释的元素必须在合适的范围内
    • 适用范围:

使用代码中校验

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<DemoDTO>> violations = validator.validate(dto);

spring boot中其实已经有定义好的,可以直接依赖:

@Resource
private Validator validator;

自定义约束

自带的约束一般来说已经够用,如果有不一般的情况,就需要自己定义约束。

组合其它约束

直接将原有的约束添加到自己的注解上,我们新增个约束年龄属性的注解,假如我们限制年龄必须在0-150之间:

@Max(150)
@Min(0)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
public @interface Age {

    @OverridesAttribute(constraint = Max.class, name = "message")
    @OverridesAttribute(constraint = Min.class, name = "message")
    String message() default "年龄超出范围";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

@OverridesAttribute注解可以覆盖组合约束的一些属性,这里只是覆盖message,其它属性也可以覆盖

简单测试一下:

@Age(message = "这年龄不太对吧")
private Integer age;

输出:

{
    "code": 500,
    "msg": "这年龄不太对吧"
}

新建约束

还是限制年龄的例子,这次我们自己写逻辑,首先是约束注解:

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = AgeValidtor.class)
public @interface Age {

    String message() default "年龄超出范围";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

注意这里的@Constraint(validatedBy = AgeValidtor.class),它设置了我们自己实现的验证器

接下来是实现验证器:

public class AgeValidtor implements ConstraintValidator<Age, Integer> {

    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        return value == null || (value >= 0 && value <= 150);
    }
}

验证器实现的接口的两个泛型,前面的Age表示约束注解,后面的Integer表示这个验证器用来验证这个类型的属性,前面介绍过,一个约束注解可以关联多个验证器,根据要验证的属性类型选择合适的验证器。

输出还是一样的:

{
    "code": 500,
    "msg": "这年龄不太对吧"
}

嵌套验证

如果一个对象内部有另一个对象需要验证,需要在这个对象属性上方加@Valid注解

@Getter
@Setter
@ToString
public class DemoDTO {

    @Valid
    private InlineObject inlineObject;
}

普通方法校验参数

除了验证接口,我们还可以验证普通的方法,首先在需要验证的方法所在类上面增加注解@Validated

@Validated
@Service
public class DemoService {
    public void demo(@Valid DemoDTO dto) {
    }
}

正常调用后返回:

{
    "code": 500,
    "msg": "这年龄不太对吧"
}

注意:

  1. 基于aop机制,被验证的方法需要注册为组件

  2. 只能在类上面增加注解@Validated,不能在单个方法上

  3. 抛出的异常为ConstraintViolationException,需要单独拦截,示例:

    @ResponseBody
    @ExceptionHandler(ConstraintViolationException.class)
    public HttpResponse handle(ConstraintViolationException e) {
        return HttpResponse.builder().code(500).msg(
            e.getConstraintViolations()
            .stream()
            .findFirst()
            .map(ConstraintViolation::getMessage)
            .orElse("参数校验失败")).build();
    }
    

分组验证

很多时候,新增和更新使用了用一个类,但是更新的时候要求id属性不能为空,而新增时必须为空,或者新增时无所谓是什么。

这时候,就需要根据情况,使用不同的校验规则,先定义新增和更新这两种情况分组:

public interface ValidationGroup {
    interface Create extends Default {}
    interface Update extends Default {}
}

注意:

  1. 只能定义为接口
  2. 需要继承javax.validation.groups.Default,否则没有加groups的其它约束将被视为其它分组

接着,定义两种情况需要验证的属性:

@NotNull(message = "更新时id必填", groups = ValidationGroup.Update.class)
private Integer id;

@NotNull(message = "新增时name必填", groups = ValidationGroup.Create.class)
private String name;

@NotNull(message = "任何情况age必填")
private Integer age;

再定义两种情况的接口:

@PostMapping("/create")
public String create(@Validated(ValidationGroup.Create.class) @RequestBody DemoDTO dto) {
    return dto.getName();
}

@PostMapping("/update")
public String update(@Validated(ValidationGroup.Update.class) @RequestBody DemoDTO dto) {
    return dto.getName();
}

这时候,我们用固定的入参,分别调用者两个接口,参数:

{
	"age": 12
}

两种情况输出:

{
    "code": 500,
    "msg": "新增时name必填"
}
{
    "code": 500,
    "msg": "更新时id必填"
}