离谱!加了一个 @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 属性中最终只会存放一个对象,接口的返回值也会只有一个,不会有重复的错误提示了。如下图所示:

相关推荐
indexsunny1 天前
互联网大厂Java求职面试实战:微服务与Spring Boot在电商场景中的应用
java·数据库·spring boot·微服务·kafka·hibernate·电商
爬山算法2 天前
Hibernate(32)什么是Hibernate的Criteria查询?
java·python·hibernate
爬山算法2 天前
Hibernate(31)Hibernate的原生SQL查询是什么?
数据库·sql·hibernate
爬山算法3 天前
Hibernate(30)Hibernate的Named Query是什么?
服务器·前端·hibernate
爬山算法3 天前
Hibernate(29)什么是Hibernate的连接池?
java·后端·hibernate
indexsunny3 天前
互联网大厂Java面试实战:基于电商场景的Spring Boot与微服务技术问答
java·spring boot·微服务·面试·hibernate·电商场景·技术问答
Mr.Entropy4 天前
JdbcTemplate 性能好,但 Hibernate 生产力高。 如何选择?
java·后端·hibernate
爬山算法5 天前
Hibernate(26)什么是Hibernate的透明持久化?
java·后端·hibernate
爬山算法5 天前
Hibernate(25)Hibernate的批量操作是什么?
java·后端·hibernate
爬山算法6 天前
Hibernate(24)Hibernate如何实现乐观锁?
java·后端·hibernate