在web应用服务开发中,作为后端服务,需要处理各种各样的前端请求,在请求进行业务处理前,需要对请求中的参数进行合法性验证,很常用的一些验证,如:某个字段不能为空,字段长度不能超过某个阈值,邮箱字段必须符合邮箱格式等。
这些验证工作,都是一些通用的与业务关联不是很大的操作,在bean validation 中,也提供了一些通用的验证api,如如@NotNull、@NotBlank、@Min、@Max等,可以很方便地验证数据的有效性,相信很多老铁都有使用过。
使用这些注解可以简化参数校验逻辑的编写,将参数校验的逻辑集中在了具体的业务Bean上,而没有散落在controller,service中,让参数校验逻辑更加方便维护。同时,将参数校验逻辑与具体的业务进行解耦,使业务主流程逻辑更加清晰。
但是老铁们都知道这些与业务无关的参数校验通常是很简单的,复杂的参数校验逻辑往往是与业务关联比较密切的,需要依赖业务的上下文进行。对于这里验证的实现,很多老铁的解决方案又走了历史的老路:将校验逻辑分散到了各个controller和service中。真的是:把简单的留给框架,把复杂的留给自己,程序员版的买椟怀珠。
本篇文章将介绍如何自定义一个Validator,实现对具体业务场景的参数校验,同时会讲解一些,我们自定义的Validator是如何被bean validation检测并生效的。
自定义个一个validator需要一下4个步骤。
我们需要定义一个自定义注解来标记需要进行验证的字段。注解可以包含自定义的属性,用于指定验证规则的细节。例如,我们可以定义一个名为@CustomValidator的注解,并在其中定义一些属性,如错误消息、验证分组等。
@Documented
@Constraint(validatedBy = CustomValidatorImpl.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomValidator {
String message() default "Invalid value";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
我们需要实现javax.validation.Validator接口,该接口定义了验证器的基本方法。我们可以创建一个类,例如CustomValidatorImpl,并实现接口中的validate方法。在该方法中,我们可以编写自定义的验证逻辑,对传入的值进行验证。
public class CustomValidatorImpl implements ConstraintValidator<CustomValidator, String> {
@Override
public void initialize(CustomValidator constraintAnnotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 自定义验证逻辑
// 在这里编写验证逻辑,根据需求判断字段是否有效
// 返回 true 表示字段有效,返回 false 表示字段无效
return value != null && value.startsWith("custom");
}
}
在实现ConstraintValidator 接口时,需要定义两个泛型,这两个泛型决定了这个validator适用的场景,很多时候,我们自定义的validator没有生效,可能是这两个参数配置有问题。
在 CustomValidatorImpl 中,分别对应:CustomValidator
和 String
。
方法 isValid
是执行参数校验逻辑的关键方法,通过该方法的返回值,来决定校验是否通过。
在需要进行验证的实体类中,我们可以使用自定义注解来标记需要验证的字段。通过在字段上添加@CustomValidator注解,并设置相应的属性,我们可以将自定义验证规则应用于该字段。
a.先定义一个并使用上述validator对其内的字段进行校验的bean
public class User {
@CustomValidator(message = "Invalid value")
private String customField;
// 省略 getter 和 setter 方法
}
b.使用注解 @Valid 触发业务请求中的参数校验
@RestController
public class UserController {
@PostMapping("/users")
public String createUser(@Valid @RequestBody User user) {
// 处理用户创建逻辑
// 当请求到达该方法时,会自动触发验证过程
// 如果验证失败,会抛出 MethodArgumentNotValidException 异常
return "User created successfully";
}
}
如果验证失败,Spring Boot会自动处理错误信息。可以通过编写全局异常处理器或使用Spring Boot提供的默认异常处理机制来捕获和处理验证错误。这样,我们可以向用户返回友好的错误响应,指示哪些字段未通过验证。
@RestControllerAdvice
public class GlobalExceptionHandlerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
return e.getBindingResult().getFieldError().getDefaultMessage();
}
}
至此,一个完整的自定义Validator就实现完毕了,感兴趣的老铁,可以直接使用上述代码,然后结合自己的参数校验场景进行调整即可。
上面提供了一个完整的自定义Validator的步骤,不知道各位老铁,会不会有以下几个问题:
1.为何使用了 @CustomValidator注解后, CustomValidatorImpl 就会生效呢?
2.定义validator时的两个泛型参数又是如何起作用的呢?
3.MethodArgumentNotValidException异常从哪里抛出来的?
4.spring-boot-starter-validation 中实现了哪些validator
针对第一个问题,如何通过 注解 @CustomValidator 找到一个 CustomValidatorImpl?
细心的小伙伴已经发现了在定义 @CustomValidator 的时候,已经通过 @Constraint(validatedBy = CustomValidatorImpl.class) 指明了使用 CustomValidatorImpl 对 CustomValidator 进行校验。
没错,实际的查找过程的确也是基于这个配置进行的。但是你有没有发现 bean validation 中内建的一些注解,其实是没有使用 参数 validatedBy 实现与具体的Validator进行关联的,比如: @NotBlack,注解的定义如下:
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
public @interface NotBlank {
String message() default "{javax.validation.constraints.NotBlank.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* Defines several {@code @NotBlank} constraints on the same element.
*
* @see NotBlank
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}
那么它是如何找到实现具体参数校验的validator的呢?为了防止问题扩大,我们把这个问题记一下,先回到上面的问题,虽然自定义的注解可以通过 validatedBy 找到指定的 validator ,那么整个查询的过程是怎么样的呢?如果你能把下面的流程搞清楚的话,相信你对 bean validation的理解会更加深入。
下面我们结合具体的源码来了解一下整体的查询过程:
当收到请求后,spring mvc会将 http中的参数和具体的java bean进行绑定,在参数定的过程中,会启动参数校验。
具体参考类 「AbstractMessageConverterMethodArgumentResolver」:
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
binder.validate(validationHints);
break;
}
}
}
上面代码是 spring 进行参数绑定过程中的一段代码,其中包含了一个比较重要的判断逻辑:
如果需要进行参数绑定的bean使用了 @Validated 或者使用了以"Valid"开头的注解标识时,就是进行参数校验。
通过进行一路的方法跟进,来到一个重点的方法:「ConstraintHelper」类中的 「getDefaultValidatorDescriptors」:
private <A extends Annotation> List<ConstraintValidatorDescriptor<A>> getDefaultValidatorDescriptors(Class<A> annotationType) {
//safe cause all CV for a given annotation A are CV
final List<ConstraintValidatorDescriptor<A>> builtInValidators = (List<ConstraintValidatorDescriptor<A>>) builtinConstraints
.get( annotationType );
if ( builtInValidators != null ) {
return builtInValidators;
}
Class<? extends ConstraintValidator<A, ?>>[] validatedBy = (Class<? extends ConstraintValidator<A, ?>>[]) annotationType
.getAnnotation( Constraint.class )
.validatedBy();
return Stream.of( validatedBy )
.map( c -> ConstraintValidatorDescriptor.forClass( c, annotationType ) )
.collect( Collectors.collectingAndThen( Collectors.toList(), CollectionHelper::toImmutableList ) );
}
根据方法签名,可以得知,这个方法的作用是:根据 「annotationType」 来查询 「validator」,这个方法的实现很有意思:
1.根据注解 找到 内建的的validator,这个通常针对 内建的注解,也就是我们常用的 @NotBlack,@NotEmpty等,刚好也回答了上面的问题:对于内建的 校验注解,虽然没有指定的 validatedBy 去可以找到一个可以使用的 validator。
2.如果找到了 自建的Validator,方法就直接返回了。其实这也表明了,我们无法对 内建的注解支持的validator进行扩展。
3.对于自定的 验证注解,通过 validateBy来获取 validator,这个也就回答了上面的第一个问题。同时也回答了上面第二个问题中 注解泛型的作用。
在回答这个问题前,大家有没有发现这样一个问题:我们在使用 @NotBlack 或者 @NotEmpty的时候,这个注解可以用在多种不同类型的字段上,可以是String,List等一些我们类型上,但用在不同的字段类型的校验逻辑是不同的的,对于String,校验逻辑是判断字符本身是否为空,而对于List,则是判断集合中的是否有元素。
在回答第一个问题时,我们提到了内建注解,查找validator的过程,参考上面的代码,查找过程的重点是下面这一行代码:
final List<ConstraintValidatorDescriptor<A>> builtInValidators = (List<ConstraintValidatorDescriptor<A>>) builtinConstraints
.get( annotationType );
我们以 @NotEmpty为例,该注解绑定了多个Validator:
List<Class<? extends ConstraintValidator<NotEmpty, ?>>> notEmptyValidators = new ArrayList<>( 11 );
notEmptyValidators.add( NotEmptyValidatorForCharSequence.class );
notEmptyValidators.add( NotEmptyValidatorForCollection.class );
notEmptyValidators.add( NotEmptyValidatorForArray.class );
notEmptyValidators.add( NotEmptyValidatorForMap.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfBoolean.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfByte.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfChar.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfDouble.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfFloat.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfInt.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfLong.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfShort.class );
putConstraints( tmpConstraints, NotEmpty.class, notEmptyValidators );
我们常用的数据类型都在其中的,那么这么多 validator 该用哪个呢, 这个就是第二个泛型字段决定的了。
那么第二个泛型字段是如何起作用的呢?
老方法,看源码,重点看类 「ConstraintValidatorManager」 的 「findMatchingValidatorDescriptor」方法:
private <A extends Annotation> ConstraintValidatorDescriptor<A> findMatchingValidatorDescriptor(ConstraintDescriptorImpl<A> descriptor, Type validatedValueType) {
Map<Type, ConstraintValidatorDescriptor<A>> availableValidatorDescriptors = TypeHelper.getValidatorTypes(
descriptor.getAnnotationType(),
descriptor.getMatchingConstraintValidatorDescriptors()
);
List<Type> discoveredSuitableTypes = findSuitableValidatorTypes( validatedValueType, availableValidatorDescriptors.keySet() );
resolveAssignableTypes( discoveredSuitableTypes );
if ( discoveredSuitableTypes.size() == 0 ) {
return null;
}
if ( discoveredSuitableTypes.size() > 1 ) {
throw LOG.getMoreThanOneValidatorFoundForTypeException( validatedValueType, discoveredSuitableTypes );
}
Type suitableType = discoveredSuitableTypes.get( 0 );
return availableValidatorDescriptors.get( suitableType );
}
在这个方法中,第一个参数是 「参数验证注解绑定的validator集合的封装」,第二个参数为具体的「被校验字段的类型」。根据字段类型从候选的validator中找到一个合适的校验器完成对参数的校验。
上文中我们知道了,validation api中提供了很多让我们可以开箱即用的注解,可以满足我们对通用字段验证的需求,那么在现有的框架中,一共支持哪些可以直接使用的validator呢?
我们以 hibernate-validator 对 validation api实现为例(我们使用的 spring-boot-starter-validation 就是基于 hibernate-validator 实现的。这里多说一点,JSR-303是JAVA EE6中的一项子规范,validation-api是一套标准(JSR-303),叫做Bean Validation,Hibernate Validator是Bean Validation的参考实现,提供了JSR-303 规范中所有内置constraint的实现)
「hibernate」对bean validation的实现主要集中在类 「ConstraintHelper」的构造方法中,感兴趣的老铁可以参考一下。
比如我们常用的判断字符串不为空的validator实现如下:
public class NotEmptyValidatorForCharSequence implements ConstraintValidator<NotEmpty, CharSequence> {
@Override
public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
if ( charSequence == null ) {
return false;
}
return charSequence.length() > 0;
}
}
验证集合不为空的 validator 实现如下:
public class NotEmptyValidatorForCollection implements ConstraintValidator<NotEmpty, Collection> {
@Override
public boolean isValid(Collection collection, ConstraintValidatorContext constraintValidatorContext) {
if ( collection == null ) {
return false;
}
return collection.size() > 0;
}
}
使用validator进行参数验证后,如果验证不通过,也就是意味着 spring mvc的参数绑定操作是存在错误的,此时就会抛出 MethodArgumentNotValidException 异常,具体看代码如下:
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) { // 参数绑定异常,异常抛出
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
此时我们可以通过自定义一个全局的异常处理器,完成对此类异常的捕获处理即可,具体如下:
@RestControllerAdvice
public class GlobalExceptionHandlerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
xxx
}
}
看完以上内容,希望你能够加深你对 bean validation的理解,可以在工作用起来,通过自定义validator来优化参数校验逻辑,是代码逻辑更加的清晰。如果改文章对你有帮助,欢迎点赞分享。