Java基础、中级、高级、架构面试资料

乱用 Spring Boot 参数校验 BindingResult 的 6 个大坑,我被第 4 个坑了 3 小时

JAVA herman 7浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog2,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog2,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领
【腾讯云】1核2G5M轻量应用服务器50元首年,高性价比,助您轻松上云

上周五,微信群里讨论的热火朝天,原因是被我的一篇文章《https://mp.weixin.qq.com/s/PeCU6_GjRoorxUHyNzZGeA》激起了大家的愤慨!

当时,有一个网友提了一个关于BindingResult使用上的问题,很快就被其它消息淹没了。

根据当时,我和这位网友的交流,我这里整理一下关于 Spring Boot 中 BindingResult 的相关用法,让大家能够轻轻松松从入门到填坑!

文章配图参见https://mp.weixin.qq.com/s/1NBmfVbjniw8M95EetNmAA

话说在 Spring Boot 开发中,参数校验是必不可少的一环。而提到参数校验,就绕不开 BindingResult 这个“老熟人”。它看似简单,实则暗藏玄机。很多开发者在初次接触时,经常会遇到各种莫名其妙的坑——要么校验没生效,要么突然抛异常,要么页面报错说 BindingResult 不能用……

尤其是一些老项目和个别网友,非常喜欢 BindingResult。但稍有不慎,就会陷入被 BindingResult 支配的恐惧之中。接下来,我们就来彻底讲透 BindingResult 的作用、使用方式、常见 bug 以及最佳实践。

BindingResult 是什么?有什么作用?

BindingResult 我其实用的很少,一方面是我觉得它不够灵活,另一方面是在低代码等潮流的影响下,BindingResult 非常鸡肋。虽说我用的不多,但还是有不少网友跳进它的坑里了,下面我们就从“它是什么?有什么作用?”讲起。

BindingResult 是 Spring 框架中位于org.springframework.validation包下的一个接口,用于封装数据绑定和验证过程中的错误信息。

它的核心作用是:

  1. 收集验证错误:当使用 @Valid@Validated 对参数进行校验时,如果校验失败,错误信息会被自动封装到 BindingResult 对象中
  2. 避免异常抛出:正常情况下,如果校验失败且没有 BindingResult,Spring MVC 会直接抛出MethodArgumentNotValidExceptionBindException。而有了 BindingResult,异常会被“吞掉”,让我们可以在代码中手动处理错误。

说白了,BindingResult 就是一个“错误收集器”,让我们能够优雅地处理参数校验失败的情况

BindingResult 的基本使用

我们先来看看它的基础用法示例。

第一步,定义实体类并添加校验注解。

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

public class User {

    @NotEmpty(message = "用户名不能为空")
    private String name;

    @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
    private String password;

    // getter/setter 省略
}

第二步,Controller 中使用 BindingResult。

@PostMapping("/user")
public String createUser(@Valid @RequestBody User user, BindingResult bindingResult) {
    // 判断是否有校验错误
    if (bindingResult.hasErrors()) {
        // 获取所有错误信息
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        for (FieldError error : fieldErrors) {
            System.out.println("字段:" + error.getField() + 
                               ",错误信息:" + error.getDefaultMessage());
        }
        return "参数校验失败";
    }
    // 校验通过,执行业务逻辑
    return "success";
}

通过上面的代码,可以看出 BindingResult 用起来并不难,但是要用好,就稍微需要掌握一些经验。

核心方法详解

BindingResult 提供了丰富的 API 来获取错误信息。

方法说明
hasErrors()判断是否有错误
getFieldErrors()获取所有字段错误列表
getFieldError(String field)获取指定字段的错误
getGlobalErrors()获取全局错误(对象级错误)
getAllErrors()获取所有错误
getErrorCount()获取错误总数

更多方法和用法,参考官方文档https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/validation/BindingResult.html

@Valid 和 BindingResult 的绑定

这里要记住一条重要规则:@ValidBindingResult必须一一对应,且 BindingResult 必须紧跟在被校验的对象后面。

看下面的案例。

// 正确:一一对应,顺序正确
public String method1(@Valid User user, BindingResult userResult,
  @Valid Role role, BindingResult roleResult)

// 错误:顺序不对,userResult 会接收到 role 的错误
public String method2(@Valid User user, @Valid Role role, 
  BindingResult userResult)

// 错误:缺少对应的 BindingResult
public String method3(@Valid User user)

BindingResult 的 6 大常见坑

接下来,我汇总了群友讨论的常见的 6 个大坑,大家看看有没有入坑过的。

文章配图参见https://mp.weixin.qq.com/s/1NBmfVbjniw8M95EetNmAA

第一个坑,忘记加 @Valid 或 @Validated。

症状为,校验注解完全不生效,无论输入什么都能通过。

经过排查后得知原因是,BindingResult 本身只是一个容器,它需要配合 @Valid@Validated 来触发校验。

针对这种情况的解决方案是确保在被校验的对象前加上 @Valid@Validated

// 错误:没有 @Valid,bindingResult 永远没有错误
public String create(@RequestBody User user, BindingResult bindingResult)

// 正确
public String create(@Valid @RequestBody User user, BindingResult bindingResult)

第二个常见的坑是,BindingResult 和被校验对象之间隔着其他参数。

遇到的症状是,校验失败时抛出 MethodArgumentNotValidException,而不是进入方法体。

究其原因,Spring 要求 BindingResult 必须紧跟在被校验对象后面,中间不能有其他参数。

// 错误:中间多了 HttpServletRequest
public String create(@Valid User user, HttpServletRequest request, 
  BindingResult bindingResult)

// 正确
public String create(@Valid User user, BindingResult bindingResult, 
  HttpServletRequest request)

第三个坑是对简单类型使用 BindingResult。

常见的症状是校验注解加在 String、Integer 等简单类型上,BindingResult 收不到错误。

背后的原因是 Spring MVC 对方法参数使用不同的解析器,简单类型的参数校验不走 Bean Validation 的流程,BindingResult 无法接收它们的校验信息。

// 错误:对 String 参数进行校验,bindingResult 收不到错误
public String save(@NotBlank(message = "名称不能为空") String name, 
  BindingResult bindingResult)

// 正确:使用包装类或实体对象
public String save(@Valid @RequestBody User user, BindingResult bindingResult)

第 4 个坑是 IllegalStateException,BindingResult 和普通目标对象都不能用作请求属性。

这是社区里被问得最多的异常之一,在 Stack Overflow https://stackoverflow.com/questions/56596671上也有大量讨论。

社区里反馈的症状是页面报错,java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'xxx' available as request attribute

背后的原因分析后得知,这个异常通常出现在使用 Spring MVC 表单标签(<form:form><form:input> 等)时。当渲染 JSP 或 Thymeleaf 页面时,Spring 会在 Model 中查找指定名称的属性对象,但没找到。

有下面 3 种常见场景。

  1. GET 请求中没有将目标对象放入 Model
  2. 表单的 modelAttribute 属性值与 Model 中的属性名不匹配
  3. 校验失败后,重定向时没有保留原对象

正确的解决方案如下代码所示。

// GET 请求:确保将空对象放入 Model
@GetMapping("/user/form")
public String showForm(Model model) {
  // 关键!
    model.addAttribute("user", new User());  
    return "userForm";
}

// POST 请求:校验失败时,返回的视图中也要有该对象
@PostMapping("/user/save")
public String save(@Valid @ModelAttribute("user") User user, 
  BindingResult result, Model model) {
    if (result.hasErrors()) {
     // 保留用户输入
        model.addAttribute("user", user); 
        return "userForm";
    }
    return "redirect:/user/list";
}

第 5 个坑是,忽略 BindingResult 却期望不抛异常。

通常是方法参数中加了 BindingResult 但从未使用,结果还是抛异常。

这类问题背后的原因也很简单。虽然加上了 BindingResult,但 Spring 仍然会进行校验。如果代码中没有通过hasErrors()检查,并且校验失败,Spring 还是会抛出异常吗?

答案是:不会。

BindingResult 的存在本身就告诉 Spring 不要抛异常,而是把错误放入 BindingResult。但如果不检查,错误会被忽略,可能导致数据继续处理。

所以,遇到这类问题的解决方案是务必检查 hasErrors() 并做出相应处理。

最后一个坑是对嵌套对象校验不生效。

尤其是实体类中包含另一个实体对象,但内部对象的校验注解不生效。

这个原因是 @Valid 不会自动级联校验嵌套对象,需要在嵌套属性上也加上@Valid

public class Order {
    @Valid  // 必须加上这个!
    private List<OrderItem> items;

    @Valid  // 必须加上这个!
    private User buyer;
}

需要注意的是,随着 Spring 7.x 和 Spring Boot 4.x 的流行。现在@Valid也支持套娃了。具体可以参加我前面写的文章《https://mp.weixin.qq.com/s/IAFHNDk3V3SWI3ESn9Ib_A》。

BindingResult 最佳实践

我这里总结了 3 类最佳实践,供大家参考!

统一异常处理 vs 手动处理

这里整理了两种处理校验错误的方式,各有优劣。

方式一,手动处理(使用 BindingResult)。

@PostMapping("/user")
public Result createUser(@Valid @RequestBody User user, 
                          BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        // 手动收集错误信息返回
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error -> 
            errors.put(error.getField(), error.getDefaultMessage())
        );
        return Result.fail(400, "参数校验失败", errors);
    }
    return Result.success();
}

方式二,统一异常处理(不使用 BindingResult)。

// Controller 中不加 BindingResult
@PostMapping("/user")
public Result createUser(@Valid @RequestBody User user) {
    return Result.success();
}

// 统一异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidationException(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error -> 
            errors.put(error.getField(), error.getDefaultMessage())
        );
        return Result.fail(400, "参数校验失败", errors);
    }
}

这里,推荐大家使用#统一异常处理的方式。原因有下面 3 条。

  • Controller 代码更简洁,业务逻辑更清晰
  • 错误处理逻辑集中,易于维护
  • 避免在每个方法中重复编写错误处理代码

合理使用分组校验

当同一个实体在不同场景下有不同的校验规则时,推荐使用分组校验。

// 定义分组接口
public interface CreateGroup {}
public interface UpdateGroup {}

// 实体类中使用分组
public class User {
    @NotNull(message = "ID不能为空", groups = {UpdateGroup.class})
    private Long id;

    @NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
    private String name;
}

// Controller 中使用
@PostMapping("/user")
public Result create(@Validated(CreateGroup.class) @RequestBody User user) {
    // 创建场景
}

@PutMapping("/user")
public Result update(@Validated(UpdateGroup.class) @RequestBody User user) {
    // 更新场景
}

自定义校验注解

当内置注解无法满足需求时,可以自定义校验注解,案例代码如下所示。

// 自定义注解
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 校验器实现
public class PhoneValidator implements ConstraintValidator<Phone, String> {
    private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;  // 空值由 @NotBlank 处理
        }
        return PHONE_PATTERN.matcher(value).matches();
    }
}

校验消息国际化

配置国际化消息,让错误提示支持多语言。

多语言配置文件,如resources/ValidationMessages.properties

user.name.notempty=用户名不能为空
user.password.size=密码长度必须在{min}-{max}位之间

然后在 Java 实体类中使用。

public class User {
    @NotEmpty(message = "{user.name.notempty}")
    private String name;

    @Size(min = 6, max = 20, message = "{user.password.size}")
    private String password;
}

总结

BindingResult 是 Spring Boot 参数校验中不可或缺的一环,理解它的工作原理和常见陷阱,能让我们的代码更加健壮。

现在回顾一下本文的核心要点。

  1. 基本规则:@Valid/@Validated 和 BindingResult 必须一一对应,且紧挨着
  2. 常见坑点:忘记加校验注解、参数顺序错误、简单类型校验、表单页面缺对象、嵌套校验不生效
  3. 最佳实践:优先使用统一异常处理、合理运用分组校验、善用自定义注解

以上,期待这篇文章能够帮助到部分网友在使用 BindingResult 时少踩坑。如果有网友遇到过其他有趣的问题,欢迎在评论区留言讨论!

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号:xttblog2。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 乱用 Spring Boot 参数校验 BindingResult 的 6 个大坑,我被第 4 个坑了 3 小时