JSR303校验

181 阅读4分钟

JSR303校验

前端表单提交数据的时候,需要进行一轮数据校验,以保证数据的合法,但是如果绕过前端给后端发送请求(Postman等),就无法保证数据的合法性了,那么,后端对提交的数据进行校验是十分重要的。

通常我们采取JSR303校验法,面向注解校验,提高开发效率。

引入依赖

注意:需要指定starter的版本

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>2.6.6</version>
</dependency>

导入依赖后,查看jar包中的注解包,发现了有很多校验的注解:

知名知意:就知道注解的大概意思了。

咱们只需要在需要校验的Bean字段上加上相应的约束注解

@Data
@TableName("pms_brand")
@AllArgsConstructor
@NoArgsConstructor
public class BrandEntity implements Serializable {
    private static final long serialVersionUID = 1L;
​
    @TableId
    private Long brandId;
​
    @NotBlank(message = "品牌名必须提交")
    private String name;
​
    @URL(message = "logo必须合法")
    @NotBlank(message = "logo不能为空")
    private String logo;
​
    private String descript;
​
    private Integer showStatus;
​
    @Pattern(regexp = "^[a-zA-Z]$",message = "首字母必须a-z或A-Z之间")
    @NotEmpty
    private String firstLetter;
​
    @Min(value = 0,message = "排序必须大于等于0")
    @NotNull
    private Integer sort;
}

以上的注解都是对特定字段加以了限制。

常用的校验注解

同样,注解也具有一些属性值,比如上面的例子中message指定了报错的提示信息,regexp指定的是满足的正则表达式。

我整理了一些常用的校验注解

Bean Validation 中内置的 constraint:

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

Hibernate Validator 附加的 constraint:

注释解释
@NotBlank验证字符串非null,且长度必须大于0
@Email被注释的元素必须是电子邮箱地址
@Length(min=,max=)被注释的字符串的大小必须在指定的范围内[min,max]
@NotEmpty被注释的字符串必须非空
@Range(min=,max=,message=)被注释的元素必须在合适的范围内

注意:其中一些注解需要注意一下@NotNull,@NotBlank,@NotEmpty的区别。

@NotNull,@NotBlank,@NotEmpty的区别

  • @NotEmpty :不能为null,且Size>0,@NotEmpty注解的String、Collection、Map、数组是不能为null或长度为0
  • @NotNull :不能为null,但可以为empty,没有Size的约束,带注释的元素不能为null。接受任何类型
  • @NotBlank :只用于String,不能为null且trim()之后size>0,纯空格的String也是不符合规则的,此注解只能用于验证String类型

限制了Bean的字段约束,当然还需要再Controller的接收表单上加以注解,提示需要校验的类。

@Valid

放在参数对象上,提示需要:该对象需要被校验

@RequestMapping("/update")
public R update(@Valid @RequestBody BrandEntity brand){
    brandService.updateById(brand);
    return R.ok();
}

加入了@Valid后,传来的BrandEntity类就会被校验,校验的标准就是之前类字段上的那些约束,违背了约束,就会抛出异常。

那么,抛出了异常,异常类型是MethodArgumentNotValidException,知道了抛出的异常,当然还需要提示用户,哪些地方出现了问题,也就是之前注解上的Message显示。

异常信息显示

可以在约束的对象后紧跟一个参数 BindingResult bindingResult,这个对象中存储了异常的细化信息。

通过BindingResult对象,我们就可以知道所有的异常信息了,可以封装起来返回给前端,使异常更加清晰。

    @RequestMapping("/save")
    public R save(@Valid @RequestBody BrandEntity brand,BindingResult result){
        if(result.hasErrors()) {
            //1.获取校验的结果集
            List<FieldError> fieldErrors = result.getFieldErrors();
            Map<String,String>map = new HashMap<>();
            for (FieldError item : fieldErrors) {
                //获取错误提示信息
                String message = item.getDefaultMessage();
                //获取错误的属性名
                String field = item.getField();
                map.put(field,message);
            }
            return R.error(400,"提交数据不合法").put("data",map);
        }else {
            brandService.save(brand);
        }
        return R.ok();
    }

这样就可以通过特定的返回码和data体,更细化的表达了异常信息,dddd。

返回异常信息就类似这样了,更加清晰明了:

那么问题来了,未必,我们每次校验后,都得繁琐的写这些异常获取和返回吗,有没有一种方法可以不写呢?

当然有,就是全局异常统一处理,听着都不陌生,之前学了,就在这记一笔吧。

全局异常统一处理

顾名思义,就是将全局的异常都交给一个地方统一处理,省去了业务中每次都要处理异常的繁琐,也提高了代码的可读性和可维护性。

@RestControllerAdvice

这个注解,是全局异常处理的核心,标注在类上,声明这是一个全局异常处理类,所有的类就会经过他来处理。

@Slf4j
//basePackages说明要处理那个包下的异常
@RestControllerAdvice(basePackages = "com.gulimall.product.controller")
public class GulimallExceptionControllerAdvice {
​
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public R handleValidException(MethodArgumentNotValidException e) {
        Map<String, String> map = new HashMap<>();
        //获取 BindingResult
        BindingResult bindingResult = e.getBindingResult();
        //获取异常集合并遍历将异常信息存入Map
        bindingResult.getFieldErrors().forEach((fieldError) -> {
            map.put(fieldError.getField(), fieldError.getDefaultMessage());
        });
        return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(),BizCodeEnum.VALID_EXCEPTION.getMsg()).put("data",map);
    }
​
    @ExceptionHandler(value = Throwable.class)
    public R handleValidException(Throwable throwable) {
        return R.error(BizCodeEnum.UNKOWN_EXCEPTION.getCode(),BizCodeEnum.UNKOWN_EXCEPTION.getMsg());
    }
}
​

之前说了校验抛出的是MethodArgumentNotValidException异常,直接交到全局异常处理类来处理,代码跟之前Controller里的一模一样,只不过,写一遍,等于写了一万遍,之后就不用在每个Controller里面都写一遍了。Controller只剩下这几行代码了,非常简洁。

    @RequestMapping("/save")
    public R save(@Valid @RequestBody BrandEntity brand){
        brandService.save(brand);
        return R.ok();
    }

说到这,应该都懂了全局异常怎么用,其实就一个注解,一个类,标识要处理的异常类,把处理内容写进去就行了,很简单。

那么问题又来了:我们约束的字段,约束条件很固定,那么,存在这样一种情况,修改(Update)情况下,类的某个字段可以不填,但增加(Add)情况下,这个字段又要非空,字段的约束条件是跟着Controller的变化而变化的,怎么解决呢?

有问题,就有解决方法,就引出了分组校验的思想。

分组校验

@Validated

跟 **@Valid注解类似,但更强大,它声明了这是分组校验,可以添加一个groups**属性,声明是运用哪一组的校验方法。

同样,看看Bean字段约束相比于之前的变化:

@Data
@TableName("pms_brand")
@AllArgsConstructor
@NoArgsConstructor
public class BrandEntity implements Serializable {
    private static final long serialVersionUID = 1L;
​
    @NotNull(message = "修改必须指定品牌id", groups = {UpdateGroup.class})
    @Null(message = "新增不能指定品牌id", groups = {AddGroup.class})
    @TableId
    private Long brandId;
​
    @NotBlank(message = "品牌名必须提交", groups = {AddGroup.class, UpdateGroup.class})
    private String name;
​
    @URL(message = "logo必须合法",groups = {AddGroup.class, UpdateGroup.class})
    @NotBlank(groups = {AddGroup.class})
    private String logo;
​
    private String descript;
​
    private Integer showStatus;
​
    @Pattern(regexp = "^[a-zA-Z]$",message = "首字母必须a-z或A-Z之间",groups = {UpdateGroup.class, AddGroup.class})
    @NotEmpty(groups = {AddGroup.class})
    private String firstLetter;
​
    @Min(value = 0,message = "排序必须大于等于0",groups = {UpdateGroup.class,AddGroup.class})
    @NotNull(groups = {UpdateGroup.class})
    private Integer sort;
}
​

可以看到,注解中,都加上了groups属性,就声明了,这个约束在哪个组中生效,传入的是一个Class集合,元素是一个个分组接口,需要我们自己声明,但这个接口其实就是一种声明而已,并没有实际作用,一个接口就是一个组。

public interface AddGroup {
}
public interface UpdateGroup {
}

同样,需要在Controller中说明参数对象是需要用哪个组进行约束:

@RestController
@RequestMapping("product/brand")
public class BrandController {
    @Autowired
    private BrandService brandService;

    @RequestMapping("/save")
    public R save(@Validated(AddGroup.class) @RequestBody BrandEntity brand){
        brandService.save(brand);
        return R.ok();
    }

    @RequestMapping("/update")
    public R update(@Validated(UpdateGroup.class)@RequestBody BrandEntity brand){
		brandService.updateById(brand);

        return R.ok();
    }
}

可以看到第一个接口和第二个接口参数对象都是BrandEntity类,但是使用了不同的分组校验后,校验方式就完全不同了,校验方式就跟Bean字段的约束对应了,什么校验组,就用什么校验方式。

注意:用@Validated标识后,如果某个字段有校验信息但没有分组约束,默认不生效

还算简单吧,都是为了简化开发。

返回状态封装

实际开发中,返回给前端的都是一个{code,msg,data}的对象数据,更加统一,也更清晰。开发者就可以通过手册差到什么样的异常对应什么样的处理方式。

  • code:返回状态码。
  • msg: 状态码对应的信息。
  • data:返回的数据封装。

返回类通常封装成一个枚举类,其中列举每一种信息就行了,用个简单的例子说明一下就行了。

public enum BizCodeEnum {

    UNKOWN_EXCEPTION(10000,"系统未知异常"),
    VALID_EXCEPTION(10001, "参数校验错误");

    private int code;
    private String msg;
    BizCodeEnum(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
    public int getCode() {
        return code;
    }
    public void setCode(int code) {
        this.code = code;
    }
    public void setMsg(String msg) {
        this.msg = msg;
    }
    public String getMsg() {
        return msg;
    }
}

之后我们就只需要BizCodeEnum.UNKOWN_EXCEPTION的方式获取状态码和msg了,返回就得到了统一,标准了开发规范。

自定义校验注解

有时候,基本的校验注解满足不了我们对属性字段的约束,那么,我们就需要自定义注解来实现相应的约束功能。

自定义,当然是得模仿提供的约束注解来写,就以@NotNull为例

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

    //1.错误提示信息来自javax.validation.constraints.NotNull.message
	String message() default "{javax.validation.constraints.NotNull.message}";
    //2.分组信息
	Class<?>[] groups() default { };
	//3.携带的载体信息
	Class<? extends Payload>[] payload() default { };

	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
	@Retention(RUNTIME)
	@Documented
	@interface List {
		NotNull[] value();
	}
}

参照这个注解,编写自定义注解,来约束显示状态字段ShowStatus

编写校验注解

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { ListValueConstraintValidator.class})
public @interface ListValue {
    String message() default "{com.common.valid.ListValue.message}";

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

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

    //咱们要约束的限制值集合
    int[] vals() default { };
}

可以看到咱们@Constraint中添加了一个ListValueConstraintValidator类,这个类是一个校验类,说明了咱们的注解是按照什么样的校验方式校验的。

点进@Constraint,我们可以看到源码:

@Documented
@Target({ ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface Constraint {

	//传入的类必须继承ConstraintValidator,代表该类是一个校验类
	Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}
public interface ConstraintValidator<A extends Annotation, T> {
	//初始化方法
	default void initialize(A constraintAnnotation) {
	}
	//是否校验成功
    //value: 要校验的值
    //context: 校验的上下文环境
	boolean isValid(T value, ConstraintValidatorContext context);
}

那么,依葫芦画瓢,编写自己的校验类吧

编写自己的校验类

public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {
    private Set<Integer> set = new HashSet<>();
    //判断是否校验成功
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        //set中存在value就校验通过了
        return set.contains(value);
    }
    //初始化方法
    @Override
    public void initialize(ListValue constraintAnnotation) {
        //constraintAnnotation.vals() 获取约束集合 --> set
        for (int val : constraintAnnotation.vals()) {
            set.add(val);
        }
    }
}

绑定注解到相应的校验类

就是通过@Constraint来绑定就行了。

@Constraint(validatedBy = { ListValueConstraintValidator.class})

注意:validatedBy中的值是一个集合,就代表这,咱们的注解可以适配多个校验器。

添加约束

自定义了注解后,就像之前使用包注解一样使用就行了,非常简单。

@Data
@TableName("pms_brand")
@AllArgsConstructor
@NoArgsConstructor
public class BrandEntity implements Serializable {
	@NotNull(groups = {AddGroup.class, UpdateStatusGroup.class})
	@ListValue(vals = {0,1},groups = {AddGroup.class, UpdateStatusGroup.class})
	private Integer showStatus;  
}

总结

JSR303校验是后端对请求数据的常用校验方式,当然发送请求前,需要先进行前端的校验逻辑后,前端校验通过后,才能发送给后端校验,两次校验,也保证了数据的安全性。