java通过自定义注解进行参数校验并使用国际化错误提示

2022-01-09  本文已影响0人  二哈_8fd0
讨论范围 >>> 自定义注解及注解国际化的使用,源码实现后续探究
为什么用注解校验,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.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. 手动调用校验方法
  1. 先在 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();
    }

}

总结

上述所有逻辑包含了使用注解message国际化,自定义注解校验,可中断式的自定义注解校验实现,自定义注解多项联动校验时的支持{} 静态属性获取${} 被校验对象获取及国际化
上一篇下一篇

猜你喜欢

热点阅读