# Java SpringBoot 自定义校验注解 + 动态载入校验配置与错误信息
最近实习实训课程在做 SpringBoot 后端项目,其中要对前端传入的请求做校验。
通过查询网上资料很快能实现自定义注解,但是对于增强可维护性、格式化错误消息方面,需要更多工作。
# 新的需求
我希望实现下面的效果:
- 实现自定义正则校验注解,正则式可以复用,无需每个注解重新写一遍相同的正则表达式
- 错误消息也可以复用
- 可以通过配置文件修改校验注解中的参数和错误消息
# 实现过程
# 【预备知识】速览:自定义注解的实现
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); | |
} | |
} | |
} |
这里仍然是使用大量反射实现的,定位注解需要调试过程
大概就是这样的层次结构
获取错误校验对应的注解之后,顺便判断下其类名,对于非自定义的校验类,还可以返回默认错误信息。
至此,错误信息可以按照我们的配置文件返回,所有需求解决了