作为一名 Spring Boot 开发者,@Valid注解大概是我初期最常用的工具之一 —— 只需在 DTO 参数前加一行注解,就能轻松搞定 “不为空”“长度限制”“邮箱格式” 这类简单校验,省去了大量重复的if-else代码。
但随着业务复杂度提升,@Valid的短板逐渐暴露:比如遇到 “订单金额超 1000 必须填身份证号”“普通用户和 VIP 用户校验规则不同” 这类动态场景时,要么得硬写自定义注解(调试半天还容易出 bug),要么把校验逻辑揉进业务代码里(后期维护时找半天找不到)。
直到接触了 “编程式验证”,我才发现:原来复杂校验也能写得优雅又清晰。今天就把我实战总结的编程式验证用法分享出来,希望能帮到同样被复杂校验困扰的你。
一、先聊聊:为什么 @Valid 搞不定复杂场景?
在讲编程式验证之前,先明确一个前提:@Valid并非不好用,而是它的设计定位就是 “解决简单静态校验”。当场景满足以下特点时,@Valid就会显得力不从心:
校验规则依赖条件:比如 “参数 A>100 时,参数 B 必须传”“参数 C 为 true 时,参数 D 要符合特定格式”;
校验规则与角色关联:比如 “普通用户不能修改手机号,VIP 用户可以改”“管理员提交的表单不需要验证码,普通用户需要”;
需要校验集合细节:比如 “列表中不能有重复 ID”“列表里每个元素的某个字段都要符合规则”;
校验逻辑需要外部数据:比如 “提交的商品 ID 必须在数据库中存在”“折扣率不能超过该用户的等级上限”。
这些场景的核心问题是:@Valid的注解是 “写死” 在代码里的静态规则,无法根据运行时的动态条件调整;而编程式验证则是用代码 “手动控制” 校验逻辑,能灵活应对各种动态场景。
二、编程式验证基础:核心概念与流程
编程式验证的核心是 Spring Boot 默认集成的Hibernate Validator提供的Validator接口 —— 通过这个接口,我们可以手动定义校验规则、执行校验、处理校验结果。
1. 核心 API 说明
Validator:校验的 “执行者”,负责加载校验规则并执行校验;
ConstraintViolation:校验失败的 “结果容器”,包含失败的字段名、失败提示信息等;
ValidatorFactory:创建Validator实例的工厂,Spring Boot 会自动配置,我们直接注入即可。
2. 基础流程演示(以用户注册为例)
需求:用户注册时,“用户名不为空且长度 2-20”“密码不为空且含至少一个大写字母”“手机号格式正确(可选填)”。
步骤 1:定义 DTO(无需加任何校验注解)
/**
* 用户注册参数DTO
* 这里没有加任何@NotNull、@Pattern等注解,所有校验靠代码控制
*/
public class UserRegisterDTO {
private String username; // 用户名(必填)
private String password; // 密码(必填)
private String phone; // 手机号(可选)
// getter/setter(这里用Lombok的@Data也可以,看个人习惯)
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
}
步骤 2:编写校验逻辑(核心)
我们可以把校验逻辑抽成一个单独的方法,也可以放在服务层里 —— 这里推荐抽成单独的 “校验器”,符合单一职责原则。
import org.hibernate.validator.constraintvalidation.HibernateConstraintValidatorContext;
import javax.validation.*;
import java.util.HashSet;
import java.util.Set;
/**
* 用户相关校验器
* 专门处理用户类DTO的校验,避免校验逻辑散落在业务代码里
*/
@Component
public class UserValidator {
// 注入Spring Boot自动配置的Validator
@Autowired
private Validator validator;
/**
* 校验用户注册参数
* @param dto 注册参数
* @throws ConstraintViolationException 校验失败时抛出
*/
public void validateRegister(UserRegisterDTO dto) {
// 1. 初始化一个集合,用来装校验失败的结果
Set<ConstraintViolation<UserRegisterDTO>> violations = new HashSet<>();
// 2. 逐个编写校验规则
// 规则1:用户名不能为空,且长度在2-20之间
if (dto.getUsername() == null || dto.getUsername().trim().isEmpty()) {
// 手动添加校验失败信息(这里用了一个辅助方法,后面会讲)
violations.add(buildViolation(dto, "username", "用户名不能为空"));
} else if (dto.getUsername().length() < 2 || dto.getUsername().length() > 20) {
violations.add(buildViolation(dto, "username", "用户名长度需在2-20个字符之间"));
}
// 规则2:密码不能为空,且至少包含一个大写字母
if (dto.getPassword() == null || dto.getPassword().trim().isEmpty()) {
violations.add(buildViolation(dto, "password", "密码不能为空"));
} else if (!dto.getPassword().matches(".*[A-Z].*")) { // 正则匹配大写字母
violations.add(buildViolation(dto, "password", "密码需包含至少一个大写字母"));
}
// 规则3:手机号如果填了,格式必须正确(11位数字,以13-9开头)
if (dto.getPhone() != null && !dto.getPhone().trim().isEmpty()) {
if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) {
violations.add(buildViolation(dto, "phone", "手机号格式不正确"));
}
}
// 3. 如果有校验失败,抛出异常(也可以返回失败信息列表,看业务需求)
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
/**
* 辅助方法:构建ConstraintViolation对象
* 因为ConstraintViolation是接口,需要手动实现它的方法
* @param target 校验的目标对象(DTO)
* @param field 校验失败的字段名
* @param message 校验失败的提示信息
* @param <T> 泛型,适配不同的DTO类型
* @return 构建好的ConstraintViolation
*/
private <T> ConstraintViolation<T> buildViolation(T target, String field, String message) {
return new ConstraintViolation<T>() {
@Override
public String getMessage() {
return message; // 失败提示信息
}
@Override
public String getPropertyPath() {
return field; // 失败的字段名
}
// 下面这些方法都是接口的默认实现,不需要特别处理,返回默认值即可
@Override
public String getMessageTemplate() {
return null;
}
@Override
public T getRootBean() {
return target;
}
@Override
public Class<T> getRootBeanClass() {
return (Class<T>) target.getClass();
}
@Override
public Object getLeafBean() {
return target;
}
@Override
public Object getInvalidValue() {
return null;
}
@Override
public ConstraintDescriptor<?> getConstraintDescriptor() {
return null;
}
@Override
public Object[] getExecutableParameters() {
return new Object[0];
}
@Override
public Object getExecutableReturnValue() {
return null;
}
@Override
public int getParameterIndex() {
return -1;
}
@Override
public Set<ConstraintViolation<T>> getConstraintViolations() {
return new HashSet<>();
}
};
}
}
步骤 3:在业务层调用校验器
@Service
public class UserService {
@Autowired
private UserValidator userValidator;
/**
* 用户注册业务方法
* @param dto 注册参数
*/
public void register(UserRegisterDTO dto) {
// 1. 先执行校验(校验失败会抛出异常)
userValidator.validateRegister(dto);
// 2. 校验通过后,执行业务逻辑(比如保存用户到数据库)
// userRepository.save(...);
System.out.println("用户注册成功:" + dto.getUsername());
}
}
步骤 4:控制器接收请求
@RestController
@RequestMapping("/api/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody UserRegisterDTO dto) {
userService.register(dto);
return ResponseEntity.ok("注册成功");
}
}
到这里,一个基础的编程式验证流程就完成了。可能有人会说:“这比 @Valid 写的代码多啊!”—— 别急,这只是基础场景,当遇到复杂场景时,编程式验证的优势会立刻显现。
三、实战复杂场景:3 个典型案例
下面用 3 个真实业务中常见的复杂场景,演示编程式验证的灵活应用。
场景 1:条件依赖校验(订单金额超 1000 需填身份证号)
需求:创建订单时,“订单金额≤1000 元,身份证号可选填;金额> 1000 元,身份证号必须填且格式正确”。
校验逻辑代码
/**
* 订单校验器
*/
@Component
public class OrderValidator {
@Autowired
private Validator validator;
/**
* 校验订单创建参数
* @param dto 订单DTO
*/
public void validateCreate(OrderCreateDTO dto) {
Set<ConstraintViolation<OrderCreateDTO>> violations = new HashSet<>();
// 基础规则:订单金额不能为负
if (dto.getAmount() == null || dto.getAmount() < 0) {
violations.add(buildViolation(dto, "amount", "订单金额不能为负数"));
}
// 条件规则:金额>1000时,身份证号必传且格式正确
if (dto.getAmount() != null && dto.getAmount() > 1000) {
if (dto.getIdCard() == null || dto.getIdCard().trim().isEmpty()) {
violations.add(buildViolation(dto, "idCard", "订单金额超1000元,需填写身份证号"));
} else if (!dto.getIdCard().matches("(\\d{18}|\\d{17}(\\d|X|x))")) { // 身份证正则
violations.add(buildViolation(dto, "idCard", "身份证号格式不正确"));
}
}
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
// 辅助方法buildViolation,和之前的UserValidator里的一样,这里省略
private <T> ConstraintViolation<T> buildViolation(T target, String field, String message) {
// 实现代码同上...
}
}
这个场景如果用注解做,需要自定义一个@ConditionalIdCard注解,还要写对应的校验器,代码量比编程式验证多得多,而且灵活性差(比如后续要改金额阈值,还得改注解逻辑)。
场景 2:角色差异化校验(普通用户 vs VIP 用户)
需求:用户编辑个人信息时,“普通用户只能修改邮箱,不能修改手机号;VIP 用户可以修改手机号和邮箱,但手机号格式必须正确”。
校验逻辑代码
public void validateUpdate(UserUpdateDTO dto) {
Set<ConstraintViolation<UserUpdateDTO>> violations = new HashSet<>();
// 基础规则:用户ID不能为空
if (dto.getUserId() == null) {
violations.add(buildViolation(dto, "userId", "用户ID不能为空"));
}
// 角色差异化规则
Integer userType = dto.getUserType(); // 1-普通用户,2-VIP用户
if (userType == 1) {
// 普通用户:不能修改手机号(如果传了手机号,就校验失败)
if (dto.getPhone() != null && !dto.getPhone().trim().isEmpty()) {
violations.add(buildViolation(dto, "phone", "普通用户暂不支持修改手机号"));
}
// 普通用户:邮箱如果传了,格式要正确
if (dto.getEmail() != null && !dto.getEmail().trim().isEmpty()) {
if (!dto.getEmail().matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
violations.add(buildViolation(dto, "email", "邮箱格式不正确"));
}
}
} else if (userType == 2) {
// VIP用户:手机号如果传了,格式要正确
if (dto.getPhone() != null && !dto.getPhone().trim().isEmpty()) {
if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) {
violations.add(buildViolation(dto, "phone", "手机号格式不正确"));
}
}
// VIP用户:邮箱如果传了,格式要正确
if (dto.getEmail() != null && !dto.getEmail().trim().isEmpty()) {
if (!dto.getEmail().matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
violations.add(buildViolation(dto, "email", "邮箱格式不正确"));
}
}
} else {
violations.add(buildViolation(dto, "userType", "用户类型错误,仅支持普通用户(1)和VIP用户(2)"));
}
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
这种场景用注解几乎无法实现,因为注解无法根据userType这个动态参数调整校验规则;而编程式验证只需通过if-else就能轻松区分角色,逻辑清晰易懂。
场景 3:集合细节校验(批量添加商品,无重复 ID + 字段合规)
需求:批量添加商品时,“商品列表不能为空”“列表中不能有重复的商品 ID”“每个商品的价格不能为负,库存不能小于 0”。
校验逻辑代码
public void validateBatchAdd(List<ProductDTO> productList) {
Set<ConstraintViolation<List<ProductDTO>>> violations = new HashSet<>();
// 规则1:列表不能为空
if (productList == null || productList.isEmpty()) {
violations.add(buildListViolation(productList, "productList", "商品列表不能为空"));
throw new ConstraintViolationException(violations);
}
// 规则2:列表中不能有重复的商品ID
Set<Long> existedIds = new HashSet<>();
for (int i = 0; i < productList.size(); i++) {
ProductDTO product = productList.get(i);
Long productId = product.getProductId();
// 子规则2.1:商品ID不能为空
if (productId == null) {
// 这里要指明是第几个元素失败,方便前端定位问题
String field = "productList[" + i + "].productId";
violations.add(buildListViolation(productList, field, "第" + (i+1) + "个商品的ID不能为空"));
}
// 子规则2.2:商品ID不能重复
else if (existedIds.contains(productId)) {
String field = "productList[" + i + "].productId";
violations.add(buildListViolation(productList, field, "第" + (i+1) + "个商品的ID与前面重复(ID:" + productId + ")"));
} else {
existedIds.add(productId);
}
// 规则3:每个商品的价格不能为负
if (product.getPrice() == null || product.getPrice() < 0) {
String field = "productList[" + i + "].price";
</doubaocanvas>
violations.add (buildListViolation (productList, field, "第" + (i+1) + "个商品的价格不能为负"));
}
// 规则 4:每个商品的库存不能小于 0
if (product.getStock () == null || product.getStock () < 0) {
String field = "productList [" + i + "].stock";
violations.add (buildListViolation (productList, field, "第" + (i+1) + "个商品的库存不能小于 0"));
}
}
// 最后检查是否有校验失败
if (!violations.isEmpty ()) {
throw new ConstraintViolationException (violations);
}
}
// 集合校验的辅助方法(和单个对象的 buildViolation 逻辑类似,泛型适配 List)
private ConstraintViolation<List> buildListViolation(List target, String field, String message) {
return new ConstraintViolation<List>() {
@Override
public String getMessage() {
return message;
}
@Override
public String getPropertyPath() {
return field;
}
// 其他接口方法返回默认值,参考之前的实现
@Override
public String getMessageTemplate () { return null;}
@Override
public List getRootBean() { return target; }
@Override
public Class<List> getRootBeanClass() { return (Class<List>) target.getClass(); }
@Override
public Object getLeafBean() { return target; }
@Override
public Object getInvalidValue() { return null; }
@Override
public ConstraintDescriptor<?> getConstraintDescriptor() { return null; }
@Override
public Object[] getExecutableParameters() { return new Object[0]; }
@Override
public Object getExecutableReturnValue() { return null; }
@Override
public int getParameterIndex() { return -1; }
@Override
public Set<ConstraintViolation<List>> getConstraintViolations() { return new HashSet<>(); }
};
}
集合校验是注解的“硬伤”——虽然`@Valid`能配合`List`做基础校验(比如每个元素的字段不为空),但“去重”“定位到具体元素的错误”这类需求完全无法实现。而编程式验证不仅能精准定位错误位置,还能灵活添加各种自定义规则,后期维护时看代码就知道每个校验点的逻辑,非常清晰。
## 四、优化与避坑:让编程式验证更优雅
在实际开发中,直接用基础写法可能会遇到“代码冗余”“异常处理混乱”等问题,分享几个优化技巧和避坑要点,让你的编程式验证更优雅。
### 1. 优化:复用辅助方法,减少重复代码
前面的例子中,`buildViolation`和`buildListViolation`有大量重复逻辑,我们可以抽成一个通用的工具类,避免每个校验器都写一遍。
```java
/**
* 校验辅助工具类
* 封装通用的ConstraintViolation构建逻辑,供所有校验器复用
*/
public class ValidationUtils {
/**
* 构建单个对象的校验失败结果
* @param target 目标对象
* @param field 字段名
* @param message 提示信息
* @param <T> 泛型
* @return ConstraintViolation
*/
public static <T> ConstraintViolation<T> buildViolation(T target, String field, String message) {
return new ConstraintViolation<T>() {
@Override
public String getMessage() { return message; }
@Override
public String getPropertyPath() { return field; }
@Override
public String getMessageTemplate() { return null; }
@Override
public T getRootBean() { return target; }
@Override
public Class<T> getRootBeanClass() { return (Class<T>) target.getClass(); }
@Override
public Object getLeafBean() { return target; }
@Override
public Object getInvalidValue() { return null; }
@Override
public ConstraintDescriptor<?> getConstraintDescriptor() { return null; }
@Override
public Object[] getExecutableParameters() { return new Object[0]; }
@Override
public Object getExecutableReturnValue() { return null; }
@Override
public int getParameterIndex() { return -1; }
@Override
public Set<ConstraintViolation<T>> getConstraintViolations() { return new HashSet<>(); }
};
}
/**
* 构建集合的校验失败结果
* @param target 目标集合
* @param field 字段名(格式:list[索引].field)
* @param message 提示信息
* @param <T> 泛型
* @return ConstraintViolation
*/
public static <T> ConstraintViolation<List<T>> buildListViolation(List<T> target, String field, String message) {
return new ConstraintViolation<List<T>>() {
@Override
public String getMessage() { return message; }
@Override
public String getPropertyPath() { return field; }
@Override
public String getMessageTemplate() { return null; }
@Override
public List<T> getRootBean() { return target; }
@Override
public Class<List<T>> getRootBeanClass() { return (Class<List<T>>) target.getClass(); }
@Override
public Object getLeafBean() { return target; }
@Override
public Object getInvalidValue() { return null; }
@Override
public ConstraintDescriptor<?> getConstraintDescriptor() { return null; }
@Override
public Object[] getExecutableParameters() { return new Object[0]; }
@Override
public Object getExecutableReturnValue() { return null; }
@Override
public int getParameterIndex() { return -1; }
@Override
public Set<ConstraintViolation<List<T>>> getConstraintViolations() { return new HashSet<>(); }
};
}
}
之后在各个校验器中,直接调用ValidationUtils.buildViolation(...)即可,不用重复写接口实现,代码瞬间简洁很多。
2. 避坑:全局统一处理校验异常
如果每个接口校验失败都直接抛出ConstraintViolationException,前端会收到杂乱的异常堆栈信息,体验很差。我们可以用 Spring 的@ControllerAdvice写一个全局异常处理器,统一返回格式。
/**
* 全局异常处理器
* 统一处理校验失败、业务异常等,返回友好的响应格式
*/
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理参数校验失败异常
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ResultVO> handleValidationException(ConstraintViolationException e) {
// 收集所有校验失败的信息
List<String> errorList = e.getConstraintViolations().stream()
.map(violation -> violation.getPropertyPath() + ":" + violation.getMessage())
.collect(Collectors.toList());
// 构建统一响应体
ResultVO result = new ResultVO();
result.setCode(400); // 400:参数错误
result.setMsg("参数校验失败");
result.setData(errorList);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
/**
* 统一响应体
* 前后端交互的标准格式,包含状态码、提示信息、数据
*/
@Data // 用Lombok简化getter/setter,没有Lombok可以手动实现
public static class ResultVO {
private Integer code; // 状态码:200成功,400参数错误,500服务器错误
private String msg; // 提示信息
private Object data; // 响应数据(成功时返回业务数据,失败时返回错误列表)
}
}
这样一来,无论哪个接口校验失败,前端都会收到格式统一的 JSON 响应,比如:
{
"code": 400,
"msg": "参数校验失败",
"data": [
"productList[2].productId:第3个商品的ID与前面重复(ID:1002)",
"productList[0].price:第1个商品的价格不能为负"
]
}
前端可以直接解析data里的错误信息,展示给用户,体验大幅提升。
3. 避坑:校验逻辑与业务逻辑分离
很多新手容易把校验逻辑直接写在业务方法里,比如在 “创建订单” 的createOrder方法里,先写几十行校验代码,再写业务逻辑 —— 这样会导致业务方法臃肿,后期想修改校验规则时,需要在业务代码里 “翻找”,维护成本很高。
正确的做法是:把校验逻辑抽成单独的 “校验器类”,每个校验器只负责一类 DTO 的校验(比如OrderValidator负责订单相关 DTO,UserValidator负责用户相关 DTO),业务层只需要调用校验器的方法即可,符合 “单一职责原则”。
就像前面例子中,OrderService的createOrder方法里,只需要调用orderValidator.validateCreate(dto),不用关心具体的校验规则 —— 这样业务代码更纯粹,校验逻辑也更易维护。
五、总结:注解与编程式验证的选择之道
看到这里,可能有读者会问:“既然编程式验证这么灵活,那以后是不是不用@Valid了?”
其实不是 —— 技术没有 “绝对最优”,只有 “场景适配”。@Valid和编程式验证各有优势,合理搭配使用才能让代码更优雅。
给大家总结一个简单的 “选择原则”:
场景类型推荐方式理由
简单静态校验注解校验(@Valid)一行注解搞定 “不为空、长度限制、邮箱格式” 等固定规则,代码简洁,开发效率高。
复杂动态校验编程式验证能处理 “条件依赖、角色差异化、集合去重、外部数据校验” 等注解搞不定的场景,灵活度高。
混合场景(简单 + 复杂)注解 + 编程式结合简单规则用注解(比如@NotNull校验订单号),复杂规则用编程式(比如金额超 1000 填身份证号),兼顾效率与灵活。
举个混合场景的例子:
// 订单DTO:简单规则用注解,复杂规则用编程式
public class OrderCreateDTO {
@NotNull(message = "订单号不能为空") // 简单静态规则:用注解
private String orderNo;
private BigDecimal amount; // 复杂规则:金额超1000需填身份证号,用编程式
private String idCard; // 复杂规则:依赖amount,用编程式
// getter/setter
}
// 校验器:注解校验+编程式校验结合
@Component
public class OrderValidator {
@Autowired
private Validator validator;
public void validateCreate(OrderCreateDTO dto) {
// 1. 先执行注解校验(处理@NotNull等简单规则)
Set<ConstraintViolation<OrderCreateDTO>> violations = validator.validate(dto);
// 2. 再执行编程式校验(处理复杂规则)
if (dto.getAmount() != null && dto.getAmount().compareTo(new BigDecimal("1000")) > 0) {
if (dto.getIdCard() == null || dto.getIdCard().trim().isEmpty()) {
violations.add(ValidationUtils.buildViolation(dto, "idCard", "订单金额超1000元,需填写身份证号"));
}
}
// 3. 校验失败抛出异常
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
这种 “注解 + 编程式” 结合的方式,既保留了注解的简洁,又兼顾了编程式的灵活,是日常开发中最推荐的做法。
最后想跟大家说:技术的核心是 “解决问题”,而不是 “追求单一工具”。@Valid让我们在简单场景下高效开发,编程式验证让我们在复杂场景下优雅应对 —— 掌握这两种方式,无论遇到什么参数校验需求,你都能游刃有余~