告别 @Valid 局限!Spring Boot 编程式验证,复杂校验场景的优雅解法

作为一名 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让我们在简单场景下高效开发,编程式验证让我们在复杂场景下优雅应对 —— 掌握这两种方式,无论遇到什么参数校验需求,你都能游刃有余~

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容