如何优雅地实现参数校验

如何优雅地实现参数校验

作者: shura | 日期: 2025-11-20

说明

本文讲如何用JSR-303/Hibernate Validator实现参数校验,避免手写if判断。

国内大多数项目都在用,Spring Boot开箱即用。

适合场景: 前后端分离、对外接口、需要统一校验规则

不适合场景: 内部工具、简单脚本

本文讲实用方案,不讲原理。


问题

传统参数校验的写法:

java 复制代码
@PostMapping("/users")
public Result create(@RequestBody UserDTO dto) {
    // 手写校验
    if (dto.getName() == null || dto.getName().isEmpty()) {
        return Result.error("姓名不能为空");
    }
    
    if (dto.getAge() == null) {
        return Result.error("年龄不能为空");
    }
    
    if (dto.getAge() < 0 || dto.getAge() > 150) {
        return Result.error("年龄必须在0-150之间");
    }
    
    if (dto.getEmail() == null || !dto.getEmail().matches("^[\\w-\\.]+@[\\w-]+\\.[a-z]{2,4}$")) {
        return Result.error("邮箱格式不正确");
    }
    
    if (dto.getPhone() == null || !dto.getPhone().matches("^1[3-9]\\d{9}$")) {
        return Result.error("手机号格式不正确");
    }
    
    // 真正的业务逻辑
    userService.create(dto);
    return Result.success();
}

问题:

  • Controller充满if判断
  • 校验逻辑和业务混在一起
  • 每个接口都要写一遍
  • 代码冗长难维护

方案

用注解声明式校验:

java 复制代码
@PostMapping("/users")
public Result create(@Valid @RequestBody UserDTO dto) {
    userService.create(dto);
    return Result.success();
}

校验规则写在DTO:

java 复制代码
public class UserDTO {
    
    @NotBlank(message = "姓名不能为空")
    private String name;
    
    @NotNull(message = "年龄不能为空")
    @Min(value = 0, message = "年龄不能小于0")
    @Max(value = 150, message = "年龄不能大于150")
    private Integer age;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @NotBlank(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
}

实现

1. 添加依赖

Spring Boot 2.3+需要手动引入:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2. 常用注解

java 复制代码
// 空值校验
@NotNull      // 不能为null
@NotEmpty     // 不能为null且长度>0 (String/Collection/Map/Array)
@NotBlank     // 不能为null且去空格后长度>0 (String)

// 数值校验
@Min(10)      // 最小值
@Max(100)     // 最大值
@Range(min=1, max=100)  // 范围
@Positive     // 正数
@PositiveOrZero  // 正数或0
@Negative     // 负数
@DecimalMin("0.1")  // 最小值(支持小数)
@DecimalMax("99.9") // 最大值(支持小数)

// 长度校验
@Size(min=2, max=10)  // 长度范围
@Length(min=2, max=10) // 长度范围(Hibernate Validator扩展)

// 格式校验
@Email        // 邮箱
@Pattern(regexp="^1[3-9]\\d{9}$")  // 正则
@URL          // URL格式

// 时间校验
@Past         // 过去的时间
@PastOrPresent  // 过去或现在
@Future       // 未来的时间
@FutureOrPresent  // 未来或现在

// 其他
@AssertTrue   // 必须为true
@AssertFalse  // 必须为false

注: @AssertTrue/@AssertFalse比较特殊,除了加在字段上,还可以加在方法上实现多字段联合校验(后面"高级用法"有例子)。

注解实现的共性:

这些注解虽然功能不同,但实现方式是统一的:

java 复制代码
// 每个注解都由两部分组成:

// 1. 注解定义 - 只是声明
@Constraint(validatedBy = EmailValidator.class)  // 指定校验器
public @interface Email {
    String message() default "邮箱格式不正确";
    // ...
}

// 2. 校验器 - 真正执行校验逻辑
public class EmailValidator implements ConstraintValidator<Email, String> {
    public boolean isValid(String value, ...) {
        // 具体校验逻辑
    }
}

所有注解都遵循这个模式:

  • 注解本身只是标记,不包含逻辑
  • 真正的校验由对应的Validator实现
  • @Constraint注解把两者关联起来

后面"高级用法"会讲如何按这个模式自定义注解。

3. Controller使用

java 复制代码
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    // 校验RequestBody
    @PostMapping
    public Result create(@Valid @RequestBody UserDTO dto) {
        userService.create(dto);
        return Result.success();
    }
    
    // 校验路径参数
    @GetMapping("/{id}")
    public Result getById(@PathVariable @Min(1) Long id) {
        User user = userService.getById(id);
        return Result.success(user);
    }
    
    // 校验查询参数
    @GetMapping
    public Result list(@RequestParam @Min(1) Integer page,
                       @RequestParam @Min(1) @Max(100) Integer size) {
        List<User> users = userService.list(page, size);
        return Result.success(users);
    }
}

注意: 校验路径参数和查询参数时,需要在类上加@Validated:

java 复制代码
@RestController
@RequestMapping("/api/users")
@Validated  // @Valid不能校验简单类型,用@Validated开启方法级别校验
public class UserController {
    // ...
}

4. 全局异常处理

java 复制代码
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    /**
     * 处理@Valid校验失败 (RequestBody)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));
        
        log.warn("参数校验失败: {}", message);
        return Result.error("PARAM_ERROR", message);
    }
    
    /**
     * 处理@Validated校验失败 (路径参数/查询参数)
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<?> handleConstraintViolation(ConstraintViolationException e) {
        String message = e.getConstraintViolations().stream()
            .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
            .collect(Collectors.joining(", "));
        
        log.warn("参数校验失败: {}", message);
        return Result.error("PARAM_ERROR", message);
    }
}

校验失败时,前端收到统一格式:

json 复制代码
{
  "success": false,
  "code": "PARAM_ERROR",
  "message": "name: 姓名不能为空, age: 年龄必须在0-150之间",
  "data": null
}

高级用法

1. 自定义校验器

校验手机号:

java 复制代码
// 自定义注解
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.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 PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // null值由@NotNull处理,这里只校验格式
        if (value == null) {
            return true;
        }
        return PATTERN.matcher(value).matches();
    }
}

使用:

java 复制代码
public class UserDTO {
    @NotBlank(message = "手机号不能为空")
    @Phone  // 使用自定义校验器
    private String phone;
}

2. 分组校验

同一个DTO,新增和修改的校验规则不同:

java 复制代码
// 定义分组接口
public interface Create {}
public interface Update {}

public class UserDTO {
    
    // 修改时必须传id,新增时不用
    @NotNull(message = "id不能为空", groups = Update.class)
    private Long id;
    
    @NotBlank(message = "姓名不能为空", groups = {Create.class, Update.class})
    private String name;
    
    // 新增时必须传密码,修改时不用
    @NotBlank(message = "密码不能为空", groups = Create.class)
    @Length(min = 6, message = "密码长度至少6位", groups = Create.class)
    private String password;
}

Controller指定分组:

java 复制代码
@PostMapping
public Result create(@Validated(Create.class) @RequestBody UserDTO dto) {
    userService.create(dto);
    return Result.success();
}

@PutMapping
public Result update(@Validated(Update.class) @RequestBody UserDTO dto) {
    userService.update(dto);
    return Result.success();
}

3. 多字段联合校验

用@AssertTrue加在方法上:

java 复制代码
public class RegisterDTO {
    
    @NotBlank(message = "密码不能为空")
    @Length(min = 6, message = "密码长度至少6位")
    private String password;
    
    @NotBlank(message = "确认密码不能为空")
    private String confirmPassword;
    
    // 加在方法上 - 校验两次密码是否一致
    @AssertTrue(message = "两次密码不一致")
    public boolean isPasswordMatch() {
        if (password == null) return true;
        return password.equals(confirmPassword);
    }
}

还可以实现条件性校验:

java 复制代码
public class PaymentDTO {
    
    @NotBlank(message = "支付方式不能为空")
    private String payType;
    
    private Long couponId;  // 使用优惠券支付时必填
    
    @AssertTrue(message = "使用优惠券支付时,优惠券id不能为空")
    public boolean isCouponValid() {
        if ("COUPON".equals(payType)) {
            return couponId != null;
        }
        return true;
    }
}

4. 嵌套校验

DTO里有另一个对象:

java 复制代码
public class OrderDTO {
    
    @NotNull(message = "商品不能为空")
    @Valid  // 必须加这个,才会校验ProductDTO
    private ProductDTO product;
    
    @NotNull(message = "收货地址不能为空")
    @Valid
    private AddressDTO address;
}

public class ProductDTO {
    @NotNull(message = "商品id不能为空")
    private Long id;
    
    @NotNull(message = "数量不能为空")
    @Min(value = 1, message = "数量至少为1")
    private Integer quantity;
}

public class AddressDTO {
    @NotBlank(message = "收货人不能为空")
    private String receiver;
    
    @NotBlank(message = "手机号不能为空")
    @Phone
    private String phone;
}

5. 集合校验

校验List中的每个元素:

java 复制代码
public class BatchUserDTO {
    
    @NotEmpty(message = "用户列表不能为空")
    @Size(max = 100, message = "批量创建最多100个")
    @Valid  // 校验List中的每个UserDTO
    private List<UserDTO> users;
}

@PostMapping("/batch")
public Result batchCreate(@Valid @RequestBody BatchUserDTO dto) {
    userService.batchCreate(dto.getUsers());
    return Result.success();
}

6. 校验数据库唯一性

校验用户名是否已存在:

java 复制代码
// 校验用户名是否已存在
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
    String message() default "用户名已存在";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
    
    @Autowired
    private UserMapper userMapper;
    
    @Override
    public boolean isValid(String username, ConstraintValidatorContext context) {
        if (username == null) {
            return true;
        }
        return userMapper.selectByUsername(username) == null;
    }
}

使用:

java 复制代码
public class UserDTO {
    @NotBlank(message = "用户名不能为空")
    @UniqueUsername  // 校验唯一性
    private String username;
}

7. 枚举校验

用@Pattern校验:

java 复制代码
public class OrderDTO {
    @NotBlank(message = "订单类型不能为空")
    @Pattern(regexp = "NORMAL|PRESALE|GROUPON", message = "订单类型只能是NORMAL/PRESALE/GROUPON")
    private String orderType;
}

或者自定义枚举注解:

java 复制代码
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValue {
    String[] value();
    String message() default "枚举值不合法";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class EnumValidator implements ConstraintValidator<EnumValue, String> {
    private Set<String> validValues;
    
    @Override
    public void initialize(EnumValue annotation) {
        validValues = Set.of(annotation.value());
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;
        return validValues.contains(value);
    }
}

// 使用
@EnumValue(value = {"NORMAL", "PRESALE", "GROUPON"}, message = "订单类型不合法")
private String orderType;

最佳实践

1. 注解选择

java 复制代码
// String类型用@NotBlank (去空格后判断)
@NotBlank
private String name;

// 集合用@NotEmpty
@NotEmpty
private List<String> tags;

// 数字/对象用@NotNull
@NotNull
private Integer age;

@NotNull
private ProductDTO product;

2. message写法

java 复制代码
// ✅ 好 - 清晰具体
@NotBlank(message = "姓名不能为空")
@Length(min = 2, max = 10, message = "姓名长度为2-10个字符")

// ❌ 不好 - 不够具体
@NotBlank(message = "参数错误")
@Length(min = 2, max = 10, message = "长度不符合要求")

3. @Valid vs @Validated

@Valid的局限:

  • 不能校验简单类型参数(路径/查询参数)
  • 不支持分组校验

@Validated解决了这些问题:

java 复制代码
// 校验路径/查询参数 - 类上加@Validated
@RestController
@Validated
public class UserController {
    @GetMapping("/{id}")
    public Result getById(@PathVariable @Min(1) Long id) {}
}

// 分组校验
@PostMapping
public Result create(@Validated(Create.class) @RequestBody UserDTO dto) {}

@PutMapping
public Result update(@Validated(Update.class) @RequestBody UserDTO dto) {}
场景 用@Valid 用@Validated
RequestBody对象
路径/查询参数 ✅ (类上)
分组校验
嵌套对象

4. 校验顺序

Spring会按注解顺序校验,遇到第一个失败就停止:

java 复制代码
@NotBlank(message = "姓名不能为空")  // 先校验这个
@Length(min = 2, max = 10, message = "姓名长度为2-10个字符")  // 再校验这个
private String name;

5. 快速失败 vs 全部校验

默认是快速失败(遇到第一个错误就停止),如果要全部校验:

java 复制代码
@Configuration
public class ValidatorConfig {
    
    @Bean
    public Validator validator() {
        ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(false)  // 不快速失败,校验所有字段
            .buildValidatorFactory();
        return factory.getValidator();
    }
}

建议使用快速失败,前端一次只显示一个错误,体验更好。

6. Service层校验

Service层也可以校验:

java 复制代码
@Service
@Validated  // 开启方法级别校验
public class UserService {
    
    public void create(@Valid UserDTO dto) {
        // 业务逻辑
    }
    
    public User getById(@Min(1) Long id) {
        // 业务逻辑
    }
}

但建议在Controller层校验就够了,Service层重复校验没必要。


总结

参数校验的核心:

  1. 用@Valid/@Validated开启校验
  2. 校验规则用注解声明在DTO
  3. 全局异常处理器统一返回错误
  4. 自定义校验器处理复杂逻辑

价值:

  • Controller代码简洁
  • 校验规则复用
  • 错误信息统一
  • 易于维护

使用建议:

场景 方案
基本校验 @NotBlank/@NotNull/@Email
数值范围 @Min/@Max/@Range
字符串长度 @Length/@Size
格式校验 @Pattern/@Email/@URL
自定义规则 自定义校验器
分组校验 @Validated(Group.class)
嵌套对象 @Valid

简单说明:

这是主流方案,不是唯一方案。

简单项目手写if也可以,关键是团队统一。

不要过度校验,该信任前端的地方要信任。


参考:

  • JSR-303规范
  • Hibernate Validator文档
  • Spring Validation文档

欢迎关注,学习不迷路!

相关推荐
20岁30年经验的码农1 小时前
Python语言基础文档
开发语言·python
spencer_tseng2 小时前
Eclipse Oxygen 4.7.2 ADT(android developer tools) Plugin
android·java·eclipse
来来走走3 小时前
Android开发(Kotlin) 协程
android·java·kotlin
河铃旅鹿4 小时前
Android开发-java版:Framgent
android·java·笔记·学习
y***61315 小时前
【springboot】Spring 官方抛弃了 Java 8!新idea如何创建java8项目
java·spring boot·spring
tanxinji5 小时前
RabbitMQ四种交换器类型详解及示例
java·rabbitmq
wjs20245 小时前
Django Nginx+uWSGI 安装配置指南
开发语言
刘一说5 小时前
一次生产环境 Tomcat 7 + JDK 7 应用启动失败的完整排查与修复实录
java·tomcat·firefox
七夜zippoe6 小时前
JVM类加载机制(Class Loading)详解:双亲委派模型与破坏实践
java·开发语言·jvm·类加载·双亲委派