自定义注解实现数据验证

1、创建约束目标

常见数据验证中,非空、长度等已经有内置的验证器,而对于枚举类型数据值的合法性约束缺失一个合适的方式,选择通过自定义验证器+注解约束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public enum CaseMode {
UPPER(0),
LOWER(1),
;

private final int code;

CaseMode(int code) {
this.code = code;
}

public int getCode() {
return code;
}

public static boolean isValid(int other) {
for (CaseMode cas : CaseMode.values()) {
if (cas.code == other) {
return true;
}
}
return false;
}
}

2、定义约束注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import cn.probiecoder.springjavademo.validator.CaseValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CaseValidator.class)
@Documented
public @interface ValidCase {
/**
* 验证不通过时的,错误消息模板
*/
String message() default "{com.mycompany.constraints.checkcase}";

/**
* 验证标签,验证时可以指定 group 进行验证
* 不指定时,划分在默认组 `jakarta.validation.groups.Default`
*/
Class<?>[] groups() default {};

/**
* 载荷信息,可以添加额外的元数据信息
*/
Class<? extends Payload>[] payload() default {};
}
  • message:必须,定义默认消息模板,约束条件验证失败的时候,通过此属性来输出错误信息

  • groups:必须,指定约束条件属于哪些校验组,可以使用字符串或者一个空的无定义的interface,建议采用 interface;

    • validator.validate()不指定group时,
  • payload:必须,Bean Validation API 的使用者可以通过此属性来给约束条件指定严重级别. 这个属性并不被API自身所使用,或者增加额外的元数据信息

3、定义约束校验器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import cn.probiecoder.springjavademo.annotations.ValidCase;
import cn.probiecoder.springjavademo.enums.CaseMode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class CaseValidator implements ConstraintValidator<ValidCase, Integer> {
@Override
public void initialize(ValidCase constraintAnnotation) {
// 初始化方法,会在 isValid前执行
}

@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return CaseMode.isValid(value);
}
}

  • 实现 ConstraintValidator 接口,继承 isValid 定义验证规则
  • initialize方法为接口默认实现方法,如果不需要在验证前进行初始化,可以不实现
  • isValid验证方法,验证逻辑需要在这里实现
  • 对于null值,Bean Validation推荐按照合法的值进行处理,如果值非空通过 @NotNull 或者 @NotBlank 验证。那么如果需要缺省值有什么好方法呢?

4、示例

4.1 所有属性验证采用默认group

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import cn.probiecoder.springjavademo.annotations.ValidCase;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class CaseModeDTO {
@NotBlank
private String a;
@Negative
private Integer b;
@Size(min=1, max = 10)
private String c;
@Min(3)
private String d;
@ValidCase
private Integer mode;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();

var dto = new CaseModeDTO();
dto.setA("a");
dto.setB(-3);
dto.setC("CCC");
dto.setD(2);
dto.setMode(CaseMode.LOWER.getCode());
var violations = validator.validate(dto);
violations.forEach(System.out::println);
}

属性D要求最小为3,设置为2,不符合验证规则,消息输出 must be greater than or equal to 3

4.2 属性指定不同 group,验证默认group(不指定group)

1
2
3
4
5
6
7
8
9
10
11
12
public class CaseModeDTO {
@NotBlank
private String a;
@Negative(groups = InsertChecks.class)
private Integer b;
@Size(min=1, max = 10, groups = InsertChecks.class)
private String c;
@Min(value = 3, groups = UpdateChecks.class)
private Integer d;
@ValidCase(groups = UpdateChecks.class)
private Integer mode;
}
1
2
3
4
5
6
7
8
9
10
11
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();

var dto = new CaseModeDTO();
dto.setB(3);
dto.setC("CCC");
dto.setD(4);
dto.setMode(2);
var violations = validator.validate(dto);
violations.forEach(System.out::println);
}

输出结果:

1
2
3
4
ConstraintViolationImpl{interpolatedMessage='must be less than 0', propertyPath=b, rootBeanClass=class cn.probiecoder.springjavademo.dto.CaseModeDTO, messageTemplate='{jakarta.validation.constraints.Negative.message}'}

ConstraintViolationImpl{interpolatedMessage='must not be blank', propertyPath=a, rootBeanClass=class cn.probiecoder.springjavademo.dto.CaseModeDTO, messageTemplate='{jakarta.validation.constraints.NotBlank.message}'}

验证时不指定group,自定义的验证器未校验,内置的验证器被执行。

4.3 属性c和mode指定group,验证指定group

1
2
3
4
5
6
7
8
9
10
11
12
public class CaseModeDTO {
@NotBlank
private String a;
@Negative(groups = InsertChecks.class)
private Integer b;
@Size(min=1, max = 10, groups = UpdateChecks.class)
private String c;
@Min(value = 3, groups = UpdateChecks.class)
private Integer d;
@ValidCase(groups = UpdateChecks.class)
private Integer mode;
}
1
2
3
4
5
6
7
8
9
10
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();

var dto = new CaseModeDTO();
dto.setC("CCC");
dto.setD(4);
dto.setMode(2);
var violations = validator.validate(dto, UpdateChecks.class);
violations.forEach(System.out::println);
}

输出结果与期望相同:只验证了 group=UpdateChecks.classmode不符合枚举值,被拦截,返回配置的默认消息

1
ConstraintViolationImpl{interpolatedMessage='Invalid case mode', propertyPath=mode, rootBeanClass=class cn.probiecoder.springjavademo.dto.CaseModeDTO, messageTemplate='Invalid case mode'}

5、错误消息配置

5.1 在配置message时,通过default直接指定固定的错误消息

1
String message() default "case mode is invalid";

配置简单,灵活性不强,需要国际化时不能很好处理

5.2 通过消息模板配置

通过 {} 包括的内容会作为消息模板索引进行检索,默认的解析器(MessageInterpolator)解析顺序如下:

  • 先在类路径下查找名称为ValidationMessages.propertiesResourceBundle,然后将占位符和这个文件中定义的resource进行匹配
  • 匹配Hibernate Validator自带的位于/org/hibernate/validator/ValidationMessages.propertiesResourceBundle
1
String message() default "{jakarta.validation.constraints.Size.message}";

5.3 Spring Boot 国际化消息配置

5.3.1 全局异常拦截处理

Spring Boot默认错误处理器(DefaultHandlerExceptionResolver)会按照400 Bad Request处理错误异常,但是无具体错误消息,对于客户端来说无实际意义,服务端需要将错误信息进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package cn.probiecoder.springjavademo.errorhandler;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(BindException.class)
public String bindExceptionHandler(BindException e) {
String message = e.getBindingResult().getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining());
return "{\"errors\":\"" + message + "\"}"; // 此处需要更优解,按照项目约定返回固定结构
}

@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(MethodArgumentNotValidException.class)
public String methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining());
return "{\"errors\":\"" + message + "\"}"; // 此处需要更优解,按照项目约定返回固定结构
}

@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(ConstraintViolationException.class)
public String constraintViolationExceptionHandler(ConstraintViolationException e) {
String message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining());
return "{\"errors\":\"" + message + "\"}"; // 此处需要更优解,按照项目约定返回固定结构
}

}
5.3.2 自定义验证器Validator指定文件位置和命名

此处需要覆盖 MessageResource来指定具体的message文件,hibernate默认在类路径下查找ValidationMessages.properties文件,如果不需要指定其他message文件或不和业务错误message统一,此处也可以不覆写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package cn.probiecoder.springjavademo.config;

import jakarta.validation.Validator;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

import java.nio.charset.StandardCharsets;

@Configuration
public class MessageHandlerConfig {

@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:messages"); // 消息文件格式为 messages_zh_CN.properties 此处指定消息文件前缀
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
return messageSource;
}

@Bean
public Validator getValidator() throws Exception {
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.setValidationMessageSource(messageSource());
return validator;
}
@Bean
public MethodValidationPostProcessor validationPostProcessor() throws Exception {
MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
//指定请求验证器
processor.setValidator(getValidator());
return processor;
}

}
5.3.3 指定全局验证

此处一定需要进行全局异常拦截处理 ConstraintViolationException,否则Spring Boot默认返回400且无具体的错误原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package cn.probiecoder.springjavademo.validator;

import jakarta.annotation.Resource;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.groups.Default;
import org.springframework.stereotype.Component;

import java.util.Set;

@Component
public class AppValidator {
@Resource
private Validator validator;

public <T> void validate(@NotNull T object) {
this.validate(object, Default.class);
}

public <T> void validate(@NotNull T object, @NotNull Class<?>... groups) {
Set<ConstraintViolation<T>> violations = this.validator.validate(object, groups);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
5.3.4 验证
1
2
3
4
// 采用默认的 Default group,目前验证自定义的验证器如果指定group后,采用默认分组不会进行验证,而hibernate内置的验证器在指定group的情况也会生效
appValidator.validate(caseModeDTO);
// 指定具体的group
appValidator.validate(caseModeDTO, UpdateChecks.class);

参考:

1、https://juejin.cn/post/6979165353481863182

2、https://gist.github.com/aoudiamoncef/9eeece142d1ef0faa4d06216a41282a2

3、https://docs.jboss.org/hibernate/validator/4.2/reference/zh-CN/html/


自定义注解实现数据验证
https://probiecoder.cn/java/data_valid_by_annotation.html
作者
duwei
发布于
2025年4月22日
许可协议