简介
Spring Validation
是在Spring Context
包下的,在Spring Boot
项目中,我们引入spring-boot-starter-web
便会引入进来,Spring Validation
是对Hibernate Validator
的二次封装,使我们可以更方便的在Spring MVC
中完成自动校验。
Hibernate Validator
是对JSR-303(Bean Validation)
的参考实现。Hibernate Validator
提供了JSR-303
规范中所有内置constraint
的实现,除此之外还有一些附加的constraint
。
JSR-303
定义的constraint
:
Constraint | Description |
---|---|
@Null | 被注解的元素必须为null |
@NotNull | 被注解的元素必须不为null |
@AssertTure | 被注解的元素必须为ture |
@AssertFalse | 被注解的元素必须为false |
@Min(value) | 被注解的元素必须是数字且必须大于等于指定值 |
@Max(value) | 被注解的元素必须是数字且必须小于等于指定值 |
@DecimalMin(value) | 被注解的元素必须是数字且必须大于等于指定值 |
@DecimalMax(value) | 被注解的元素必须是数字且必须小于等于指定值 |
@Size(max, min) | 被注解的元素必须在指定的范围内 |
@Digits(integer, fraction) | 被注解的元素必须是数字且其值必须在给定的范围内 |
@Past | 被注解的元素必须是一个过去的日期 |
@Future | 被注解的元素必须是一个将来的日期 |
@Pattern(value) | 被注解的元素必须符合给定正则表达式 |
Hibernate Validator
附加实现的constraint
Constraint | Description |
---|---|
被注解的元素必须是Email 地址 | |
@Length(min, max) | 被注解的元素长度必须在指定的范围内 |
@NotEmpty | 被注解的元素必须非空 |
@Range | 被注解的元素(可以是数字或者表示数字的字符串)必须在给定的范围内 |
@URL | 被注解的元素必须是URL |
当然,我们也可以自定义实现,自定义实现在下面使用中在讲吧。
使用
首先是引入依赖,在Spring Boot
项目中,我们引入web
就已经可以使用了,这里就不再具体赘述了。
使用@Validated注解拦截校验
先定义下要校验的实体吧:
public class User {
@Length(min = 1, max = 22, message = "name字段不合法")
private String name;
@Min(value = 1, message = "age字段不合法")
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
在Controller
中,我们需要校验前端传递过来的参数,我们可以这么写
@RestController
public class TestController {
@PostMapping("/test")
public Object test(@RequestBody @Validated User user, BindingResult result) {
if (result.hasErrors()) {
return result.getAllErrors().stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.toList());
}
return user;
}
}
只需要在需要校验的实体前面打上@Validated
注解就可以了,这时候,如果我们传递的参数符合要求,则会正常返回。否则返回:
[
"age字段不合法",
"name字段不合法"
]
它会将我们所有不合法信息一次性全部返回,在日常开发中,我们可以吧校验BindingResult
是否有错误信息的校验统一抽出到一个工具类中去做处理,使用项目中统一格式返回错误信息就好。这就是一个最简单的校验示例了,其他注解也都是类似的,就不多举例了,可以自己尝试着玩玩。
在日常开发中想必都曾遇到过这样的需求,比如这个age
这个字段,我想要这个字段只在PC
端校验,在App
端不做限制,这就需要用到分组校验了,每个注解都提供了一个group
属性,利用这个属性就可以轻易做到以上需求。比如在User
上的注解中加入group
属性,指定其被校验的group
:
public class User {
@Length(min = 1, max = 22, message = "name字段不合法", groups = {App.class, PC.class})
private String name;
@Min(value = 1, message = "age字段不合法", groups = PC.class)
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
在Controller
中的@Validated
中指定当前group
:
@RestController
public class TestController {
@PostMapping("/test")
public Object test(@RequestBody @Validated(App.class) User user, BindingResult result) {
if (result.hasErrors()) {
return result.getAllErrors().stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.toList());
}
return user;
}
}
这时候我再使用两个不合法字段访问返回:
[
"name字段不合法"
]
可以看到,它并没有对age
字段进行校验。这就是它的分组校验。
在方法实现中拦截校验
它不只是在Controller
校验前端传递过来的参数的时候可以用,它在方法中同样可以用,我们可以这样来使用:
@RestController
public class TestController {
@Autowired
ObjectMapper objectMapper;
@Autowired
SmartValidator smartValidator;
@GetMapping("/test")
public Object test() {
String context = "{\"name\": \"felixu\",\"age\": 0}";
User user = null;
try {
user = objectMapper.readValue(context, User.class);
} catch (IOException e) {
e.printStackTrace();
}
BeanPropertyBindingResult result = new BeanPropertyBindingResult(user, "user");
smartValidator.validate(user, result);
if (result.hasErrors()) {
return result.getAllErrors().stream()
.map(ObjectError::getDefaultMessage)
.collect(Collectors.toList());
}
return user;
}
}
使用需要被校验的实体构造BeanPropertyBindingResult
对象,然后将传递给SmartValidator
的validate
方法来完成跟上面相同的校验。validate
有个重载方法,也接收分组,所以这种方式同样可以实现分组校验。
自定义实现
需求总是多变的,有时候,可能上面的校验方式并不能满足我们的要求,这时候就需要我们自定义一下校验了,要做到自定义注解来校验,我们需要做以下两步,首先实现ConstraintValidator<A extends Annotation, T>
(ps
:原谅我的自恋。。。不要管我干了啥,关键是要知道可以用来干啥对不对,哈哈哈哈):
public class IsFelixuValidator implements ConstraintValidator<IsFelixu, String> {
@Override
public void initialize(IsFelixu annotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if ("felixu".equals(value)) {
return true;
}
return false;
}
}
isValid
便是我们的校验逻辑,true
为通过校验。
然后我们实现注解:
@Documented
@Constraint(
// 指定对应的校验类
validatedBy = {IsFelixuValidator.class}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsFelixu {
String message() default "this value is not felixu";
// 这两个属性必须要存在
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
这样就ok
了,我们继续使用之前的来做测试,在User
的name
属性上加上@IsFelixu
注解,此时测试,如果不传递name
为felixu
的值,则会提示如下信息:
[
"this value is not felixu",
"age字段不合法"
]
这个多多少少看着有点沙雕,我决定拿个别的举例了,先看注解
@Documented
@Constraint(
validatedBy = {PrecisionValidator.class}
)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Precision {
String message() default "精度不符合要求";
// 最少小数位
int min() default 0;
// 最多小数位
int max() default Integer.MAX_VALUE;
// 是否固定小数位,固定多少位,-1 为不固定
int fixed() default -1;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
emmmm
,这是个校验精度的,我们可以看到它有好几个属性,比如min
啊、max
的之类的,就是为了标注当前值要最小几位小数,最多多少位小数。
下面我们再来看看是怎么实现校验的:
public class PrecisionValidator implements ConstraintValidator<Precision, Object> {
// Hibernate validator 自带的一些错误提示
private static final Log LOG = LoggerFactory.make(MethodHandles.lookup());
private int min;
private int max;
private int fixed;
// 项目启动时,此方法便会被调用,可以拿到注解中各属性的值,用以做合法性校验
// 比如这里就检查了这三个属性的合法性
@Override
public void initialize(Precision precision) {
min = precision.min();
max = precision.max();
fixed = precision.fixed();
validateParameters();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
// 数字转字符串
if (value instanceof Number)
value = String.valueOf(value);
if (value instanceof CharSequence) {
// 这是自己的一个工具类,可以不用管,就是把 value 以 . 切割成数组
List<String> vals = Splitters.DOT.splitToList((String) value);
// 如果只有 1 位,说明没有小数位,直接放行
if (vals.size() == 1)
return true;
// 不是 2 位说明参数是瞎特么传的
if (vals.size() != 2)
return false;
// 获取小数位的长度
int length = vals.get(1).length();
// 判断有没有指定固定小数位, -1 认为是没有
if (fixed != -1)
return length == fixed;
// 判断长度是否在范围内
return length >= min && length <= max;
}
return false;
}
// 注解属性值校验方法
private void validateParameters() {
if (min < 0)
throw LOG.getMinCannotBeNegativeException();
if (max < 0)
throw LOG.getMaxCannotBeNegativeException();
if (max < min)
throw LOG.getLengthCannotBeNegativeException();
if (fixed < 0 && fixed != -1)
throw new IllegalArgumentException("The fixed cannot be negative.");
}
}
之前实现那个沙雕注解的时候有些方法没有详细介绍,可以看一下上面这个实现中的注释。当时这个是为了满足产品的一个沙雕要求,正好拿出来举个🌰。
补充说明
在上面举例中,我在controller
中注入了BindingResult
来获取错误信息,可以将错误信息封装到一个工具类中来统一返回,例如下面代码中的onValidFail
方法,将错误信息封装到统一返回中
public class RespDTO<T> implements Serializable{
public int code;
public String error;
public T data;
public static <T> RespDTO<T> onSuc() {
return onSuc(null);
}
public static <T> RespDTO<T> onSuc(T data) {
return build(ErrorCode.OK.getCode(), ErrorCode.OK.getMsg(), data);
}
public static <T> RespDTO<T> onValidFail(BindingResult result) {
String errorMsg = result.getAllErrors()
.stream()
.map(objectError -> {
FieldError error = (FieldError) objectError;
return error.getField() + ", " + error.getDefaultMessage();
})
.collect(Collectors.joining("\n"));
return build(ErrorCode.PARAM_ERROR.getCode(), errorMsg, null);
}
public static <T> RespDTO<T> onFail(ErrorCode errorCode) {
return onFail(errorCode.getCode(), errorCode.getMsg());
}
public static <T> RespDTO<T> onFail(int code, String msg) {
return build(code, msg, null);
}
private static <T> RespDTO<T> build(int ret, String msg, T data) {
return new RespDTO<T>(ret, msg, data);
}
}
当然我们也可以不去注入BindingResult
,而直接使用注解,这样校验失败就会抛出异常,再由我们自己的统一异常拦截去拦截,之后再处理成统一返回,这样也是可以的,比如下面这样写controller
public class DemoController {
@PostMapping
public RespDTO<Boolean> create(@Validated({Create.class, Default.class}) @RequestBody RoutineInfo routineInfo) {
Account account = accountService.getDefaultAccount(routineInfo.getUserId());
routineInfo.setAccountId(account.getId());
return RespDTO.onSuc(routineInfoService.save(routineInfo));
}
}
然后定义异常拦截去拦截:
@RestControllerAdvice
public class DemoExceptionHandler {
@ExceptionHandler(BindException.class)
public ResponseEntity<RespDTO<Object>> handleBindException(BindException e) {
return new ResponseEntity<>(RespDTO.onValidFail(e), HttpStatus.OK);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<RespDTO<Object>> methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
return new ResponseEntity<>(RespDTO.onValidFail(e.getBindingResult()), HttpStatus.OK);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<RespDTO<Object>> validationExceptionHandler(ConstraintViolationException e) {
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
Map<String, String> result = new HashMap<>(violations.size());
for (ConstraintViolation<?> violation : violations) {
String fieldName = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
result.put(fieldName, violation.getMessage());
}
BindException exception = new BindException(e, "exception");
result.forEach((key, value) -> exception.addError(new FieldError(exception.getObjectName(), key, value)));
return new ResponseEntity<>(RespDTO.onValidFail(exception.getBindingResult()), HttpStatus.OK);
}
}
这样校验失败抛出的异常便会被统一的异常拦截器拦截到,然后被处理成统一返回,返回给前端了。
结语
JSR-303
的发布使得在数据自动绑定和验证变得简单,使开发人员在定义数据模型时不必考虑实现框架的限制。当然Bean Validation
还只是提供了一些最基本的constraint
。
上面只是相对简单的用法,在实际的开发过程中,用户可以根据自己的需要组合或开发出更加复杂的constraint
。这就需要想象力了,从上面的用法中应该可以想到很多地方可以去使用,但是设计和实现时,往往需要考虑诸多因素,比如易用性和封装的复杂度,等等方面,还需要自己去考量了。