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

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

作者: 二哈_8fd0 | 来源:发表于2022-01-09 16:41 被阅读0次
    讨论范围 >>> 自定义注解及注解国际化的使用,源码实现后续探究
    为什么用注解校验,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国际化,自定义注解校验,可中断式的自定义注解校验实现,自定义注解多项联动校验时的支持{} 静态属性获取${} 被校验对象获取及国际化

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/xasgqrtx.html