如何优雅地实现参数校验
作者: 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层重复校验没必要。
总结
参数校验的核心:
- 用@Valid/@Validated开启校验
- 校验规则用注解声明在DTO
- 全局异常处理器统一返回错误
- 自定义校验器处理复杂逻辑
价值:
- Controller代码简洁
- 校验规则复用
- 错误信息统一
- 易于维护
使用建议:
| 场景 | 方案 |
|---|---|
| 基本校验 | @NotBlank/@NotNull/@Email |
| 数值范围 | @Min/@Max/@Range |
| 字符串长度 | @Length/@Size |
| 格式校验 | @Pattern/@Email/@URL |
| 自定义规则 | 自定义校验器 |
| 分组校验 | @Validated(Group.class) |
| 嵌套对象 | @Valid |
简单说明:
这是主流方案,不是唯一方案。
简单项目手写if也可以,关键是团队统一。
不要过度校验,该信任前端的地方要信任。
参考:
- JSR-303规范
- Hibernate Validator文档
- Spring Validation文档
欢迎关注,学习不迷路!