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

相关推荐
爬山算法16 小时前
Hibernate(85)如何在持续集成/持续部署(CI/CD)中使用Hibernate?
java·ci/cd·hibernate
indexsunny18 小时前
互联网大厂Java面试实战:微服务与Spring生态技术解析
java·spring boot·redis·kafka·mybatis·hibernate·microservices
爬山算法19 小时前
Hibernate(86)如何在性能测试中使用Hibernate?
java·后端·hibernate
爬山算法1 天前
Hibernate(84)如何在DevOps流程中使用Hibernate?
oracle·hibernate·devops
爬山算法3 天前
Hibernate(81)如何在数据同步中使用Hibernate?
java·后端·hibernate
爬山算法4 天前
Hibernate(79)如何在ETL流程中使用Hibernate?
java·hibernate·etl
爬山算法4 天前
Hibernate(80) 如何在数据迁移中使用Hibernate?
java·oracle·hibernate
爬山算法5 天前
Hibernate(78)如何在GraphQL服务中使用Hibernate?
java·hibernate·graphql
爬山算法6 天前
Hibernate(74)如何在CQRS架构中使用Hibernate?
java·架构·hibernate
爬山算法6 天前
Hibernate(76)如何在混合持久化环境中使用Hibernate?
java·后端·hibernate