在构建健壮的 Web 应用程序时,数据校验是不可或缺的一环。一个好的校验机制能够有效防止非法数据进入你的业务逻辑,从而保障系统的稳定性和安全性。Spring 框架通过集成 JSR 303/JSR 380 (Bean Validation) 标准,为我们提供了一套非常强大且易于使用的声明式校验工具。
在本篇博客中,我将带你深入探索 Spring 中如何利用 JSR 303 注解进行数据校验,包括:
- 核心校验注解的使用:如
@NotNull,@Max,@Min,@Email等。 - 开启嵌套校验的
@Valid注解。 - 使用
BindingResult在 Controller 中优雅地捕获和处理校验结果。 - 通过全局异常处理器(Global Exception Handler)实现统一、优雅的参数校验失败响应。
sequenceDiagram
participant Client as 客户端
participant Controller as 控制器
participant SpringValidation as Spring校验框架
participant GlobalExceptionHandler as 全局异常处理器
Client->>Controller: 发送包含UserDTO的HTTP请求
activate Controller
Controller->>SpringValidation: @Valid触发校验
activate SpringValidation
alt 校验成功
SpringValidation-->>Controller: 校验通过
Controller-->>Client: 返回成功响应 (200 OK)
else 校验失败
SpringValidation-->>Controller: 抛出MethodArgumentNotValidException
Note right of Controller: 异常被框架捕获并路由
activate GlobalExceptionHandler
GlobalExceptionHandler->>GlobalExceptionHandler: 提取错误信息并构建响应体
GlobalExceptionHandler-->>Client: 返回统一的错误响应 (400 Bad Request)
deactivate GlobalExceptionHandler
end
deactivate Controller
1. JSR 303:Java Bean 校验标准
JSR 303 (Bean Validation 1.0) 和其后续版本 JSR 380 (Bean Validation 2.0) 是 Java 官方定义的一套用于数据校验的规范。它允许我们通过注解的方式,在 JavaBean 的属性上直接定义校验规则。这样做的好处是,校验逻辑与业务逻辑解耦,代码更加简洁、可读性更高。
Spring Boot 默认集成了 Hibernate Validator 作为 JSR 303 的实现,我们只需要在 pom.xml 中引入 spring-boot-starter-web 依赖,即可自动获得校验功能。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
2. 常用校验注解详解
让我们通过一个用户注册的例子,来看看这些注解如何使用。假设我们有一个 UserDTO (Data Transfer Object),用于接收前端传递的用户注册信息。
import javax.validation.constraints.*;
import java.util.Date;
public class UserDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度必须在4到20个字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 8, message = "密码长度至少为8位")
private String password;
@NotNull(message = "年龄不能为空")
@Min(value = 18, message = "必须年满18岁")
@Max(value = 100, message = "年龄不能超过100岁")
private Integer age;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@Past(message = "生日必须是一个过去的时间")
private Date birthday;
}
下面我们来解读一下这些常用的注解:
@NotBlank: 验证字符串不为null,且去除首尾空格后的长度大于0。常用于字符串类型的字段。@NotNull: 验证对象不为null。可用于任何类型。@NotEmpty: 验证集合、Map或数组不为null且大小不为0;验证字符串不为null且长度不为0。@Size(min, max): 验证字符串、集合、Map或数组的大小是否在指定的范围内。@Min(value): 验证数值是否大于或等于指定的最小值。@Max(value): 验证数值是否小于或等于指定的最大值。@Email: 验证字符串是否为合法的电子邮件地址格式。@Pattern(regexp): 验证字符串是否匹配指定的正则表达式。@Past/@Future: 验证日期是否在当前时间之前/之后。@AssertTrue/@AssertFalse: 验证布尔值是否为true/false。
每个注解都有一个 message 属性,用于自定义校验失败时的提示信息,这是一个非常好的实践。
3. 在 Controller 中开启校验:@Valid 注解
定义好了 DTO 的校验规则后,我们如何在 Controller 中触发这些校验呢?答案就是使用 @Valid 注解。
将 @Valid 注解添加到需要校验的参数前,Spring MVC 在进行参数绑定时,就会自动对该对象进行校验。
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping("/register")
public String registerUser(@Valid @RequestBody UserDTO userDTO) {
// 如果校验通过,这里的代码才会执行
// ... 执行用户注册逻辑
return "用户注册成功!";
}
}
现在,如果你尝试发送一个不符合规则的请求,例如:
{
"username": "us",
"password": "123",
"age": 16,
"email": "invalid-email"
}
Spring 会自动拦截这个请求,并抛出 MethodArgumentNotValidException 异常。默认情况下,你会收到一个包含大量信息的 JSON 错误响应,这对前端用户来说并不友好。
接下来,我们将学习如何优雅地处理这些校验失败的情况。
4. 使用 BindingResult 捕获校验结果
第一种处理方式是,在 Controller 方法中紧跟在校验对象后面,添加一个 BindingResult 类型的参数。BindingResult 对象会封装所有的校验错误信息,并且 阻止 Spring 抛出 MethodArgumentNotValidException 异常。
这样,我们就可以在方法体内通过编程的方式处理校验结果。
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping("/register-with-binding-result")
public ResponseEntity<?> registerWithBindingResult(@Valid @RequestBody UserDTO userDTO, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// 将错误信息收集到 Map 中
Map<String, String> errors = bindingResult.getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(existing, replacement) -> existing // 处理 key 冲突
));
return ResponseEntity.badRequest().body(errors);
}
// 校验通过,执行业务逻辑
return ResponseEntity.ok("用户注册成功!");
}
}
优点:
- 灵活性高,可以在每个 Controller 方法中定义不同的错误处理逻辑。
缺点:
- 需要在每个需要校验的方法中都注入
BindingResult并编写重复的错误处理代码,造成代码冗余。
为了解决这个问题,我们通常会采用一种更优雅、更集中的方式——全局异常处理器。
5. 终极方案:全局异常处理器
全局异常处理器允许我们使用 AOP (Aspect-Oriented Programming) 的思想,将散落在各个 Controller 中的异常处理逻辑集中到一个地方进行管理。这使得我们的 Controller 代码更加专注于业务逻辑。
我们需要创建一个类,并使用 @RestControllerAdvice (或 @ControllerAdvice) 注解来标记它。然后,在类中创建一个方法,使用 @ExceptionHandler 注解来指定它要处理的异常类型,这里是 MethodArgumentNotValidException。
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest().body(errors);
}
}
现在,当任何一个 Controller 中使用了 @Valid 的参数校验失败时,MethodArgumentNotValidException 都会被这个处理器捕获。我们不再需要在 Controller 方法中添加 BindingResult 参数了。
此时,我们的 Controller 代码可以恢复到最初的简洁形式:
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping("/register")
public String registerUser(@Valid @RequestBody UserDTO userDTO) {
// 校验逻辑已由全局异常处理器接管
return "用户注册成功!";
}
}
当我们再次发送之前的非法请求时,会得到一个清晰、统一的 JSON 响应:
{
"password": "密码长度至少为8位",
"age": "必须年满18岁",
"username": "用户名长度必须在4到20个字符之间",
"email": "邮箱格式不正确"
}
这样的响应格式对于前端应用来说非常友好,易于解析和展示。
7. 总结
毫无疑问,使用全局异常处理器是处理校验错误的最佳实践。它将错误处理逻辑与业务逻辑完全解耦,提高了代码的可维护性和可重用性,并能为客户端提供一致、友好的错误响应。