简介:参数校验是接口安全的第一道防线。本文从脏数据入库的真实场景出发,梳理Spring Boot中基于Bean Validation的校验体系,覆盖基础注解、分组校验、嵌套校验、全局异常统一响应与常见失效原因,帮助开发者在请求入口就完成数据拦截,避免错误数据流向Service甚至数据库。
很多项目在接口层接收参数后直接进入业务逻辑,直到数据库约束生效、邮件发送失败或下游查询异常时,才发现数据本身就不合法。空用户名、负数年龄、错误邮箱格式等脏数据一旦写入系统,排查成本远高于修复成本。参数校验应当前置,在请求进入Controller方法体之前就完成基础合法性判断,而不是让错误数据在Service层、持久层甚至更下游才暴露。
Spring Boot生态中,参数校验的标准方案基于JSR 380规范,也被称为Bean Validation。它通过注解声明字段约束,由容器在参数绑定阶段自动触发校验。Spring Boot 3.x使用jakarta.validation.constraints包,Spring Boot 2.x使用javax.validation.constraints包,两者注解名称和语义一致,仅包名随Jakarta EE迁移发生变化。
以一个用户注册接口为例,接收请求体的数据结构可以这样定义:
public class UserDTO {@NotBlank(message = "用户名不能为空")@Size(min = 2, max = 20, message = "用户名长度需要在2到20个字符之间")private String username;@NotBlank(message = "邮箱不能为空")@Email(message = "邮箱格式不正确")private String email;@NotNull(message = "年龄不能为空")@Min(value = 0, message = "年龄不能为负数")@Max(value = 150, message = "年龄超出合理范围")private Integer age;@NotBlank(message = "密码不能为空")@Size(min = 8, max = 32, message = "密码长度需要在8到32个字符之间")private String password;}
这些注解的语义非常直观。@NotBlank针对字符串,要求值不为null且去除首尾空格后长度大于0;@NotNull只判断是否为null,适用于包装类型;@Email根据正则规则校验邮箱格式;@Size限制字符串长度或集合大小;@Min和@Max用于数值范围。将注解直接定义在DTO字段上,校验规则与数据结构绑定,后续任何使用该DTO的接口都会自动复用同一套约束。
Controller层只需要在方法参数前添加@Validated或@Valid注解:
@PostMapping("/register")public Result register(@Validated @RequestBody UserDTO user) {userService.register(user);return Result.ok();}
Spring在参数绑定完成之后、方法体执行之前触发校验。任何约束不满足都会抛出MethodArgumentNotValidException,请求根本不会进入register方法。这个机制确保了业务代码只处理已经通过基础合法性检查的数据。
但默认的异常响应并不友好。Spring Boot返回的错误体通常包含timestamp、status、error和path四个字段,缺少具体的字段错误信息。前端无法直接告诉用户“邮箱格式不正确”还是“用户名长度不够”,需要从全局异常处理器中提取绑定结果。
借助@RestControllerAdvice,可以集中处理所有Controller抛出的异常。对于参数校验失败场景,关键是拿到BindingResult中的FieldError列表:
@RestControllerAdvicepublic class GlobalExceptionHandler {@ExceptionHandler(MethodArgumentNotValidException.class)public Result handleValidationException(MethodArgumentNotValidException e) {String message = e.getBindingResult().getFieldErrors().stream().map(error -> error.getField() + ": " + error.getDefaultMessage()).collect(Collectors.joining("; "));return Result.fail(400, message);}}
通过getFieldErrors可以拿到所有字段级错误,每个FieldError包含字段名、拒绝值和默认消息。拼接后的响应能够明确告知前端是哪个字段、哪条规则未通过。对于需要兼容复杂前端表单的场景,也可以返回结构化错误列表,而不是简单拼接字符串。
除了MethodArgumentNotValidException,还需要处理请求参数层面的校验异常。当@RequestParam、@PathVariable等参数使用约束注解且校验失败时,Spring抛出ConstraintViolationException。两类异常的来源不同,前者针对请求体对象绑定,后者针对方法参数级约束,需要分别处理。
同一个DTO在不同业务场景下往往需要不同的校验规则。创建用户时要求密码必填,更新用户时密码可能允许为空;新增订单时商品ID必填,查询订单时商品ID可作为过滤条件。通过校验分组可以让同一DTO适配不同接口。
定义两个标记接口作为分组:
public interface CreateGroup {}public interface UpdateGroup {}
在DTO字段注解上指定groups属性:
public class UserDTO {@NotNull(groups = CreateGroup.class, message = "创建用户时用户名不能为空")private String username;@NotBlank(groups = CreateGroup.class, message = "创建用户时密码不能为空")private String password;@NotNull(groups = UpdateGroup.class, message = "更新用户时用户ID不能为空")private Long userId;}
Controller方法使用@Validated注解的value属性指定触发哪个分组:
@PostMapping("/create")public Result create(@Validated(CreateGroup.class) @RequestBody UserDTO user) {// 仅校验CreateGroup标记的约束}@PostMapping("/update")public Result update(@Validated(UpdateGroup.class) @RequestBody UserDTO user) {// 仅校验UpdateGroup标记的约束}
未指定groups属性的注解属于默认分组,只有在使用@Validated未指定分组时才会被校验。分组机制让校验规则从DTO中解耦出一层场景语义,避免为每个接口重复定义结构相似但约束不同的DTO类。
当DTO内部包含另一个需要校验的对象时,仅在外层字段上标注@Valid才会级联校验。比如用户信息中包含地址对象:
public class UserDTO {@Valid@NotNull(message = "地址信息不能为空")private AddressDTO address;}
AddressDTO内部可以继续使用约束注解,@Valid会让校验器继续深入检查嵌套对象。如果缺少@Valid,外层@NotNull只判断address是否为null,不会检查address内部的字段合法性。嵌套可以是多层的,每一层关联对象都需要在引用处标注@Valid,校验器会递归执行。
对于集合类型的嵌套字段,同样使用@Valid标注在集合字段上,校验器会遍历集合中的每个元素进行级联校验。这在批量导入、批量创建等场景下尤为重要,一个批次中的任意一条非法数据都应当被拦截。
内置注解无法覆盖所有业务场景。例如要求用户名唯一、手机号符合特定规则、某个字段的值依赖另一个字段等,需要自定义校验注解。自定义校验分为两步。第一步定义注解:
@Target({ElementType.FIELD})@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 PHONE_PATTERN =Pattern.compile("^1[3-9]\\d{9}$");@Overridepublic boolean isValid(String value, ConstraintValidatorContext context) {if (value == null || value.isEmpty()) {return true;}return PHONE_PATTERN.matcher(value).matches();}}
自定义注解需要在注解定义上通过@Constraint指定校验器类,校验器实现ConstraintValidator接口的isValid方法。注意空值处理策略:自定义校验器默认不会收到null值,如果字段同时标注了@NotNull或@NotBlank,空值由这些注解负责;如果允许为空且非空时才校验格式,需要在isValid中对空值返回true。
参数校验看似简单,实际项目中有不少场景会失效。最常见的三类原因如下。
第一,忘记在Controller方法参数上添加@Validated或@Valid。DTO字段上的约束注解只是声明,需要触发器才会执行。当校验器没有被触发时,非法数据会直接穿过Controller层。
第二,Controller类没有交给Spring容器管理。@Validated触发依赖于Spring MVC的参数解析流程。如果Controller不是由Spring扫描创建,而是自行实例化,参数解析器不会执行校验逻辑。
第三,方法参数类型不匹配。当@Validated标注在非Bean类型参数上,例如直接标注在String或Integer参数上,校验行为与预期不同。对于简单参数,使用约束注解配合@RequestParam时,需要在Controller类上标注@Validated,让Spring处理方法参数级校验。
另一个容易被忽略的问题是校验顺序。@Validated触发的约束按照字段在类中的定义顺序执行,但不同校验器之间的执行顺序并不保证完全稳定。当需要确保某个校验先于其他校验时,可以使用@GroupSequence定义分组执行顺序。
将参数校验集中在Controller入口,带来的不只是代码整洁。它明确了系统边界:任何进入Service层的数据都已经通过基础合法性检查。Service层可以专注于业务规则和事务处理,持久层可以专注于存储映射,每一层职责清晰。脏数据从一开始就被拦截,数据库约束异常、邮件发送失败、下游查询异常等因非法数据引发的问题大幅减少。
对于排查问题而言,校验前置意味着每一个非法请求都在入口处留有明确记录。日志中可以清晰看到哪个字段、哪个约束未通过,请求来源、时间、参数内容都可追溯。相比数据在系统内部流转多轮后才暴露问题,入口拦截的定位成本低得多。
在生产环境中,参数校验还可以与API网关或统一的请求过滤器配合,但DTO层校验仍然不可替代。网关能够拦截协议级和基础格式问题,业务级约束必须由应用自身完成。Bean Validation方案与Spring Boot深度集成,无需引入额外框架,是接口参数校验的首选实践。