参数校验实践
参数校验是防御式编程的第一道防线,确保进入业务的数据是合法有效的。
1. 校验框架层级关系
理解校验框架要先搞清楚它们的关系:
JSR303 (规范) ──→ Jakarta Validation (JSR303 重命名)
↓
Hibernate Validator (实现) ← Spring Validation (封装 + 扩展)
| 层级 | 名称 | 说明 |
|---|---|---|
| 规范 | JSR303 / Bean Validation | Java 官方定义的校验规范 |
| 实现 | Hibernate Validator | JSR303 的参考实现,Spring Boot 默认使用 |
| 封装 | Spring Validation | 在 Hibernate Validator 基础上添加 Spring 特性 |
| 新命名 | Jakarta Validation | JSR303 在 Jakarta EE 中的新名字 |
小贴士:可以把 JSR303 理解为"校验规则的规范文档",Hibernate Validator 是"按照这个规范编写的产品",Spring Validation 则是"给这个产品加了更方便的使用界面"。
2. Hibernate Validator vs Spring Validation
2.1 本质区别
| 特性 | Hibernate Validator | Spring Validation |
|---|---|---|
| 定位 | JSR303 规范的实现 | Spring 生态的封装和扩展 |
| 使用方式 | @Valid |
@Validated |
| 分组校验 | ❌ 不支持 | ✅ 支持 |
| 嵌套校验 | 仅校验属性 | 递归校验嵌套对象 |
| 校验时机 | 方法参数级别 | 更丰富的校验场景 |
| 错误处理 | 直接抛异常 | 可通过 BindingResult 获取详细错误 |
2.2 代码层面的差异
// Hibernate Validator 只能做基础校验
@Valid
@RequestBody UserRequest req // 嵌套对象不会递归校验
// Spring Validation 支持分组校验
@Validated(Create.class)
@RequestBody UserRequest req // 可指定校验分组
// 嵌套对象递归校验
public class OrderRequest {
@Valid // 配合 @Validated 使用才会递归校验嵌套对象
private UserRequest user;
}
2.3 实际项目选择
Spring Boot 项目 → 直接用 Spring Validation(默认已引入 Hibernate Validator)
- Spring Boot 自动依赖了 Hibernate Validator
- 使用
@Validated即可获得分组校验等功能 - 不需要额外引入任何依赖
3. JSR303 vs Jakarta Validation
两者功能完全相同,只是命名空间变了:
| 规范 | 包名 | 适用场景 |
|---|---|---|
| JSR303 | javax.validation.* |
Java EE / 旧 Spring 项目 |
| Jakarta Validation | jakarta.validation.* |
Jakarta EE 9+ / Spring Boot 3+ |
迁移注意:Spring Boot 3.0 起使用 Jakarta Validation,如从旧项目迁移需要修改 import 语句。
4. 校验框架对比总览
| 框架 | 来源 | 层级 | 适用场景 |
|---|---|---|---|
| JSR303 | Java 官方 | 规范 | 学习理论 |
| Hibernate Validator | Red Hat | 实现 | Spring Boot 默认实现 |
| Spring Validation | Spring | 封装 | Spring Boot 项目(推荐) |
| Jakarta Validation | Jakarta EE | 规范(新) | Jakarta EE / Spring Boot 3+ |
结论:Spring Boot 项目直接使用 Spring Validation(
@Validated+ Hibernate Validator),无需额外配置。
2. 基础用法
2.1 常用校验注解
public class UserCreateRequest {
@NotNull(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名3-20位")
@Pattern(regexp = "^[a-zA-Z][a-zA-Z0-9_]*$", message = "用户名以字母开头")
private String username;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
private String mobile;
@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能超过150")
private Integer age;
@Email(message = "邮箱格式错误")
private String email;
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额最小为0.01")
@Digits(integer = 10, fraction = 2, message = "金额格式错误")
private BigDecimal amount;
@NotNull(message = "生日不能为空")
@Past(message = "生日必须是过去的时间")
private LocalDate birthday;
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "日期格式YYYY-MM-DD")
private String dateStr;
// 列表校验
@NotEmpty(message = "标签不能为空")
@Size(max = 10, message = "标签最多10个")
private List<@NotBlank String> tags;
}
2.2 分组校验
// 定义分组
public interface Create {}
public interface Update {}
// 请求对象使用分组
public class UserRequest {
@NotBlank(groups = Create.class, message = "创建时用户名必填")
@Blank(groups = Update.class, message = "更新时用户名不能修改")
private String username;
@NotNull(groups = {Create.class, Update.class})
private Long id;
}
// Controller指定分组
@PostMapping
public Result create(@Validated(Create.class) @RequestBody UserRequest req) {
return Result.success(userService.create(req));
}
@PutMapping
public Result update(@Validated(Update.class) @RequestBody UserRequest req) {
return Result.success(userService.update(req));
}
2.3 Controller层统一校验
@RestController
public class BaseController {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return Result.error(400, message);
}
}
// 全局校验(不写在DTO上)
@PostMapping("/user")
public Result createUser(
@RequestBody @Valid UserRequest req,
BindingResult bindingResult) {
// 手动校验
if (bindingResult.hasErrors()) {
String msg = bindingResult.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return Result.error(400, msg);
}
return Result.success(userService.create(req));
}
3. 自定义校验
3.1 自定义校验注解
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
@Documented
public @interface ValidPhone {
String message() default "手机号格式错误";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 校验器
public class PhoneValidator implements ConstraintValidator<ValidPhone, 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; // 用 @NotNull 单独控制
}
return PHONE_PATTERN.matcher(value).matches();
}
}
// 使用
public class UserRequest {
@ValidPhone
private String mobile;
}
3.2 校验关联字段
// 结束日期必须大于开始日期
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "结束日期必须大于开始日期";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, Object> {
private String startField;
private String endField;
@Override
public void initialize(ValidDateRange annotation) {
this.startField = annotation.startField();
this.endField = annotation.endField();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
try {
LocalDate start = (LocalDate) getFieldValue(value, startField);
LocalDate end = (LocalDate) getFieldValue(value, endField);
if (start == null || end == null) {
return true; // 由 @NotNull 控制
}
return end.isAfter(start);
} catch (Exception e) {
return false;
}
}
}
@ValidDateRange(startField = "startDate", endField = "endDate")
public class QueryRequest {
private LocalDate startDate;
private LocalDate endDate;
}
4. 常用校验规则清单
| 场景 | 注解 | 示例 |
|---|---|---|
| 非空字符串 | @NotBlank |
用户名 |
| 非空对象 | @NotNull |
ID、金额 |
| 非空集合 | @NotEmpty |
列表、Map |
| 字符串长度 | @Size(min, max) |
3-20位 |
| 数值范围 | @Min, @Max |
0-150 |
| 小数精度 | @Digits |
金额、坐标 |
| 正则匹配 | @Pattern |
手机号、邮箱 |
| 邮箱格式 | @Email |
邮箱 |
| URL格式 | @URL |
网址 |
| 日期格式 | @Past / @Future |
生日、预约 |
| 枚举值 | 自定义@EnumValue | 状态 |
| 身份证 | 自定义@IdCard | 身份证号 |
5. 注意事项
- 分层校验:Controller 校验请求、Service 校验业务规则
- 不要过度校验:能用枚举的不要用正则,能用类型的不要用字符串
- 错误信息国际化:使用
{validatedValue}显示具体值 - 性能注意:复杂正则不要写在注解里,放到常量或缓存
- 文件校验:除了扩展名,要校验 MIME type 和文件魔数
最后更新:2026/05/11