BindingResult 方法参数的存在确定抛出的异常?

Presence of BindingResult method parameter determines exception thrown?

我有一个 Spring @RestController 有一个 POST 端点定义如下:

@RestController
@Validated
@RequestMapping("/example")
public class Controller {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ResponseEntity<?> create(@Valid @RequestBody Request request,
                                    BindingResult _unused, // DO NOT DELETE
                                    UriComponentsBuilder uriBuilder) {
        // ...
    }
}

它还有一个异常处理程序 javax.validation.ConstraintViolationException:

@ExceptionHandler({ConstraintViolationException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
ProblemDetails handleValidationError(ConstraintViolationException e) {...}

我们的 Spring-Boot 应用程序正在使用 spring-boot-starter-validation 进行验证。 Request 对象使用 javax.validation.* 注释将约束应用于各个字段,如下所示:

public class Request {

    private Long id;

    @Size(max = 64, message = "name length cannot exceed 64 characters")
    private String name;

    // ...
}

如上所述,如果您 POST 一个带有无效 Request 的请求,验证将抛出一个 ConstraintViolationException,它将由异常处理程序处理。这行得通,我们对其进行了单元测试,一切都很好。

我注意到 post 方法中的 BindingResult 没有被使用(名称 _unused 和评论 //DO NOT DELETE 有点危险。)我去了提前并删除了参数。突然间,我的测试失败了——入站请求仍然得到验证,但它不再抛出 ConstraintValidationException ......现在它抛出 MethodArgumentNotValidException!不幸的是我不能使用这个其他异常,因为它不包含我需要的格式的失败验证(也不包含我需要的所有数据)。

为什么参数列表中的 BindingResult 控制抛出哪个异常?如何在 javax.validation 确定请求正文无效时删除未使用的变量并仍然抛出 ConstraintViolationException


Spring-引导 2.5.5

OpenJDK 17.

来自规范:

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/MethodArgumentNotValidException.html

@Valid @RequestBody Request request

如果您的对象无效,我们总是会收到 MethodArgumentNotValidException。这里的区别取决于 BindingResult ...

没有 BindingResult,将按预期抛出 MethodArgumentNotValidException。

使用BindingResult,错误将被插入到BindingResult。我们经常需要检查 bindresult 是否有错误并对其进行处理。

if (bindingResult.hasErrors()) {  
    // handle error or create bad request status
}

BindingResult:“表示绑定结果的通用接口。扩展错误注册功能的接口,允许应用验证器,并添加特定于绑定的分析和模型构建。”

您可以再次检查绑定结果中的错误。 我没有看到完整的代码,所以我不知道哪个是 ConstraintViolationException 的原因,但我猜你跳过了绑定结果中的错误并继续将实体插入数据库并违反了一些约束...

我不知道控制器方法中 BindingResult 的存在可以修改抛出的异常类型,因为我以前从未将它作为参数添加到控制器方法中。我通常看到的是 MethodArgumentNotValidException 因请求 body 验证失败而抛出, ConstraintViolationException 因请求参数、路径变量和 header 值违规而抛出。 MethodArgumentNotValidException 中错误详细信息的格式可能与 ConstraintViolationException 中的格式不同,但它通常包含您需要的有关错误的所有信息。以下是我为您的控制器编写的异常处理程序 class:

package com.example.demo;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
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;

@RestControllerAdvice
public class ControllerExceptionHandler {
    public static final Logger LOGGER = LoggerFactory.getLogger(ControllerExceptionHandler.class);

    @ExceptionHandler({ ConstraintViolationException.class })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationError(ConstraintViolationException exception) {
        LOGGER.warn("ConstraintViolationException thrown", exception);
        Map<String, Object> response = new HashMap<>();
        List<Map<String, String>> errors = new ArrayList<>();

        for (ConstraintViolation<?> violation : exception.getConstraintViolations()) {
            Map<String, String> transformedError = new HashMap<>();
            
            String fieldName = violation.getPropertyPath().toString();
            transformedError.put("field", fieldName.substring(fieldName.lastIndexOf('.') + 1));
            transformedError.put("error", violation.getMessage());

            errors.add(transformedError);
        }
        response.put("errors", errors);

        return response;
    }

    @ExceptionHandler({ MethodArgumentNotValidException.class })
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleValidationError(MethodArgumentNotValidException exception) {
        LOGGER.warn("MethodArgumentNotValidException thrown", exception);
        Map<String, Object> response = new HashMap<>();

        if (exception.hasFieldErrors()) {
            List<Map<String, String>> errors = new ArrayList<>();

            for (FieldError error : exception.getFieldErrors()) {
                Map<String, String> transformedError = new HashMap<>();
                transformedError.put("field", error.getField());
                transformedError.put("error", error.getDefaultMessage());

                errors.add(transformedError);
            }
            response.put("errors", errors);
        }

        return response;
    }
}

它将 MethodArgumentNotValidExceptionConstraintViolationException 转换为下面相同的错误响应 JSON:

{
    "errors": [
        {
            "field": "name",
            "error": "name length cannot exceed 64 characters"
        }
    ]
}

ConstraintViolationException 相比,您在 MethodArgumentNotValidException 中缺少哪些信息?

此处涉及两层验证,按以下顺序发生:

  1. 控制器层 :

    • 当控制器方法的参数用 @RequestBody@ModelAttribute@Valid@Validated 或名称以“有效”开头的任何注释注释时启用(逻辑参考this)。
    • 基于 DataBinder 内容
    • 只能验证请求
    • 如果出现验证错误并且控制器方法中没有 BindingResult 参数,则抛出 org.springframework.web.bind.MethodArgumentNotValidException。否则,继续使用 BindingResult 参数调用控制器方法,捕获验证错误信息。
  2. Bean的方法层 :

    • 启用 spring bean,如果它用 @Validated 注释并且方法参数或返回值仅用 bean 验证注释注释,例如 @Valid@Size
    • 基于 AOP 的东西。方法拦截器是MethodValidationInterceptor
    • 可以验证请求和响应
    • 如果验证错误,抛出 javax.validation.ConstraintViolationException

最后两层中的验证将委托给 bean 验证来执行实际验证。

因为控制器实际上是一个 spring bean,所以在调用控制器方法时,两层中的验证都可以生效,这正是您的案例所证明的,并发生以下情况:

  1. DataBinder 验证请求不正确,但由于控制器方法有 BindingResult 参数,它跳过抛出 MethodArgumentNotValidException 并继续调用控制器方法

  2. MethodValidationInterceptor 验证请求不正确,并抛出 ConstraintViolationException

文档没有明确提到这种行为。以上是我阅读源码后的总结。我同意这令人困惑,尤其是在您的情况下,当在两个层中都启用验证并且还使用 BindingResult 参数时。你可以看到bean验证实际上验证了两次请求,这听起来很尴尬......

因此,要解决您的问题,您可以在控制器层的 DataBinder 中禁用验证,并始终依赖于 bean 方法级别的验证。您可以使用以下 @InitBinder 方法创建 @ControllerAdvice

@ControllerAdvice
public class InitBinderControllerAdvice {

    @InitBinder
    private void initBinder(WebDataBinder binder) {
        binder.setValidator(null);
    }
} 
    

然后即使从控制器方法中删除 BindingResult,它也应该抛出 ConstraintViolationException.