问题现象
有个项目新增了一个接口,这个接口的请求参数里面定义了一个字段,这个字段使用了 @NotNull 注解修饰,同时这个对象上使用了 Lombok 的 @Data 注解修饰。然后调用这个接口的时候提示信息有重复的。如下图所示: 
问题复现
首先定义了一个 TestDTO,它的类上使用了 @Data 注解修饰,它的字段上使用 @NotNull 注解修饰。代码如下:
java
@Data
public class TestDTO {
@NotNull(message = "消息不能为空")
private String message;
}
然后是 HelloController,它的 test() 方法的参数使用了 @Valid 注解修饰。代码如下:
java
@RestController
@Validated
public class TestController {
@PostMapping("/test")
public String test(@RequestBody @Valid TestDTO testDTO) {
return "测试";
}
}
然后定义了全局的异常处理器,将 MethodArgumentNotValidException 异常中的的错误信息获取到生成 ApiResponse 并返回。代码如下:
java
@RestControllerAdvice
public class GlobalAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<?> handleException(MethodArgumentNotValidException ex) {
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors();
String defaultMessage = allErrors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));
return ApiResponse.error(400, defaultMessage);
}
}
项目依赖的 lombok 版本是 1.18.24 ,如下图所示: 
依赖的 Hibernate Validator 的版本是 6.0.22 ,如下图所示: 
这个问题定位了很久没有找到原因,所以当时就在 GlobalAdvice 的 handleException() 做了一下去重处理。代码如下:
java
@RestControllerAdvice
public class GlobalAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<?> handleException(MethodArgumentNotValidException ex) {
// 这里做了一个去重处理
List<ObjectError> allErrors = ex.getBindingResult().getAllErrors().stream().distinct().collect(Collectors.toList());
String defaultMessage = allErrors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));
return ApiResponse.error(400, defaultMessage);
}
}
去重后接口返回的错误提示信息不重复了,如下图所示: 
问题原因
Lombok 版本
首先是 lombok 的原因,在上面的代码中,虽然是在 TestDTO 的 message 字段上使用的 @NotNull 注解修饰的,但是 lombok 在生成它的 getter() 和 setter() 方法时,会把字段上的注解也复制到方法的参数上,这样在字段和方法参数上都有 @NotNull 注解修饰了。如下图所示: 
在 lombok 的 HandlerUtil 里面定义了 BASE_COPYABLE_ANNOTATIONS 的一个名单,在这个名单里面的注解在生成 getter() 或者 setter() 会进行拷贝,在 lombok 的 1.18.24 版本是配置了 javax.validation.constraints.NotNull 的。如下图所示: 
这个注解是2021年10月份加进去的,如下图所示: 
在2022年5月份被移除了,如下图所示: 
Hibernate Validator 版本
其次是 Hibernate Validator 的版本,在 Hibernate Validator 中是通过 ConstraintViolationImpl 对象来表示的校验错误信息。在 6.0.22 版本里面生这个信息是在 ConstraintViolationImpl 的 createConstraintViolation() 方法中实现的。代码如下:
java
public Set<ConstraintViolation<T>> createConstraintViolations(ValueContext<?, ?> localContext,
ConstraintValidatorContextImpl constraintValidatorContext) {
return constraintValidatorContext.getConstraintViolationCreationContexts().stream()
.map( c -> createConstraintViolation( localContext, c, constraintValidatorContext.getConstraintDescriptor() ) )
.collect( Collectors.toSet() );
}
public ConstraintViolation<T> createConstraintViolation(ValueContext<?, ?> localContext, ConstraintViolationCreationContext constraintViolationCreationContext, ConstraintDescriptor<?> descriptor) {
String messageTemplate = constraintViolationCreationContext.getMessage();
String interpolatedMessage = interpolate(
messageTemplate,
localContext.getCurrentValidatedValue(),
descriptor,
constraintViolationCreationContext.getPath(),
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables()
);
// at this point we make a copy of the path to avoid side effects
Path path = PathImpl.createCopy( constraintViolationCreationContext.getPath() );
Object dynamicPayload = constraintViolationCreationContext.getDynamicPayload();
switch ( validationOperation ) {
case PARAMETER_VALIDATION:
return ConstraintViolationImpl.forParameterValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
executableParameters,
dynamicPayload
);
case RETURN_VALUE_VALIDATION:
return ConstraintViolationImpl.forReturnValueValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
executableReturnValue,
dynamicPayload
);
default:
return ConstraintViolationImpl.forBeanValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
dynamicPayload
);
}
}
最终所有的校验结果都是放在 ValidationContext 中的 failingConstraintViolations 属性中,而它是一个 Set 类型,那就会根据对象的 hashCode 值是否是同一个对象。代码如下:
java
public class ValidationContext<T> {
private final Set<ConstraintViolation<T>> failingConstraintViolations;
public void addConstraintFailures(Set<ConstraintViolation<T>> failingConstraintViolations) {
this.failingConstraintViolations.addAll( failingConstraintViolations );
}
}
而在 6.0.22 版本里,ConstraintViolationImpl 的 createHashCode() 方法是包含了 elementType 的,那么字段和 getter() 方法创建对象计算出来的 hashCode 是不一样的。代码如下:
java
private int createHashCode() {
int result = interpolatedMessage != null ? interpolatedMessage.hashCode() : 0;
result = 31 * result + ( propertyPath != null ? propertyPath.hashCode() : 0 );
result = 31 * result + System.identityHashCode( rootBean );
result = 31 * result + System.identityHashCode( leafBeanInstance );
result = 31 * result + System.identityHashCode( value );
result = 31 * result + ( constraintDescriptor != null ? constraintDescriptor.hashCode() : 0 );
result = 31 * result + ( messageTemplate != null ? messageTemplate.hashCode() : 0 );
result = 31 * result + ( elementType != null ? elementType.hashCode() : 0 );
return result;
}
但是在 6.2.0.Final 版本里,ConstraintViolationImpl 的 createHashCode() 方法把 elementType 给移除了,那么字段和 getter() 方法创建对象计算出来的 hashCode 是不一样的,从而达到了去重的目的。如下图所示: 
通过在 6.2.0.Final 版本实际调试后发现,字段和 getter() 方法生成的校验对象的 hashCode值是一样,这样在 ValidationContext 中的 failingConstraintViolations 属性中最终只会存放一个对象,接口的返回值也会只有一个,不会有重复的错误提示了。如下图所示: 
