java通过自定义注解进行参数校验并使用国际化错误提示
讨论范围 >>> 自定义注解及注解国际化的使用,源码实现后续探究
为什么用注解校验,1.可以通过@Validated 和 @Valid 注解在controller层自动进行校验 2. 也可以灵活的使用javax.validation.Validator 来手动校验,并且这种校验方式不同以往手动校验抛出异常的方式,可以遇到第一个错误后不抛出异常,将所有错误信息收集到一起。适用于复杂配置草稿化,也就是可以在配置错误的情况下先暂存,在发布配置时统一校验并将所有错误信息返回。
如何使用
1.注解提示信息国际化
首先注解的提示信息可以通过注解上的message属性定义例如下面代码
@Length(min = 1, max = 45, message = "节点名称长度1 ~ 45")
private String name;
而message的国际化,可以通过配置resource中的 ValidationMessages.properties来定义
image.png
通过key value的映射,配置国际化信息,但是需要指定一个新的properties加载配置
@Bean
public Validator validator(ResourceBundleMessageSource messageSource) {
// RESOURCE_NAME 为字符串,为指定新的国际化配置,和原有的
messageSource.getBasenameSet().add(RESOURCE_NAME);
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
factoryBean.setValidationMessageSource(messageSource);
return factoryBean;
}
国际化的 映射关系为 key(配置类型) -> 语言 -> 具体的key value映射
而注解的国际化配置配置类型默认为 ValidationMessages,具体原理后续源码解析中讨论
然后就可以在注解的message中使用配置code
国际化配置的演示
/**
* 注解中的 国际化配置必须是{} 包裹,因为其可以混合静态值使用,并且可以使用被校验目标对象
* 通过el表达式获取值,或者直接获取产生校验的注解的静态属性,下面一一演示
*/
public static final String CONDITION_ERROR = "{CONDITION_ERROR}";
// ----------国际化配置-------
CONDITION_ERROR=条件校验失败,请检查!
CONDITION_ERROR=condition validate error,please check config!
使用静态固定值配合 国际化配置
@Length(min = 1, max = 45, message = "{NAME_LENGTH}1 ~ 45")
private String name;
// 国际化配置
NAME_LENGTH=名称的长度范围是
NAME_LENGTH=yingwenhuozheqitayuyan
// 校验不通过拿到的异常信息是
名称的长度范围是1 ~ 45
yingwenhuozheqitayuyan1 ~ 45
使用注解静态属性值 + 国际化配置
这里要注意的是低版本的hibernate-validator校验实现jar包有bug,关于基本类型数组转换的类型安全问题,已经在新版本解决了这个bug,具体可看上一篇文章 https://www.jianshu.com/p/8d4ad5e2d735 当注解中使用基本类型数组作为属性时如果通过下面{min}的方式会报错噢
@Length(min = 1, max = 45, message = "{NAME_LENGTH}{min} ~ {max}")
private String name;
// 国际化配置
NAME_LENGTH=名称的长度范围是
NAME_LENGTH=yingwenhuozheqitayuyan
// 校验不通过拿到的异常信息是
名称的长度范围是1 ~ 45
yingwenhuozheqitayuyan1 ~ 45
使用注解静态属性值 + 国际化配置 + 目标属性el表达式
@Length(min = 1, max = 45, message = "{NAME_LENGTH}{min} ~ {max} 传入的值为 ${validatedValue}")
private String name;
// 国际化配置
NAME_LENGTH=名称的长度范围是
NAME_LENGTH=yingwenhuozheqitayuyan
// 校验不通过拿到的异常信息是
名称的长度范围是1 ~ 45 ${传入的值}
yingwenhuozheqitayuyan1 ~ 45 ${传入的值}
当然如果name替换成对象,也是可以通过{}这种方式,是获取目标数据就通过${},而validatedValue 则是固定的值,只被校验的目标对象,后续源码解析可以看到源码中写死的这个值。
最佳实现
// 只使用code 映射到国际化配置中,国际化配置中可以使用 {} 和 ${validatedValue}
@Length(min = 1, max = 45, message = "{NAME_LENGTH}")
private String name;
// 国际化配置
NAME_LENGTH=名称的长度最长范围为{min} ~ {max} 传入的值为 ${validatedValue}
NAME_LENGTH=yingwen 为{min} ~ {max} yingwen ${validatedValue}
// 校验不通过拿到的异常信息是
名称的长度范围是1 ~ 45 ${传入的值}
yingwen 为1 ~ 45 yingwen ${传入的值}
自定义注解实现校验
实现 ConstraintValidator<A, T> 接口
public interface ConstraintValidator<A extends Annotation, T> {
// 初始化当前校验实例时回调的方法
default void initialize(A constraintAnnotation) {
}
// 校验方法,返回false则会抛出异常,如果使用手动校验的方式,会收集每个返回false的message信息和被校验的目标对象
boolean isValid(T value, ConstraintValidatorContext context);
}
定义注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
// 指定自定义校验实现类
@Constraint(validatedBy = TestValidateImpl.class)
public @interface TestValidate {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
自定义实现类 需要指定注解和被校验的 对象类型,如果不是自定义对象可以直接指定,被指定的对象类型如果为了通用性可以使用接口或者抽象类
public class TestValidateImpl implements ConstraintValidator<TestValidate, TestValidateModel> {
@Override
public boolean isValid(TestValidateModel value, ConstraintValidatorContext context) {
return false;
}
}
被校验的对象
@Data
public class TestModel {
@TestValidate(message = "{TEST} 嗷呜 ${validatedValue.name}")
private TestValidateModel bo;
@Data
public static class TestValidateModel{
private String name;
}
}
然后在实现类里写逻辑即可,通过返回true 和false 实现校验是否通过
还有一种情况在一个实现类中对复杂对象进行多项校验,或者多个属性联动校验
@Data
public class TestModel {
@TestValidate(message = "{TEST} 嗷呜 ${validatedValue.name}")
private TestValidateModel bo;
@Data
public static class TestValidateModel{
private String name;
private LocalDateTime startTime;
private LocalDateTime endTime;
}
}
接下来有两种方式
1. 如果是给 controller层用@Validated 注解进行接口层的校验可以直接抛出异常
2. 手动调用校验方法
- 先在 controller层异常拦截做好国际化逻辑
/**
* 要在自定义部分处理好国际化
* @param constraintDeclarationException 在controller validation阶段抛出的异常 自定义校验注解使用
* @param locale locale
* @return RestErrorResponse
*/
@ExceptionHandler(value = CustomConstraintDeclarationException.class)
@ResponseBody
public final RestErrorResponse handConstraintDeclarationException(CustomConstraintDeclarationException constraintDeclarationException, Locale locale) {
String message = constraintDeclarationException.getMessage();
// 应该在内部拼接时已经 处理好国际化
log.error(message, constraintDeclarationException);
return new RestErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), message);
}
校验逻辑 代码
public class TestValidateImpl implements ConstraintValidator<TestValidate, TestValidateModel> {
@Autowired
MessageSource messageSource;
@Override
public boolean isValid(TestValidateModel value, ConstraintValidatorContext context) {
if (value == null){
setMessage(new BaseException(这里写对应code));
return false;
}
if (value.getName().length() > 15){
// 这样操作是直接中断校验,抛出异常,CustomConstraintDeclarationException是在进行注解校验过程中唯一可以不catch抛出的
// 为了可以直接中断抛出,结合 @Validated 注解在controller层使用
String message = messageSource.getMessage(配置好的国际化code, null, LocaleContextHolder.getLocale());
throw new CustomConstraintDeclarationException(message, 随便定义一个异常类型);
return false;
}
if (value.getEndTime().compareTo(value.getStartTime()) < 0){
// 如果不直接抛出异常中断,则配置 注解上的 message 拿到信息。但是这样错误信息的获取复杂度太高,好处是可以兼容@Validated注解
//和手动调用校验方法,又可以controller层校验,又可以手动校验一次拿到所有校验不通过的属性
return false;
}
return true;
}
// 这里是使用 自身框架 throw 自定义 exception逻辑,通过 MessageSource直接获取国际化信息即可,这里的逻辑会直接中断,不会一直收集后续的校验信息
@SneakyThrows
protected void setMessage(BaseException baseException) {
// MessageSourceUtils 方法为我们目前自己定义的逻辑,可忽略,主要是 MessageSource#getMessage()方法
String message = messageSource.getMessage(MessageSourceUtils.getMessageCode(baseException.getMessageCode()),
baseException.getArgs(), LocaleContextHolder.getLocale());
throw new CustomConstraintDeclarationException(message, baseException);
}
}
使用方式和集中情况如上。但是我们这里有多个校验项,又想每个校验项让客户端(无论是@Validated注解还是手动调用校验方法,都可以拿到所有校验信息和对应的错误提示)
先看手动如何调用校验方法
@Autowired
protected Validator globalValidator;
@Override
public <T> void verify(T target, Function<T, String> targetCategoryMapper, Function<T, Long> idMapper) {
// 手动调用校验,可以获取所有 返回 false不同过的校验项,然后取注解上的message作为提示信息
Set<ConstraintViolation<T>> constraintViolations = globalValidator.validate(target);
if (CollectionUtils.isNotEmpty(constraintViolations)) {
constraintViolations.forEach(constraint -> {
String nodeName = targetCategoryMapper != null ? targetCategoryMapper.apply(target) : null;
Long id = idMapper != null ? idMapper.apply(target) : null;
setFailInfo(nodeName, constraint.getMessage(), id);
});
}
}
####### 如何使用
public class TestValidateImpl implements ConstraintValidator<TestValidate, TestValidateModel> {
@Autowired
MessageSource messageSource;
@Autowired
VerfiyServiceverfiyService;
@Override
public boolean isValid(TestValidateModel value, ConstraintValidatorContext context) {
boolean result = true;
if (value == null) {
verfiyService.set错误信息(value, "{NOT_NULL}");
// 为了所有校验项都要交验到,可以不立即返回false
result = false;
}
if (value.getName().length() > 15) {
context.buildConstraintViolationWithTemplate("{NAME_LENGTH}").addConstraintViolation();
result = false;
}
if (value.getEndTime().compareTo(value.getStartTime()) < 0) {
context.buildConstraintViolationWithTemplate("{TIME_START_END}").addConstraintViolation();
result = false;
}
return true;
}
}
那么问题来了,这里的方法不会走spring 和 hibernate-validator内置方法来做国际化转换,我们需要自己实现这部分逻辑。下面是仿照hibernate-validator源码实现,拿到
/**
* @description: 自定义的注解的 message国际化
* @author: yhr
* @modified By: yhr
* @date: Created in 2021/12/3 10:11
* @version:v1.0
* 1.通过自定义注解或者原有注解 在注解的message上用 {} 包裹国际化code
* 2. 在自定义注解 使用手动填入信息时{@link ProcessVerifyService#verify(Object, Function, Function)}
* 在自定义注解例如 {@link TriggerConfigValidateImpl} 通过 {@link ProcessSetFailByTargetConsumer#setFailInfo(Object, String)}
* 来手动放入错误信息。手动放入的信息也可以 用 {} 包裹国际化code
* 3. 国际化code 需要在resource下的 ValidatedMessages 对应的properties配置国际化信息,同时国际化的信息可以使用sp el表达式
* 使用方式为 ${} 包裹 被添加注解的对象为 validatedValue 固定值例如
* {err_code_1}
* err_code_1=嗷嗷呜~${validatedValue.name} 会获取被注解对象的name字段,获取不道则替换为null
* -------------如果是直接获取注解中的配置项,在properties中就不需要用${},使用{}即可,例如{@link org.hibernate.validator.constraints.Length}
* 例如{@link Length#max()}直接在properties配置err_code_1=嗷嗷呜~名字长度不能超过{max}输入名称为:${validatedValue.name}
* 但是这样只适用于不是数组,如果取注解中的数组会报错 , 目前spring-boot-starter-validator 2.3.2依赖 hibernate-validator 6.1.5
* 有bug,后续hibernate-validator 6.2以上已经把数组类型安全bug修复目前咱不可以使用获取注解中的数组变量
* 特别注意的是不支持方法和计算,当前的表达式实使用的是 {@link ValueExpression} 只是属性取值表达式
* 并不支持 {@link javax.el.MethodExpression} 方法表达式,也就是不能支持像正常sp el表达式类似 obj != null ? a : b
* T(org.apache.commons.collections4.isNotEmpty(list)) ? a : b 之类的静态方法使用及当前实例方法使用及计算都不被支持
* 4.
* 本类实现仿照javax.validation的标准 {@link MessageInterpolator} 的 hibernate包的实现
* @see AbstractMessageInterpolator
* ---------------------快速查看案例-------------------
* @see TestController#test1()
* @see TestController#test2()
*/
public interface ValidationCustomResourceBundle {
/**
* 自定义注解校验 实现国际化 及el表达式逻辑
* @param messageTemplate 消息模板,注解中的message 或者 自定义手动放入的 字符串
* 目前统一放在{@link FlowValidateMessages}
* @see ProcessVerifyService#verify(Object, Function, Function)
* @see ProcessSetFailByTargetConsumer#setFailInfo(Object, String)
* @param locale 国际化
* @param target 目标对象
* @return 处理好的返回信息
*/
String parseMessageTemplate(String messageTemplate, Locale locale, Object target);
}
具体实现,代码逻辑并不复杂,先处理{} 静态属性获取,然后处理${} 被校验对象的获取,然后
通过 MessageSourceResourceBundleLocator 处理国际化逻辑
@Configuration
@Slf4j
public class ValidationCustomResourceBundleHibernateImpl implements ValidationCustomResourceBundle {
@Autowired
MessageSource messageSource;
private final ExpressionFactory expressionFactory = new ExpressionFactoryImpl();
private static final String VALIDATED_VALUE_NAME = "validatedValue";
private static final String LIFT = "{";
private static final String RIGHT = "}";
private static final String RESOURCE_NAME = "ValidationMessages";
private static final String DOLLAR_SIGN = "$";
private static final String SIGN = "\\";
private static final Pattern LEFT_BRACE = Pattern.compile("\\{", Pattern.LITERAL);
private static final Pattern RIGHT_BRACE = Pattern.compile("\\}", Pattern.LITERAL);
private static final Pattern SLASH = Pattern.compile("\\\\", Pattern.LITERAL);
private static final Pattern DOLLAR = Pattern.compile("\\$", Pattern.LITERAL);
private static final int DEFAULT_INITIAL_CAPACITY = 100;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
private final ConcurrentReferenceHashMap<LocalizedMessage, String> resolvedMessages;
private final ConcurrentReferenceHashMap<String, List<Token>> tokenizedParameterMessages;
private final ConcurrentReferenceHashMap<String, List<Token>> tokenizedELMessages;
private MessageSourceResourceBundleLocator messageSourceResourceBundleLocator;
public ValidationCustomResourceBundleHibernateImpl() {
this.resolvedMessages = new ConcurrentReferenceHashMap<>(
DEFAULT_INITIAL_CAPACITY,
DEFAULT_LOAD_FACTOR,
DEFAULT_CONCURRENCY_LEVEL,
SOFT,
SOFT,
EnumSet.noneOf(ConcurrentReferenceHashMap.Option.class)
);
this.tokenizedParameterMessages = new ConcurrentReferenceHashMap<>(
DEFAULT_INITIAL_CAPACITY,
DEFAULT_LOAD_FACTOR,
DEFAULT_CONCURRENCY_LEVEL,
SOFT,
SOFT,
EnumSet.noneOf(ConcurrentReferenceHashMap.Option.class)
);
this.tokenizedELMessages = new ConcurrentReferenceHashMap<>(
DEFAULT_INITIAL_CAPACITY,
DEFAULT_LOAD_FACTOR,
DEFAULT_CONCURRENCY_LEVEL,
SOFT,
SOFT,
EnumSet.noneOf(ConcurrentReferenceHashMap.Option.class)
);
}
@Bean
public Validator validator(ResourceBundleMessageSource messageSource) {
messageSource.getBasenameSet().add(RESOURCE_NAME);
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
factoryBean.setValidationMessageSource(messageSource);
return factoryBean;
}
@PostConstruct
protected void init() {
messageSourceResourceBundleLocator = new MessageSourceResourceBundleLocator(messageSource);
}
@Override
public String parseMessageTemplate(String messageTemplate, Locale locale, Object target) {
if (!messageTemplate.contains(LIFT)) {
return replaceEscapedLiterals(messageTemplate);
}
ResourceBundle resourceBundle = messageSourceResourceBundleLocator.getResourceBundle(locale);
String resolvedMessage = null;
resolvedMessage = resolvedMessages.computeIfAbsent(new LocalizedMessage(messageTemplate, locale), lm ->
interpolateBundleMessage(messageTemplate, resourceBundle, locale, target));
if (resolvedMessage.contains(LIFT)) {
// 参数解析 {} 部分 获取注解中的参数及 message中配置的{}
resolvedMessage = interpolateBundleMessage(new TokenIterator(getParameterTokens(resolvedMessage, tokenizedParameterMessages, InterpolationTermType.PARAMETER))
, locale, target, resourceBundle);
// el 通过属性el表达式获取被校验对象的 属性值
resolvedMessage = interpolateBundleMessage(new TokenIterator(getParameterTokens(resolvedMessage, tokenizedELMessages, InterpolationTermType.EL))
, target, locale);
}
// last but not least we have to take care of escaped literals
resolvedMessage = replaceEscapedLiterals(resolvedMessage);
return resolvedMessage;
}
private String interpolateBundleMessage(TokenIterator tokenIterator, Locale locale, Object target, ResourceBundle resourceBundle)
throws MessageDescriptorFormatException {
while (tokenIterator.hasMoreInterpolationTerms()) {
String term = tokenIterator.nextInterpolationTerm();
String resolvedParameterValue = resolveParameter(
term, resourceBundle, locale, target
);
tokenIterator.replaceCurrentInterpolationTerm(resolvedParameterValue);
}
return tokenIterator.getInterpolatedMessage();
}
private String interpolateBundleMessage(TokenIterator tokenIterator, Object target, Locale locale) {
while (tokenIterator.hasMoreInterpolationTerms()) {
String term = tokenIterator.nextInterpolationTerm();
SimpleELContext elContext = new SimpleELContext(expressionFactory);
String resolvedExpression = null;
try {
ValueExpression valueExpression = bindContextValues(term, elContext, locale, target);
resolvedExpression = (String) valueExpression.getValue(elContext);
} catch (RuntimeException e) {
log.warn("ValidationMessages >>> 表达式错误 value:{} ", term, e);
}
tokenIterator.replaceCurrentInterpolationTerm(resolvedExpression);
}
return tokenIterator.getInterpolatedMessage();
}
private List<Token> getParameterTokens(String resolvedMessage, ConcurrentReferenceHashMap<String, List<Token>> cache, InterpolationTermType termType) {
return cache.computeIfAbsent(
resolvedMessage,
rm -> new TokenCollector(resolvedMessage, termType).getTokenList()
);
}
private String resolveParameter(String parameterName, ResourceBundle bundle, Locale locale, Object target)
throws MessageDescriptorFormatException {
String parameterValue;
try {
if (bundle != null) {
parameterValue = bundle.getString(removeCurlyBraces(parameterName));
parameterValue = interpolateBundleMessage(parameterValue, bundle, locale, target);
} else {
parameterValue = parameterName;
}
} catch (MissingResourceException e) {
// return parameter itself
parameterValue = parameterName;
}
return parameterValue;
}
private ValueExpression bindContextValues(String messageTemplate, SimpleELContext elContext, Locale locale, Object targetValue) {
// bind the validated value
ValueExpression valueExpression = expressionFactory.createValueExpression(
targetValue,
Object.class
);
elContext.getVariableMapper().setVariable(VALIDATED_VALUE_NAME, valueExpression);
// bind a formatter instantiated with proper locale
valueExpression = expressionFactory.createValueExpression(
new FormatterWrapper(locale),
FormatterWrapper.class
);
elContext.getVariableMapper().setVariable(RootResolver.FORMATTER, valueExpression);
return expressionFactory.createValueExpression(elContext, messageTemplate, String.class);
}
private String removeCurlyBraces(String parameter) {
return parameter.substring(1, parameter.length() - 1);
}
private String replaceEscapedLiterals(String resolvedMessage) {
if (resolvedMessage.contains(SIGN)) {
resolvedMessage = LEFT_BRACE.matcher(resolvedMessage).replaceAll(LIFT);
resolvedMessage = RIGHT_BRACE.matcher(resolvedMessage).replaceAll(RIGHT);
resolvedMessage = SLASH.matcher(resolvedMessage).replaceAll(Matcher.quoteReplacement(SIGN));
resolvedMessage = DOLLAR.matcher(resolvedMessage).replaceAll(Matcher.quoteReplacement(DOLLAR_SIGN));
}
return resolvedMessage;
}
private String interpolateBundleMessage(String message, ResourceBundle bundle, Locale locale, Object target)
throws MessageDescriptorFormatException {
TokenCollector tokenCollector = new TokenCollector(message, InterpolationTermType.PARAMETER);
TokenIterator tokenIterator = new TokenIterator(tokenCollector.getTokenList());
while (tokenIterator.hasMoreInterpolationTerms()) {
String term = tokenIterator.nextInterpolationTerm();
String resolvedParameterValue = resolveParameter(
term, bundle, locale, target
);
tokenIterator.replaceCurrentInterpolationTerm(resolvedParameterValue);
}
return tokenIterator.getInterpolatedMessage();
}
}