SpringBoot參數(shù)校驗及原理全面解析
前言
平時服務(wù)端開發(fā)過程中,不可避免的需要對接口參數(shù)進(jìn)行校驗,比較常見的比如用戶名不能為空、年齡必須大于0、郵箱格式要合規(guī)等等。
如果通過if else去校驗參數(shù),校驗代碼會跟業(yè)務(wù)耦合,且顯得很冗長。
SpringBoot提供了一種簡潔、高效的方式,通過@Validated/@Valid注解來做參數(shù)校驗,大大提高了工作效率
一、基本用法
總共三種方式:
- Controller的@RequestBody參數(shù)校驗
- Controller的@RequestParam/@PathVariable參數(shù)校驗
- 編程式校驗,直接調(diào)用hibernate的validate方法
三種方式都需要加上以下依賴。里面有所需的jakarta.validation-api和hibernate-validator包
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
@RequestBody參數(shù)校驗
該方式適用于Controller中POST/PUT方法的參數(shù)校驗,校驗失敗會拋MethodArgumentNotValidException
1.首先在參數(shù)類的屬性上聲明約束注解,比如@NotBlank、@Email等
@Data public class UserVo implements Serializable { @NotBlank(message = "名字不能為空") @Size(min = 2, max = 50, message = "名字長度的范圍為2~50") private String name; @Email(message = "郵箱格式不對") private String email; @NotNull(message = "年齡不能為空") @Min(18) @Max(100) private Integer age; @NotEmpty(message = "照片不能為空") private List<String> photoList; }
2.接著在Controller方法@RequestBody旁加上@Validated注解
@Slf4j @RestController public class UserController { @ApiOperation("保存用戶") @PostMapping("/save/user") public Result<Boolean> saveUser(@RequestBody @Validated UserVo user) { return Result.ok(); } }
@RequestParam/@PathVariable參數(shù)校驗
該方式適用于Controller中GET方法的參數(shù)校驗,校驗失敗會拋ConstraintViolationException。它是通過類上加@Validated注解,方法參數(shù)前加@NotBlank等約束注解的方式來實現(xiàn)的,所以其它Spring Bean的方法也適用
1.Controller類上加@Validated注解;@RequestParam/@PathVariable旁加上@NotBlank、@Max等注解
@Slf4j @RestController @Validated public class UserController { @ApiOperation("查詢用戶") @GetMapping("/list/user") public Result<List<UserVo>> listUser( @Min(value = 100, message = "id不能小于100") @RequestParam("id") Long id, @NotBlank(message = "名稱不能為空") @RequestParam("name") String name, @Max(value = 90, message = "年齡不能大于90") @RequestParam("age") Integer age) { List<UserVo> list = new ArrayList<>(); return Result.ok(list); } }
編程式校驗
該方式適用于Service參數(shù)的校驗,校驗失敗手動拋ValidationException
1.通過@bean注解初始化Validator對象
public class ValidatorConfig { @Bean public Validator validator() { return Validation.byProvider(HibernateValidator.class) .configure() // 快速失敗模式 .failFast(true) .buildValidatorFactory() .getValidator(); } }
2.在Service方法中調(diào)用hibernate的validate方法對參數(shù)進(jìn)行校驗
@Service @Slf4j public class UserService { @Autowired private Validator validator; public boolean editUser(UserVo user) { Set<ConstraintViolation<UserVo>> validateSet = validator.validate(user); if (CollectionUtils.isNotEmpty(validateSet)) { StringBuilder errorMessage = new StringBuilder(); for (ConstraintViolation<UserVo> violation : validateSet) { errorMessage.append("[").append(violation.getPropertyPath().toString()).append("]") .append(violation.getMessage()).append(";"); } throw new ValidationException(errorMessage.toString()); } return Boolean.TRUE; } }
二、進(jìn)階用法
自定義驗證注解
jakarta.validation-api和hibernate-validator包中內(nèi)置的注解有些場景可能不支持,比如添加用戶時,需要校驗用戶名是否重復(fù),這時可以通過自定義注解來實現(xiàn)
1.首先自定義注解
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) @Retention(RUNTIME) @Documented @Repeatable(UniqueName.List.class) @Constraint(validatedBy = {UniqueNameValidator.class}) public @interface UniqueName { String message() default "用戶名重復(fù)了"; // 分組 Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) @Retention(RUNTIME) @Documented public @interface List { UniqueName[] value(); } }
2.接著給自定義注解添加驗證器
- 實現(xiàn)ConstraintValidator接口,并指定自定義注解<UniqueName>和驗證的數(shù)據(jù)類型 <String>
- 重寫isValid方法,實現(xiàn)驗證邏輯
@Component public class UniqueNameValidator implements ConstraintValidator<UniqueName, String> { @Autowired private UserService userService; @Override public boolean isValid(String name, ConstraintValidatorContext context) { if (StringUtils.isBlank(name)) { return true; } UserVo user = userService.getByName(name); if (user == null) { return true; } return false; } }
3.使用自定義注解
@Data public class UserVo implements Serializable { @UniqueName private String name; }
多屬性聯(lián)合校驗
當(dāng)一個字段的校驗依賴另一個字段的值時,需要用到多屬性聯(lián)合校驗,或者叫分組校驗。
舉個例子,某個系統(tǒng)提交用戶信息時需要做校驗,當(dāng)性別為女時,照片信息不能為空。這時,照片信息能否為空,依賴于性別的取值。
hibernate-validator提供了DefaultGroupSequenceProvider接口供我們自定義分組,具體使用如下:
1.首先定義兩個組,Boy和Girl
public interface Boy { } public interface Girl { }
2.分組邏輯實現(xiàn),當(dāng)性別為女時,將用戶分到Girl組
public class CustomGroupSequenceProvider implements DefaultGroupSequenceProvider<UserVo> { @Override public List<Class<?>> getValidationGroups(UserVo user) { List<Class<?>> defaultGroupSequence = new ArrayList<>(); defaultGroupSequence.add(UserVo.class); if (user != null) { String sex = user.getSex(); if ("女".equals(sex)) { defaultGroupSequence.add(Girl.class); } } return defaultGroupSequence; } }
3.使用分組校驗photoList字段
- 實體類上添加@GroupSequenceProvider(CustomSequenceProvider.class)注解
- 字段上添加@NotEmpty(message = "性別為女時照片不能為空", groups = {Girl.class})注解
@Data @GroupSequenceProvider(CustomSequenceProvider.class) public class UserVo implements Serializable { @NotBlank(message = "性別不能為空") private String sex; @NotEmpty(message = "性別為女時照片不能為空", groups = {Girl.class}) private List<String> photoList; }
嵌套校驗
當(dāng)VO對象中存在對象屬性需要校驗時,可以使用嵌套校驗,
1.在對象屬性上加@Valid注解
@Data public class UserVo implements Serializable { @Valid @NotNull(message = "地址不能為空") private Address address; }
2.然后在內(nèi)嵌對象中聲明約束注解
@Data public class Address implements Serializable { @NotBlank(message = "地址名稱不能為空") private String name; private String longitude; private String latitude; }
三、實現(xiàn)原理
@RequestBody參數(shù)校驗實現(xiàn)原理
所有@RequestBody注釋的參數(shù)都要經(jīng)過RequestResponseBodyMethodProcessor類處理,該類主要用于解析@RequestBody注釋方法的參數(shù),以及處理@ResponseBody注釋方法的返回值。其中,resolveArgument()方法是解析@RequestBody注釋參數(shù)的入口
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor { @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); } }
resolveArgument方法中的validateIfApplicable(binder, parameter)會對帶有@valid/@validate注解的參數(shù)進(jìn)行校驗
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) { Annotation[] annotations = parameter.getParameterAnnotations(); for (Annotation ann : annotations) { Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann); if (validationHints != null) { binder.validate(validationHints); break; } } } //會對@Validated注解或者@Valid開頭的注解進(jìn)行校驗 public static Object[] determineValidationHints(Annotation ann) { Class<? extends Annotation> annotationType = ann.annotationType(); String annotationName = annotationType.getName(); if ("javax.validation.Valid".equals(annotationName)) { return EMPTY_OBJECT_ARRAY; } Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class); if (validatedAnn != null) { Object hints = validatedAnn.value(); return convertValidationHints(hints); } if (annotationType.getSimpleName().startsWith("Valid")) { Object hints = AnnotationUtils.getValue(ann); return convertValidationHints(hints); } return null; }
Spring通過一圈適配轉(zhuǎn)換后,會把參數(shù)校驗邏輯落到hibernate-validator中,在ValidatorImpl#validate(T object, Class<?>... groups)中做校驗
public class ValidatorImpl implements Validator, ExecutableValidator { @Override public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) { Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() ); sanityCheckGroups( groups ); @SuppressWarnings("unchecked") Class<T> rootBeanClass = (Class<T>) object.getClass(); BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass ); if ( !rootBeanMetaData.hasConstraints() ) { return Collections.emptySet(); } BaseBeanValidationContext<T> validationContext = getValidationContextBuilder().forValidate( rootBeanClass, rootBeanMetaData, object ); ValidationOrder validationOrder = determineGroupValidationOrder( groups ); BeanValueContext<?, Object> valueContext = ValueContexts.getLocalExecutionContextForBean( validatorScopedContext.getParameterNameProvider(), object, validationContext.getRootBeanMetaData(), PathImpl.createRootPath() ); return validateInContext( validationContext, valueContext, validationOrder ); } }
具體校驗過程在validateConstraintsForSingleDefaultGroupElement方法中,它會遍歷@NotNull、@NotBlank、@Email這些約束注解,看參數(shù)是否符合限制
public class ValidatorImpl implements Validator, ExecutableValidator { private <U> boolean validateConstraintsForSingleDefaultGroupElement(BaseBeanValidationContext<?> validationContext, ValueContext<U, Object> valueContext, final Map<Class<?>, Class<?>> validatedInterfaces, Class<? super U> clazz, Set<MetaConstraint<?>> metaConstraints, Group defaultSequenceMember) { boolean validationSuccessful = true; valueContext.setCurrentGroup( defaultSequenceMember.getDefiningClass() ); //metaConstraints是@NotNull、@NotBlank、@Email這些約束注解的集合,一個個驗證 for ( MetaConstraint<?> metaConstraint : metaConstraints ) { final Class<?> declaringClass = metaConstraint.getLocation().getDeclaringClass(); if ( declaringClass.isInterface() ) { Class<?> validatedForClass = validatedInterfaces.get( declaringClass ); if ( validatedForClass != null && !validatedForClass.equals( clazz ) ) { continue; } validatedInterfaces.put( declaringClass, clazz ); } boolean tmp = validateMetaConstraint( validationContext, valueContext, valueContext.getCurrentBean(), metaConstraint ); if ( shouldFailFast( validationContext ) ) { return false; } validationSuccessful = validationSuccessful && tmp; } return validationSuccessful; } }
validator.isValid()是所有驗證器的入口,包括hibernate-validator內(nèi)置的,以及自定義的
public abstract class ConstraintTree<A extends Annotation> { protected final <V> Optional<ConstraintValidatorContextImpl> validateSingleConstraint( ValueContext<?, ?> valueContext, ConstraintValidatorContextImpl constraintValidatorContext, ConstraintValidator<A, V> validator) { boolean isValid; try { @SuppressWarnings("unchecked") V validatedValue = (V) valueContext.getCurrentValidatedValue(); isValid = validator.isValid( validatedValue, constraintValidatorContext ); } catch (RuntimeException e) { if ( e instanceof ConstraintDeclarationException ) { throw e; } throw LOG.getExceptionDuringIsValidCallException( e ); } if ( !isValid ) { //We do not add these violations yet, since we don't know how they are //going to influence the final boolean evaluation return Optional.of( constraintValidatorContext ); } return Optional.empty(); } }
以下是@NotBlank約束注解驗證器的具體實現(xiàn)
public class NotBlankValidator implements ConstraintValidator<NotBlank, CharSequence> { /** * Checks that the character sequence is not {@code null} nor empty after removing any leading or trailing * whitespace. * * @param charSequence the character sequence to validate * @param constraintValidatorContext context in which the constraint is evaluated * @return returns {@code true} if the string is not {@code null} and the length of the trimmed * {@code charSequence} is strictly superior to 0, {@code false} otherwise */ @Override public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) { if ( charSequence == null ) { return false; } return charSequence.toString().trim().length() > 0; } }
@RequestParam/@PathVariable參數(shù)校驗實現(xiàn)原理
該方式本質(zhì)是通過類上加@Validated注解,方法參數(shù)前加@NotBlank等約束注解來實現(xiàn)的。底層使用的是Spring AOP,具體來說是通過MethodValidationPostProcessor動態(tài)注冊AOP切面,然后使用MethodValidationInterceptor對切點方法織入增強(qiáng)。
以下是容器啟動時初始化@Validated切點,以及MethodValidationInterceptor增強(qiáng)
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean { private Class<? extends Annotation> validatedAnnotationType = Validated.class; @Nullable private Validator validator; @Override public void afterPropertiesSet() { Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true); this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator)); } protected Advice createMethodValidationAdvice(@Nullable Validator validator) { return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor()); } }
具體增強(qiáng)邏輯在MethodValidationInterceptor中
public class MethodValidationInterceptor implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton if (isFactoryBeanMetadataMethod(invocation.getMethod())) { return invocation.proceed(); } Class<?>[] groups = determineValidationGroups(invocation); // Standard Bean Validation 1.1 API ExecutableValidator execVal = this.validator.forExecutables(); Method methodToValidate = invocation.getMethod(); Set<ConstraintViolation<Object>> result; try { result = execVal.validateParameters( invocation.getThis(), methodToValidate, invocation.getArguments(), groups); } catch (IllegalArgumentException ex) { // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011 // Let's try to find the bridged method on the implementation class... methodToValidate = BridgeMethodResolver.findBridgedMethod( ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass())); result = execVal.validateParameters( invocation.getThis(), methodToValidate, invocation.getArguments(), groups); } if (!result.isEmpty()) { throw new ConstraintViolationException(result); } Object returnValue = invocation.proceed(); result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups); if (!result.isEmpty()) { throw new ConstraintViolationException(result); } return returnValue; } }
其中execVal.validateParameters()方法是用來做參數(shù)校驗的,最終會進(jìn)到hibernate-validator中。后面的邏輯跟上面類似,此處就不再贅述
public class ValidatorImpl implements Validator, ExecutableValidator { @Override public <T> Set<ConstraintViolation<T>> validateParameters(T object, Method method, Object[] parameterValues, Class<?>... groups) { Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() ); Contracts.assertNotNull( method, MESSAGES.validatedMethodMustNotBeNull() ); Contracts.assertNotNull( parameterValues, MESSAGES.validatedParameterArrayMustNotBeNull() ); return validateParameters( object, (Executable) method, parameterValues, groups ); } }
項目源碼
https://github.com/layfoundation/spring-param-validate
附件
jakarta.validation-api(版本2.0.1)所有注解
注解 | 說明 |
---|---|
@AssertFalse | 驗證 boolean 類型值是否為 false |
@AssertTrue | 驗證 boolean 類型值是否為 true |
@DecimalMax(value) | 驗證數(shù)字的大小是否小于等于指定的值,小數(shù)存在精度 |
@DecimalMin(value) | 驗證數(shù)字的大小是否大于等于指定的值,小數(shù)存在精度 |
@Digits(integer, fraction) | 驗證數(shù)字是否符合指定格式 |
驗證字符串是否符合電子郵件地址的格式 | |
@Future | 驗證一個日期或時間是否在當(dāng)前時間之后 |
@FutureOrPresent | 驗證一個日期或時間是否在當(dāng)前時間之后或等于當(dāng)前時間 |
@Max(value) | 驗證數(shù)字的大小是否小于等于指定的值 |
@Min(value) | 驗證數(shù)字的大小是否大于等于指定的值 |
@Negative | 驗證數(shù)字是否是負(fù)整數(shù),0無效 |
@NegativeOrZero | 驗證數(shù)字是否是負(fù)整數(shù) |
@NotBlank | 驗證字符串不能為空null或"",只能用于字符串驗證 |
@NotEmpty | 驗證對象不得為空,可用于Map和數(shù)組 |
@NotNull | 驗證對象不為 null |
@Null | 驗證對象必須為 null |
@past | 驗證一個日期或時間是否在當(dāng)前時間之前。 |
@PastOrPresent | 驗證一個日期或時間是否在當(dāng)前時間之前或等于當(dāng)前時間。 |
@Pattern(value) | 驗證字符串是否符合正則表達(dá)式的規(guī)則 |
@Positive | 驗證數(shù)字是否是正整數(shù),0無效 |
@PositiveOrZero | 驗證數(shù)字是否是正整數(shù) |
@Size(max, min) | 驗證對象(字符串、集合、數(shù)組)長度是否在指定范圍之內(nèi) |
hibernate-validator(版本6.0.17.Final)補(bǔ)充的常用注解
注解 | 說明 |
---|---|
@Length | 被注釋的字符串的大小必須在指定的范圍內(nèi) |
@Range | 被注釋的元素必須在合適的范圍內(nèi) |
@SafeHtml | 被注釋的元素必須是安全Html |
@URL | 被注釋的元素必須是有效URL |
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
Spring AOP實現(xiàn)復(fù)雜的日志記錄操作(自定義注解)
Spring AOP實現(xiàn)復(fù)雜的日志記錄操作(自定義注解),具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-09-09在Spring Boot中實現(xiàn)多環(huán)境配置的方法
在SpringBoot中,實現(xiàn)多環(huán)境配置是一項重要且常用的功能,它允許開發(fā)者為不同的運(yùn)行環(huán)境,這種方式簡化了環(huán)境切換的復(fù)雜度,提高了項目的可維護(hù)性和靈活性,本文給大家介紹在Spring Boot中實現(xiàn)多環(huán)境配置的方法,感興趣的朋友跟隨小編一起看看吧2024-09-09@ConfigurationProperties加載外部配置方式
這篇文章主要介紹了@ConfigurationProperties加載外部配置方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-03-03Java異常處理Guava?Throwables類使用實例解析
這篇文章主要為大家介紹了Java異常處理神器Guava?Throwables類使用深入詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-12-12