离谱!加了一个 @NotNull,接口竟然返回两条重复报错?

问题现象

有个项目新增了一个接口,这个接口的请求参数里面定义了一个字段,这个字段使用了 @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 ,如下图所示:

这个问题定位了很久没有找到原因,所以当时就在 GlobalAdvicehandleException() 做了一下去重处理。代码如下:

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 的原因,在上面的代码中,虽然是在 TestDTOmessage 字段上使用的 @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 版本里面生这个信息是在 ConstraintViolationImplcreateConstraintViolation() 方法中实现的。代码如下:

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 版本里,ConstraintViolationImplcreateHashCode() 方法是包含了 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 版本里,ConstraintViolationImplcreateHashCode() 方法把 elementType 给移除了,那么字段和 getter() 方法创建对象计算出来的 hashCode 是不一样的,从而达到了去重的目的。如下图所示:

通过在 6.2.0.Final 版本实际调试后发现,字段和 getter() 方法生成的校验对象的 hashCode值是一样,这样在 ValidationContext 中的 failingConstraintViolations 属性中最终只会存放一个对象,接口的返回值也会只有一个,不会有重复的错误提示了。如下图所示:

相关推荐
babywew12 天前
探索基于人工势场的无人车避障路径算法
hibernate
.生产的驴4 天前
泛微E10二开 组织架构、人员信息、分部信息基本操作
java·jvm·spring·架构·tomcat·intellij-idea·hibernate
杀死那个蝈坦7 天前
Docker
java·docker·eclipse·tomcat·hibernate
maycho12312 天前
基于遗传算法GA算法的BP神经网络优化 非线性函数拟合 可用于参数反演 matlab源代码 代...
hibernate
6***092613 天前
Spring 中集成Hibernate
java·spring·hibernate
来旺13 天前
互联网大厂Java面试实战:核心技术栈与业务场景深度解析
java·spring boot·docker·kubernetes·mybatis·hibernate·microservices
信码由缰18 天前
Spring Data JPA 最佳实践【2/2】:存储库设计指南
hibernate
S90378459718 天前
为什么取模在除数等于2^n的时候可以用按位与替代?
java·tomcat·计算机外设·hibernate
j***827023 天前
Spring 中集成Hibernate
java·spring·hibernate