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 |
| 被注释的元素必须是电子邮箱地址 | |
| @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校验是后端对请求数据的常用校验方式,当然发送请求前,需要先进行前端的校验逻辑后,前端校验通过后,才能发送给后端校验,两次校验,也保证了数据的安全性。