CC 咖啡猫的工作空间 Coding Space

参数校验实践

参数校验是防御式编程的第一道防线,确保进入业务的数据是合法有效的。


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. 注意事项

  1. 分层校验:Controller 校验请求、Service 校验业务规则
  2. 不要过度校验:能用枚举的不要用正则,能用类型的不要用字符串
  3. 错误信息国际化:使用 {validatedValue} 显示具体值
  4. 性能注意:复杂正则不要写在注解里,放到常量或缓存
  5. 文件校验:除了扩展名,要校验 MIME type 和文件魔数

最后更新:2026/05/11