深入剖析Spring Boot参数校验:实现原理、自定义注解组件与国际化多语言实践
1.概述
书接上回,我们总结了后端接口参数校验的重要性,详解讲述了Spring Boot项目中如何整合Spring-Validator
组件进行参数校验,实战教程:后端接口没做参数检验导致服务雪崩,被批评代码健壮性太差…
因为参数校验是Web开发中保证数据完整性和安全性的重要环节,所以Spring Boot基于**JSR-380(Bean Validation 2.0)**规范,提供了强大的参数校验机制,支持:
✔ 声明式校验(通过注解)
✔嵌套校验(参数对象多级)
✔ 分组校验(不同场景不同规则)
自定义校验逻辑(扩展ConstraintValidator
)
国际化错误消息(支持多语言)
✔的已经在入门实战教程中总结过了,不清楚的自行跳转查看,今天我们结合实际项目开发,谈谈自定义注解校验特定场景规则和国际化多语言错误消息处理。在此之前,我们先来看看Spring Boot参数校验原理
Spring Boot参数校验原理
接口参数校验属于web应用的范畴,所以对于最常用的@RequestBody
参数对象校验肯定是在Spring MVC组件中实现的,RequestResponseBodyMethodProcessor
是用来解析参数@RequestBody
和处理响应@ResponseBody
的核心所在,所以参数校验的逻辑也一定在这里解析参数的方法里:
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {......@Overridepublic 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);}......
}
参数检验的方法:validateIfApplicable(binder, parameter)
:
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {// 获取参数注解Annotation[] annotations = parameter.getParameterAnnotations();for (Annotation ann : annotations) {// 获取@Validated注解Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);// 有@Validated直接开启校验。// 没有再判断参数前是否有Valid起头的注解。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;}}
}
这里也是@Validated
和@Valid
都能开启参数检验的逻辑所在。
跟着执行校验代码binder.validate(validationHints)
, 最终来到了LocalValidatorFactoryBean
的验证方法:
@Override
public void validate(Object target, Errors errors, Object... validationHints) {if (this.targetValidator != null) {processConstraintViolations(// 进入Hibernate Validator执行真正的校验this.targetValidator.validate(target, asValidationGroups(validationHints)), errors);}
}
来到MetaConstraint
的doValidateConstraint()
方法:
private boolean doValidateConstraint(ValidationContext<?> executionContext, ValueContext<?, ?> valueContext) {valueContext.setConstraintLocationKind(this.getConstraintLocationKind());boolean validationResult = this.constraintTree.validateConstraints(executionContext, valueContext);return validationResult;}
最后执行ConstraintTree
的validateSingleConstraint()
:
protected final <V> Optional<ConstraintValidatorContextImpl> validateSingleConstraint(ValueContext<?, ?> valueContext, ConstraintValidatorContextImpl constraintValidatorContext, ConstraintValidator<A, V> validator) {boolean isValid;try {// 获取参数值V validatedValue = (V)valueContext.getCurrentValidatedValue();// 具体约束逻辑实现isValid = validator.isValid(validatedValue, constraintValidatorContext);} catch (RuntimeException e) {if (e instanceof ConstraintDeclarationException) {throw e;}throw LOG.getExceptionDuringIsValidCallException(e);}return !isValid ? Optional.of(constraintValidatorContext) : Optional.empty();}
可以看到真正验证参数是否合法逻辑在ConstraintValidator
的isValid()
,这也是我们自定义注解验证特定场景需要实现的接口逻辑所在哦。
关于方法级别requestParam/PathVariable
参数校验大家自行了解,入口在MethodValidationPostProcessor
这个切面
3.自定义注解验证进阶实践
在真实的项目开发中,业务场景需求是多种多样的,校验框架提供的原生注解不一定能满足复杂场景的校验,这时候我们只能自定义一个注解来实现该场景的参数校验,这里我列举两个实际开发中经常用到的。
3.1 枚举值合法性校验
在接口入参中,枚举字段很常见,比如入门教程里面的userParam
的性别字段:
/** 性别 0:男生 1:女生 */
@NotNull(message = "性别不能为空")
private Integer gender;
使用了@NotNull
校验了参数不能为空,但是并没有对性别的枚举值进行校验,要是传一个2上来没校验直接落库,就会产生了非法数据”不男不女“了,后患无穷~~~所以我们需要对参数值进行校验,必须武装严谨到牙齿,哈哈。
基于上面的实现原理和原生注解的实现套路,我们首先先定一个注解标记验证字段:
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EnumValueValidator.class})
public @interface EnumValue {String message() default "enum value is not valid";/** 关联的枚举类 CheckEnumValue是我们定义的公共枚举接口,所以枚举类都要实现提供返回枚举值的方法 */Class<? extends CheckEnumValue> linkEnum() default CheckEnumValue.class;Class<?>[] groups() default { };Class<? extends Payload>[] payload() default { };
}
该注解和框架原生提供的注解套路几乎一致,这里只是多了一个验证字段关联的枚举类的属性linkEnum
。
接下来定义具体约束校验逻辑:
public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {private Class<? extends CheckEnumValue> clz;@Overridepublic void initialize(EnumValue constraintAnnotation) {clz = constraintAnnotation.linkEnum();}@Overridepublic boolean isValid(Object value, ConstraintValidatorContext context) {// 参数值为空不校验if (Objects.isNull(value)) {return true;}// 关联的不是枚举类不校验if (!clz.isEnum()) {return true;}CheckEnumValue[] enumConstants = clz.getEnumConstants();if (enumConstants == null || enumConstants.length == 0) {return true;}CheckEnumValue enumConstant = enumConstants[0];List enumValue = enumConstant.getEnumValue();// 判断参数值是否在枚举值中if (CollectionUtils.isEmpty(enumValue)) {return true;}if (enumValue.contains(value)) {return true;}return false;}
}
枚举校验的公共接口,枚举类都要实现该接口,该接口返回所有枚举值
public interface CheckEnumValue<T> {List<T> getEnumValue();}
性别的枚举类如下:
public enum GenderEnum implements CheckEnumValue<Integer> {MAN(0, "男生"),WOMAN(1, "女生");private Integer code;private String name;GenderEnum(Integer code, String name) {this.code = code;this.name = name;}@Overridepublic List<Integer> getEnumValue() {return Stream.of(GenderEnum.values()).map(genderEnum -> genderEnum.code).collect(Collectors.toList());}
}
接下来我们就可以在接口参数对象使用自定义注解进行枚举字段校验了
/** 性别 0:男生 1:女生 */
@EnumValue(message = "性别枚举值不对", linkEnum = GenderEnum.class)
@NotNull(message = "性别不能为空")
private Integer gender;
调接口输入参数:
{"gender":2,
}
接口放回结果如下:
{"code": 400,"msg": "Bad Request","data": {"gender": "性别枚举值不对"}
}
可以看出,我们自定义注解正常运作了。
3.2 字段联合校验
上篇文章中我们提到了因为后端没有参数校验导致服务崩溃不可用,其实接口是使用了validator
进行参数校验的,但有一种特殊情况,当其中一个字段的入参等于某个值的时候,另一个字段不能为空,这种情况框架提供的基本注解解决不了,只能在接口代码写代码判断,还是userParam
为例,要求输入性别为女生gender=1
时,出生日期birthday
必传:
if (Objects.equals(userParam.getGender(), 1) && Objects.isNull(userParam.getBirthday())) {throw new BizException("出生日期不能为空");}
可惜用了框架校验,再在接口里面写参数校验代码就显得繁杂,不够优雅,所以就没写最后就出问题了~~~
要想解决这个问题,我们只能自定义注解来实现这个复杂场景校验,同时又要不失优雅。
首先定义一个多字段组合验证注解:
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {CombineNotNullValidator.class})
public @interface CombineNotNull {String message() default "enum value is not valid";/** Spring SpEL表达式 */@Language("SpEL")String condition() default "";Class<?>[] groups() default { };Class<? extends Payload>[] payload() default { };
}
实现约束校验逻辑:
public class CombineNotNullValidator implements ConstraintValidator<CombineNotNull, Object> {// 解析SpEL有一定开销, 缓存表达式private static final ConcurrentHashMap<String, Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>();// SpelExpressionParser是线程安全的private static final ExpressionParser parser = new SpelExpressionParser();// 条件表达式字符串private String condition;@Overridepublic void initialize(CombineNotNull constraintAnnotation) {condition = constraintAnnotation.condition();}@Overridepublic boolean isValid(Object value, ConstraintValidatorContext context) {// 表达式为空if (StringUtils.isBlank(condition)) {return true;}// 获取入参对象(被校验的对象)RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();Object requestBody = requestAttributes.getAttribute("request_body", RequestAttributes.SCOPE_REQUEST);if (requestBody == null) {return true;}Expression expression = EXPRESSION_CACHE.computeIfAbsent(condition,k -> parser.parseExpression(k));Boolean result = expression.getValue(requestBody, Boolean.class);if (Boolean.FALSE.equals(result)) {return true;}return value != null;}
}
框架提供的ConstraintValidatorContext context
上下文并没有提供入参对象,直接获取不了,所以只能我们自己实现传递入参对象上下文,实现传递方式很多,大家可以自行实现,我这里使用RequestBodyAdvice
实现:
@RestControllerAdvice
public class RequestBodyHandlerAdvice implements RequestBodyAdvice {@Overridepublic boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {return true;}@Overridepublic HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {return inputMessage;}@Overridepublic Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {// 判断接口有没有开启接口参数校验Valid valid = parameter.getParameterAnnotation(Valid.class);Validated validated = parameter.getParameterAnnotation(Validated.class);if (valid != null || validated != null) {// 传递入参对象RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();requestAttributes.setAttribute("request_body", body, RequestAttributes.SCOPE_REQUEST);}return body;}@Overridepublic Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {return null;}
}
对RequestBodyAdvice
不太了解的,可以查看我们之前的总结:一文带你掌握SpringMVC扩展点RequestBodyAdvice和ResponseBodyAdvice如何使用及实现原理
接下来就可以接口参数类上直接使用:
@CombineNotNull(message = "出生日期不能为空", condition = "#this.gender == 1")
private Date birthday;
当我们调接口入参如下:
{"gender":1,
}
接口返回如下:
{"code": 400,"msg": "Bad Request","data": {"birthday": "出生日期不能为空"}
}
完美解决多字段联合校验问题。
4.国际化多语言
如果你的项目是一个国际化的应用,那就必须考虑多语言了,可以使用国际化 (i18n) 以用户首选语言显示错误消息。
在项目的资源目录resources
配置国际化资源文件:
messages.properties
(默认)
user.id.notNull= id not be null
messages_zh_CN.properties
(中文)
user.id.notNull=用户id不能为空
调整入参检验:
@NotNull(message = "{user.id.notNull}", groups = {Update.class})
private Long id;
调接口入参不输入id,提示如下:
{"code": 400,"msg": "Bad Request","data": {"id": "用户id不能为空"}
}
当发生验证错误时,错误消息将根据随请求发送的“Accept-Language”标头以用户的首选语言显示。
5.总结
Spring Boot参数校验既灵活又强大,合理使用可以大幅提升代码健壮性和可维护性! 🚀基于入门实战教程,使用@Validated
完成接口常规场景接口参数校验,与此同时我们深入了解了validator实现原理,实现自定义验证注解解决特定场景业务需求,做到了代码优雅简洁、规范健壮,最终提高了系统的稳定性和可维护性。