REST 控制器中的 Spring Boot 绑定和验证错误处理

Posted

技术标签:

【中文标题】REST 控制器中的 Spring Boot 绑定和验证错误处理【英文标题】:Spring Boot binding and validation error handling in REST controller 【发布时间】:2016-04-16 03:39:02 【问题描述】:

当我有以下带有 JSR-303(验证框架)注释的模型时:

public enum Gender 
    MALE, FEMALE


public class Profile 
    private Gender gender;

    @NotNull
    private String name;

    ...

以及以下 JSON 数据:

 "gender":"INVALID_INPUT" 

在我的 REST 控制器中,我想同时处理绑定错误(gender 属性的枚举值无效)和验证错误(name 属性不能为空)。

以下控制器方法不起作用:

@RequestMapping(method = RequestMethod.POST)
public Profile insert(@Validated @RequestBody Profile profile, BindingResult result) 
    ...

这会在绑定或验证发生之前产生com.fasterxml.jackson.databind.exc.InvalidFormatException 序列化错误。

经过一番折腾,我想出了这个自定义代码,它可以满足我的需求:

@RequestMapping(method = RequestMethod.POST)
public Profile insert(@RequestBody Map values) throws BindException 

    Profile profile = new Profile();

    DataBinder binder = new DataBinder(profile);
    binder.bind(new MutablePropertyValues(values));

    // validator is instance of LocalValidatorFactoryBean class
    binder.setValidator(validator);
    binder.validate();

    // throws BindException if there are binding/validation
    // errors, exception is handled using @ControllerAdvice.
    binder.close(); 

    // No binding/validation errors, profile is populated 
    // with request values.

    ...

基本上这段代码的作用是序列化为通用映射而不是模型,然后使用自定义代码绑定到模型并检查错误。

我有以下问题:

    自定义代码是这里的方式还是在 Spring Boot 中有更标准的方式来执行此操作? @Validated 注释如何工作?如何制作自己的自定义注解,类似于 @Validated 来封装我的自定义绑定代码?

【问题讨论】:

【参考方案1】:

这是我在我的一个项目中使用的用于在 Spring Boot 中验证 REST api 的代码,这与您的要求不同,但相同.. 检查这是否有帮助

@RequestMapping(value = "/person/id",method = RequestMethod.PUT)
@ResponseBody
public Object updatePerson(@PathVariable Long id,@Valid Person p,BindingResult bindingResult)
    if (bindingResult.hasErrors()) 
        List<FieldError> errors = bindingResult.getFieldErrors();
        List<String> message = new ArrayList<>();
        error.setCode(-2);
        for (FieldError e : errors)
            message.add("@" + e.getField().toUpperCase() + ":" + e.getDefaultMessage());
        
        error.setMessage("Update Failed");
        error.setCause(message.toString());
        return error;
    
    else
    
        Person person = personRepository.findOne(id);
        person = p;
        personRepository.save(person);
        success.setMessage("Updated Successfully");
        success.setCode(2);
        return success;
    

Success.java

public class Success 
int code;
String message;

public int getCode() 
    return code;


public void setCode(int code) 
    this.code = code;


public String getMessage() 
    return message;


public void setMessage(String message) 
    this.message = message;


Error.java

public class Error 
int code;
String message;
String cause;

public int getCode() 
    return code;


public void setCode(int code) 
    this.code = code;


public String getMessage() 
    return message;


public void setMessage(String message) 
    this.message = message;


public String getCause() 
    return cause;


public void setCause(String cause) 
    this.cause = cause;



你也可以看看这里:Spring REST Validation

【讨论】:

这不适用于@RequestBody,例如当接收到一些JSON - 否则对于基于REST 的Web 服务来说是非常常见的情况(也是问题的重点)。【参考方案2】:

通常当 Spring MVC 无法读取 http 消息(例如请求正文)时,它会抛出一个 HttpMessageNotReadableException 异常的实例。因此,如果 spring 无法绑定到您的模型,它应该抛出该异常。此外,如果您在方法参数中NOT在每个待验证模型之后定义BindingResult,则在验证错误的情况下,spring 将抛出MethodArgumentNotValidException 异常。有了这一切,您可以创建ControllerAdvice 来捕获这两个异常并以您想要的方式处理它们。

@ControllerAdvice(annotations = RestController.class)
public class UncaughtExceptionsControllerAdvice 
    @ExceptionHandler(MethodArgumentNotValidException.class, HttpMessageNotReadableException.class)
    public ResponseEntity handleBindingErrors(Exception ex) 
        // do whatever you want with the exceptions
    

【讨论】:

这里的缺点是发生绑定错误时,您不会得到 BindingResult。 IE。你可以对MethodArgumentNotValidException 异常执行ex.getBindingResult(),但不能对HttpMessageNotReadableException 异常执行。 后者似乎是合理的,因为当绑定失败时,我们无法得到绑定结果。没有绑定。 在我看来,绑定错误(例如将字符串放入 int 字段或错误的 Enum 值)应视为验证错误。使用 DataBinder 独立也绑定字段错误在 BindingResult 中,因此服务可以返回更详细的错误响应。 我同意。当有人为枚举提供无效值时,我在尝试正确显示 GOOD 错误消息时遇到问题。 Jackson 将它包装起来,我最终得到了一个非常通用的 HttpMessageNotReadableException。里面的消息包括java包和类信息。不能接受的。我想知道失败的领域以及失败的原因,但我找不到任何方法来做到这一点。我试过关闭 WRAP_EXCEPTIONS 设置,但似乎没有任何效果【参考方案3】:

您无法使用@RequestBody 获得BindException。不在带有Errors 方法参数的控制器中,如下所述:

Errors, BindingResult 用于从验证和数据中访问错误 绑定命令对象(即 @ModelAttribute 参数)或 来自 @RequestBody 或 @RequestPart 验证的错误 论据。您必须声明一个错误或 BindingResult 参数 紧跟在验证的方法参数之后。

它指出对于@ModelAttribute,您会收到绑定和验证错误,对于您的@RequestBody,您只会收到验证错误

https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-methods

这里讨论过:

https://github.com/spring-projects/spring-framework/issues/11406?jql=text%2520~%2520%2522RequestBody%2520binding%2522

对我来说,从用户的角度来看它仍然没有意义。让 BindExceptions 向用户显示正确的错误消息通常非常重要。论据是,无论如何您都应该进行客户端验证。但如果开发人员直接使用 API,情况就不是这样了。

假设您的客户端验证基于 API 请求。您想根据保存的日历检查给定日期是否有效。您将日期和时间发送到后端,但它只是失败了。

您可以使用 ExceptionHAndler 对 HttpMessageNotReadableException 做出反应来修改您得到的异常,但是对于这个异常,我无法像使用 BindException 一样正确访问哪个字段引发错误。我需要解析异常消息才能访问它。

所以我没有看到任何解决方案,这有点糟糕,因为使用@ModelAttribute 很容易出现绑定和验证错误。

【讨论】:

【参考方案4】:

我已经放弃了;在没有大量自定义代码的情况下,使用@RequestBody 是不可能得到绑定错误的。这与绑定到普通 JavaBeans 参数的控制器不同,因为 @RequestBody 使用 Jackson 来绑定而不是 Spring 数据绑定器。

见https://jira.spring.io/browse/SPR-6740?jql=text%20~%20%22RequestBody%20binding%22

【讨论】:

【参考方案5】:

解决此问题的主要障碍之一是杰克逊数据绑定器的默认急切失败性质;必须以某种方式说服它继续解析,而不是在第一个错误时绊倒。人们还必须收集这些解析错误,以便最终将它们转换为BindingResult 条目。基本上,必须catchsuppresscollect 解析异常,convert 它们到BindingResult 条目然后将这些条目添加到右侧 @Controller 方法 BindingResult 参数。

catch & suppress 部分可以通过以下方式完成:

自定义 jackson 反序列化器,它会简单地委托给默认相关的反序列化器,但也会捕获、抑制和收集它们的解析异常 使用AOP(aspectj 版本)可以简单地拦截解析异常的默认反序列化程序,抑制并收集它们 使用其他方式,例如适当的BeanDeserializerModifier,也可以捕获、抑制和收集解析异常;这可能是最简单的方法,但需要对杰克逊特定的自定义支持有一些了解

收集部分可以使用ThreadLocal变量来存储所有必要的异常相关细节。 conversionBindingResult 条目和右侧BindingResult 参数的添加 可以通过AOP 拦截器在@Controller 方法(任何类型AOP,包括 Spring 变体)。

有什么收获

通过这种方法,可以将数据 binding 错误(除了 validation 错误)放入 BindingResult 参数中,与获取它们时所期望的方式相同使用例如@ModelAttribute。它也适用于多个级别的嵌入式对象 - 问题中提出的解决方案不会很好地解决这个问题。

解决方案详情自定义杰克逊反序列化器方法)

我创建了一个small project proving the solution(运行测试类),而这里我只强调主要部分:

/**
* The logic for copying the gathered binding errors 
* into the @Controller method BindingResult argument.
* 
* This is the most "complicated" part of the project.
*/
@Aspect
@Component
public class BindingErrorsHandler 
    @Before("@within(org.springframework.web.bind.annotation.RestController)")
    public void logBefore(JoinPoint joinPoint) 
        // copy the binding errors gathered by the custom
        // jackson deserializers or by other means
        Arrays.stream(joinPoint.getArgs())
                .filter(o -> o instanceof BindingResult)
                .map(o -> (BindingResult) o)
                .forEach(errors -> 
                    JsonParsingFeedBack.ERRORS.get().forEach((k, v) -> 
                        errors.addError(new FieldError(errors.getObjectName(), k, v, true, null, null, null));
                    );
                );
        // errors copied, clean the ThreadLocal
        JsonParsingFeedBack.ERRORS.remove();
    


/**
 * The deserialization logic is in fact the one provided by jackson,
 * I only added the logic for gathering the binding errors.
 */
public class CustomIntegerDeserializer extends StdDeserializer<Integer> 
    /**
    * Jackson based deserialization logic. 
    */
    @Override
    public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException 
        try 
            return wrapperInstance.deserialize(p, ctxt);
         catch (InvalidFormatException ex) 
            gatherBindingErrors(p, ctxt);
        
        return null;
    

    // ... gatherBindingErrors(p, ctxt), mandatory constructors ...


/**
* A simple classic @Controller used for testing the solution.
*/
@RestController
@RequestMapping("/errormixtest")
@Slf4j
public class MixBindingAndValidationErrorsController 
    @PostMapping(consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Level1 post(@Valid @RequestBody Level1 level1, BindingResult errors) 
    // at the end I show some BindingResult logging for a @RequestBody e.g.:
    // "nr11":"x","nr12":1,"level2":"nr21":"xx","nr22":1,"level3":"nr31":"xxx","nr32":1
    // ... your whatever logic here ...

有了这些你会得到BindingResult这样的东西:

Field error in object 'level1' on field 'nr12': rejected value [1]; codes [Min.level1.nr12,Min.nr12,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.nr12,nr12]; arguments []; default message [nr12],5]; default message [must be greater than or equal to 5]
Field error in object 'level1' on field 'nr11': rejected value [x]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.level3.nr31': rejected value [xxx]; codes []; arguments []; default message [null]
Field error in object 'level1' on field 'level2.nr22': rejected value [1]; codes [Min.level1.level2.nr22,Min.level2.nr22,Min.nr22,Min.java.lang.Integer,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [level1.level2.nr22,level2.nr22]; arguments []; default message [level2.nr22],5]; default message [must be greater than or equal to 5]

第 1 行由 validation 错误决定(将 1 设置为 @Min(5) private Integer nr12; 的值),而第 2 行由 binding 决定(将"x" 设置为@JsonDeserialize(using = CustomIntegerDeserializer.class) private Integer nr11; 的值)。第 3 行测试 绑定错误 与嵌入对象:level1 包含一个 level2,其中包含一个 level3 对象属性。

注意其他方法如何简单地替换自定义杰克逊反序列化器的使用,同时保留解决方案的其余部分(AOPJsonParsingFeedBack)。

【讨论】:

【参考方案6】:
enter code here
 public class User 

@NotNull
@Size(min=3,max=50,message="min 2 and max 20 characters are alllowed !!")
private String name;

@Email
private String email;

@Pattern(regexp="[7-9][0-9]9",message="invalid mobile number")
@Size(max=10,message="digits should be 10")
private String phone;

@Override
public String toString() 
    return "User [name=" + name + ", email=" + email + ", phone=" + phone + "]";


public String getName() 
    return name;


public void setName(String name) 
    this.name = name;


public String getEmail() 
    return email;


public void setEmail(String email) 
    this.email = email;


public String getPhone() 
    return phone;


public void setPhone(String phone) 
    this.phone = phone;





   Controller.java

    @Controller
    public class User_Controller 

    @RequestMapping("/")
    public String showForm(User u,Model m)
    
    m.addAttribute("user",new User());
    m.addAttribute("title","Validation Form");
    return "register";
    

    @PostMapping("/")
    public String register(@Valid User user,BindingResult bindingResult ,Model m)
    
    if(bindingResult.hasErrors())
    
        return "register";
    
    else 
        m.addAttribute("message", "Registration successfully... ");
    return "register";
    
    
    
 

   register.html
   <div class="container">
   <div class="alert alert-success" role="alert" th:text="$message">
   </div>
   <h1 class="text-center">Validation Form </h1>
   <form action="/" th:action="@/" th:object="$user" method="post">
   <div class="mb-3">
   <label for="exampleInputEmail1" class="form-label">Name</label>
   <input type="text" class="form-control" id="exampleInputEmail1" aria- 
    describedby="emailHelp" th:field="*name">
    <br>
    <p th:if="$#fields.hasErrors('name')" th:errors="*name" class="alert alert- 
    danger"></p>
    </div>
    <div class="mb-3">
    <label for="exampleInputPassword1" class="form-label">Email</label>
     <input type="email" class="form-control" id="exampleInputPassword1" th:field="* 
    email">
    <br>
   <p th:if="$#fields.hasErrors('email')" th:errors="*email" class="alert alert- 
   danger"></p>
   </div>

   <div class="mb-3">
   <label for="exampleInputPassword1" class="form-label">Phone</label>
   <input type="text" class="form-control" id="exampleInputPassword1" th:field="* 
   phone">
    <p th:if="$#fields.hasErrors('phone')" th:errors="*phone" class="alert alert- 
    danger"></p>
    <br>
    </div>

    <button type="submit" class="btn btn-primary">Submit</button>
     </form>
     </div>

【讨论】:

【参考方案7】:

根据这篇文章https://blog.codecentric.de/en/2017/11/dynamic-validation-spring-boot-validation/ - 您可以在控制器方法中添加一个额外的参数“错误” - 例如。

@RequestMapping(method = RequestMethod.POST)
public Profile insert(@Validated @RequestBody Profile profile, Errors errors) 
   ...

然后得到验证错误,如果有的话。

【讨论】:

但是如果用户为整数字段输入“a”,您将无法获得 BindException。【参考方案8】:

我想我应该以相反的顺序回答你的问题。

关于你的第二个问题, 如果在字段验证期间出现错误,@Validate 注释将抛出 MethodArgumentNotValidException。此注解的对象包含两个方法,getBindingResult()、getAllErrors(),它们提供了验证错误的详细信息。您可以使用 AspectJ (AOP) 创建自定义注释。但这里不需要。因为你的情况可以使用SpringBoot的ExceptionHandler来解决。

现在你的第一个问题,

请浏览此链接Link 的5部分。实际上它涵盖了 Spring Boot 中的整个 bean 验证。您的问题可以通过5部分解决。 spring boot 中一般异常处理的基础知识可能更好理解。为此,我可以在 google 上分享该主题的查询链接ExceptionHandling。请查看它的前几个结果。

【讨论】:

以上是关于REST 控制器中的 Spring Boot 绑定和验证错误处理的主要内容,如果未能解决你的问题,请参考以下文章