# Java SpringBoot 自定义校验注解 + 动态载入校验配置与错误信息

最近实习实训课程在做 SpringBoot 后端项目,其中要对前端传入的请求做校验。
通过查询网上资料很快能实现自定义注解,但是对于增强可维护性、格式化错误消息方面,需要更多工作。

# 新的需求

我希望实现下面的效果:

  1. 实现自定义正则校验注解,正则式可以复用,无需每个注解重新写一遍相同的正则表达式
  2. 错误消息也可以复用
  3. 可以通过配置文件修改校验注解中的参数和错误消息

# 实现过程

# 【预备知识】速览:自定义注解的实现

SpringBoot 的 Validation 模块提供了校验服务,对于 Controller 方法的请求体,
只需要加上 @Validated 就可以标注校验,具体的校验项需要设置在请求对象的定义中。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class TrolleyRequest {
    @NotNull
    Integer id;
    @Min(1)
    Integer quantity;
    Integer userId;
    
    @Pattern(regexp = "...",message = "...") // 正则校验
    String name;
}

Validation 模块内置了几种常用校验注解,满足大多数校验需求。
对于更自由的校验可能库里没有提供,这时候需要自定义校验注解 + 校验器来实现需求。

自定义注解形式如下:

@Constraint(validatedBy = SthValidator.class)   // 校验器
@Target({ElementType.FIELD}) // 注解作用对象
@Retention(RUNTIME) // 注解作用时机
public @interface SthAnno {
    String message() default "invalid";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}

必要的三个注解、三个方法,此外可以添加自定义方法。
validatedBy 则指定校验器,校验器形式如下:

public class RegexpValidator implements ConstraintValidator<ValidRegexp, String> {
    @Override
    public void initialize(ValidRegexp constraintAnno) {
    }
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        return true;
    }
}

需要重载两个方法。
以上是自定义校验的方法,网上教程随便一艘就有。

# 效果预览

新定义正则表达式注解、状态枚举注解,以此为例展开需求预览。

@Constraint(validatedBy = RegexpValidator.class)
@Target({ElementType.FIELD})
@Retention(RUNTIME)
public @interface ValidRegexp {
    String type();
    String message() default "ValidRegexp: default message";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}
@Constraint(validatedBy = StateValidator.class)
@Target({ElementType.FIELD})
@Retention(RUNTIME)
public @interface ValidState {
    String type();
    String message() default "ValidState: default message";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}

如上两个注解, type 指定了我的校验的类别(这个域叫做 name 也行)。

接着是配置文件 application.yml

app:
  validator:
    valid-regexp:
      username:
        message: 用户名必须是6-16位的字母或数字
        regexp: ^[0-9A-Za-z]{6,16}$
      password:
        message: 密码必须包含数字和字母,且长度在6-16位之间
        regexp: ^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,16}$
      tel:
        message: 手机号码格式错误
        regexp: ^(?:\+?86)?1(?:3\d{3}|5[^4\D]\d{2}|8\d{3}|7(?:[235-8]\d{2}|4(?:0\d|1[0-2]|9\d))|9[0-35-9]\d{2}|66\d{2})\d{6}$
    valid-state:
      role: [ ADMIN,CONSUMER,MERCHANT ]

对应的配置类比较显然:

@Data
@Configuration
@ConfigurationProperties(prefix = "app.validator")
public class ValidatorConfig {
    ValidRegexp validRegexp;
    ValidState validState;
    @Data
    public static class ValidRegexp {
        Username username;
        Password password;
        Tel tel;
        @Data
        public static class Base {
            String regexp;
            String message;
        }
        public static class Password extends Base {
        }
        public static class Username extends Base {
        }
        public static class Tel extends Base {
        }
    }
    @Data
    public static class ValidState {
        List<String> role;
        List<String> state;
    }
}

使用例:

@Data
public class UserRegisterRequest {
    @ValidRegexp(type = "tel")
    private String tel;
    @ValidRegexp(type = "username")
    private String name;
    private String pass;
    @ValidState(type = "role")
    private String role;
    @Email
    private String email;
}

期望效果:
tel 字段进行正则校验 ^[0-9A-Za-z]{6,16}$ ,校验失败的消息为 用户名必须是6-16位的字母或数字 ;其余字段同理。
role 字段只允许值为 [ ADMIN,CONSUMER,MERCHANT ] 之一。

# 实现验证器

要实现期望效果,首先是注解验证器。

public class RegexpValidator implements ConstraintValidator<ValidRegexp, String> {
    @Autowired
    ValidatorConfig config;
    private String regexp;
    @Override
    public void initialize(ValidRegexp constraintAnno) {
        try {
            String type = constraintAnno.type();
            Field field = ValidatorConfig.ValidRegexp.class.getDeclaredField(type);
            field.setAccessible(true);
            var valid = (ValidatorConfig.ValidRegexp.Base) field.get(config.getValidRegexp());
            regexp = valid.getRegexp();
            AnnotationUtil.updateAnnotationValue(
                    constraintAnno,
                    "message",
                    valid.getMessage()
            );
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        return s != null && s.matches(regexp);
    }
}
public class StateValidator implements ConstraintValidator<ValidState, String> {
    @Autowired
    private ValidatorConfig validatorConfig;
    List<String> states;
    @Override
    public void initialize(ValidState constraintAnnotation) {
        try {
            System.out.println("initialize StateValidator");
            String type = constraintAnnotation.type();
            Field field = ValidatorConfig.ValidState.class.getDeclaredField(type);
            field.setAccessible(true);
            states = (List<String>) field.get(validatorConfig.getValidState());
            AnnotationUtil.updateAnnotationValue(
                    constraintAnnotation,
                    "message",
                    "只能是枚举常量" + states.toString()
            );
        } catch (IllegalAccessException | NoSuchFieldException e) {
            throw new RuntimeException(e);
        }
    }
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        return s == null || s.isEmpty() || states.contains(s);
    }
}

两个校验器大同小异。这里解释 initialize 中的操作:

  • 首先从注解中获取我们自定义的参数 type ,接着通过反射,获取配置类中对应校验器子类中的 type 域。(例如
    @ValidRegexp(type="tel") 注解,获取的是 ValidatorConfig.ValidRegexp.tel
  • 获取 type 指定的配置内容,存入校验器成员变量,这样在 isValid 才可以使用。
  • 更新校验注解的 message 的值

前两条比较平凡。重新设置校验注解的 message 我封装进另外一个方法:

public class AnnotationUtil {
    public static void updateAnnotationValue(Annotation annotation, String key, Object newValue) {
        try {
            InvocationHandler handler = Proxy.getInvocationHandler(annotation);
            Field memberValuesField = handler.getClass().getDeclaredField("memberValues");
            memberValuesField.setAccessible(true);
            Map<String, Object> memberValues = (Map<String, Object>) memberValuesField.get(handler);
            memberValues.put(key, newValue);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

注解是通过动态代理实现的,想要在运行时修改注解定义中的值,需要先获取代理类。关于动态代理本文不需要细说,而以上的代码也很好理解。

这样,我们实现了:

  • 通过在注解上指定 type 字段,设置校验器的行为
  • 从配置文件中载入 type 对应的校验参数和错误信息

然而,还有一点是需要完善的。
实现到这里,我们触发一下校验错误,在拦截器中看一下错误消息:

错误信息很冗长,最后字段 message 告示我们,校验错误消息仍然是默认值。
我们还需要修改一下拦截器。

# 拦截校验错误并格式化错误消息

简单说说为什么上面的 message 没有被更新。一句话就是, Validation 模块的初始化过程是在 Validator::initialize 之前进行的。
Validation 在 SpringBoot 项目启动后就会被初始化,包括将注解目标添加到校验列表、设置错误信息等。而 Validator::initialize
实际上是在校验过程中才触发(通过调试可以发现)。
我们只更新了注解的 message ,但是抛出的校验错误是由 Validation 模块包装的,两者没有引用关系。

我的思路是,在异常拦截器里,根据校验异常,重新定位校验注解,获取其 message

拦截器新增针对 MethodArgumentNotValidException 的方法:

@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidationException(MethodArgumentNotValidException e) {
    String fieldName = Objects.requireNonNull(e.getBindingResult().getFieldError()).getField();
    String errorMessage = ValidationUtil.getValidationErrorMessage(e);
    return Result.error(-101, fieldName + "校验失败," + errorMessage);
}

其中,封装了 getValidationErrorMessage ,获取注解的错误信息。

public class ValidationUtil {
    static public String getValidationErrorMessage(MethodArgumentNotValidException e) {
        try {
            FieldError filedError = e.getBindingResult().getFieldError();
            Field source = ObjectError.class.getDeclaredField("source");
            source.setAccessible(true);
            var constraintViolation = (ConstraintViolationImpl) source.get(filedError);
            Annotation anno = constraintViolation.getConstraintDescriptor().getAnnotation();
            if (anno instanceof ValidState || anno instanceof ValidRegexp) {
                Method messageMethod = anno.getClass().getMethod("message");
                return (String) messageMethod.invoke(anno);
            } else if (filedError != null) {
                return filedError.getDefaultMessage();
            } else {
                return "未知的校验错误";
            }
        } catch (NoSuchFieldException | IllegalAccessException | InvocationTargetException | NoSuchMethodException ex) {
            throw new RuntimeException(ex);
        }
    }
}

这里仍然是使用大量反射实现的,定位注解需要调试过程


大概就是这样的层次结构

获取错误校验对应的注解之后,顺便判断下其类名,对于非自定义的校验类,还可以返回默认错误信息。

至此,错误信息可以按照我们的配置文件返回,所有需求解决了

更新于 阅读次数