参数校验:一条 @Valid 拦不住的那一半
参数校验:一条 @Valid 拦不住的那一半
后端接口的校验代码,通常是这么长出来的:先写业务逻辑,联调时前端说"这个字段我这边限制不了",于是加个 @NotNull;测试提了个 500 的 bug,于是加个 @Size;上线后有人传了个负数金额,于是再加个 @Min。加了半年,实体上一排注解,但真要问"这个接口到底校验了什么、失败时返回什么、前端怎么显示",没人说得清。
这篇想讲清楚三件事:Spring MVC 里校验其实有两层,两层的异常类型不一样;@Valid 和 @Validated 的分工不是"随便选一个";以及前端那些看起来已经做过的校验,为什么一条都不能替代后端。
一、@Valid 本身不是校验,它只是"往下走"
这是最容易搞混的一点。@Valid 来自 Bean Validation 规范,它的语义是级联——"请顺便校验我这个字段内部声明的约束",它自己不带任何约束规则。
所以下面这段代码,什么都不会校验:
@PostMapping("/orders")
public Result create(@RequestBody @Valid OrderCreateReq req) {
// req 内部一个约束注解都没有
}
@Valid 只是让框架进入 req 这个对象,去读里面有没有 @NotNull、@Size。里面没有,就等于没校验。
这个认知的价值在于:@Valid 该加在哪一层,是由数据结构决定的,不是习惯决定的。一个三层嵌套的请求体,只有每一层都加 @Valid,级联才会继续往下:
public class OrderCreateReq {
@Valid
@NotNull
private CustomerInfo customer; // 第二层:必须加 @Valid 才会进去
@Valid
@NotEmpty
private List<OrderItemReq> items; // 集合元素上的 @Valid 是另一回事,见第四节
}
public class CustomerInfo {
@NotBlank
private String name; // 只有上面那层加了 @Valid,这里才会生效
}
少加一个 @Valid,整棵子树的校验就静默失效了。它不会报错,也不会警告,只是不生效。这是所有校验 bug 里最常见的一种。
二、MVC 有两层校验,异常类型不同
Spring MVC 的校验分两个层次,这一点在文档里写得很清楚,但很多人只用了第一层。
第一层:参数对象级校验。 @Valid 或 @Validated 加在 @RequestBody、@ModelAttribute、@RequestPart 参数上,校验的是整个对象。失败抛 MethodArgumentNotValidException。
第二层:方法级校验。 约束注解直接写在方法参数上,比如 @RequestParam @Min(1) int page。这是 Spring Framework 6.1 之后 MVC 内置支持的能力,失败抛 HandlerMethodValidationException。
@GetMapping("/orders")
public Result<Page<OrderVO>> list(
@RequestParam(defaultValue = "1") @Min(1) long current,
@RequestParam(defaultValue = "20") @Min(1) @Max(500) long size) {
// 这里是方法级校验,失败抛 HandlerMethodValidationException
}
关键在于:这两个异常是并列的,不是继承关系。你的全局异常处理器如果只写了 MethodArgumentNotValidException,那么 @RequestParam 上的约束一旦不满足,就会走到默认的 500 错误页——前端拿到的是一个 HTML 错误页或者一坨堆栈,而不是熟悉的 {code, msg}。
所以全局处理必须两个都接:
@RestControllerAdvice
public class GlobalExceptionHandler {
// 第一层:对象级
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleBody(MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
Map<String, String> detail = new LinkedHashMap<>();
for (FieldError e : errors) {
detail.putIfAbsent(e.getField(), e.getDefaultMessage());
}
return ResponseEntity.badRequest().body(Map.of(
"code", 400,
"msg", "参数校验失败",
"errors", detail));
}
// 第二层:方法级
@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<Object> handleMethod(HandlerMethodValidationException ex) {
Map<String, String> detail = new LinkedHashMap<>();
ex.visitResults(new HandlerMethodValidationException.Visitor() {
@Override
public void requestParam(RequestParam param, ParameterValidationResult result) {
String name = param != null ? param.name() : "unknown";
detail.put(name, result.getResolvableErrors().get(0).getDefaultMessage());
}
});
return ResponseEntity.badRequest().body(Map.of(
"code", 400,
"msg", "参数校验失败",
"errors", detail));
}
}
visitResults 配合 Visitor 的写法有点绕,但它比遍历 ParameterValidationResult 更有价值的地方在于:你可以按参数来源(header / query / path / body / cookie)分别处理,因为不同来源的错误,前端展示的位置本来就不同。
detail.putIfAbsent 那个细节也值得说一句。同一个字段如果有多个约束同时失败(比如既 @NotBlank 又 @Size),getFieldErrors() 里会有多条。全塞给前端,用户会看到两个气泡提示堆在一起。取第一条就够,putIfAbsent 保证这一点。
三、那个反直觉的地方:类上的 @Validated 要删掉
这一节可能是全文最"哦,原来是这样"的部分。
在老项目里,控制器上经常会有这么一行:
@Validated // 为了校验 @RequestParam 上的约束
@RestController
@RequestMapping("/orders")
public class OrderController { ... }
在 Spring Framework 6.1 之前,这是标准做法:类上加 @Validated,Spring 就会给这个 bean 织入一个 AOP 代理,代理在方法调用前做校验,失败抛 ConstraintViolationException。
但 6.1 之后,MVC 内置了方法校验能力,不需要 AOP 了。而官方文档里有一句非常直接的话,大意是:如果控制器上有类级 @Validated,方法校验仍然会通过 AOP 代理进行;想要用上 MVC 6.1 的内置支持,必须把类上的 @Validated 删掉。
也就是说,那个"为了开启校验"而加的注解,在新版本里反而让你的控制器走上了旧路径。后果不只是异常类型不同(ConstraintViolationException 而不是 HandlerMethodValidationException),还包括:
- AOP 代理带来的自调用失效问题——同一个类内部方法互调,校验不会触发;
- 代理需要走 CGLIB 或 JDK 动态代理,对
final方法、private方法无效; - 异常里拿不到
MethodParameter,想精确定位是哪个参数失败,只能去解析ConstraintViolation.getPropertyPath()的字符串,形如list.current、list.size,脆弱且难看。
所以升级到 Boot 3.2+(对应 Framework 6.1)之后,一件值得主动做的事就是:把控制器类上的 @Validated 删掉,只保留参数对象上的 @Valid 和方法参数上的约束注解。
不过要注意一个例外。@Validated 在非控制器的 Spring bean 上依然是必需的——比如你在 Service 上写方法参数约束,那就必须有 @Validated,并且容器里要有 MethodValidationPostProcessor,因为它走的是 AOP 代理那条路。Spring Boot 会在检测到 Bean Validation 实现时自动注册这个处理器,所以通常不用手动声明。但"自动注册"这件事只对 Boot 生效,纯 Spring 项目要自己加。
一句话总结这个分工:控制器用内置的,Service 用 @Validated + AOP 的。两套机制并存,异常类型也不同,别混。
四、@RequestBody @Valid List<Foo> 校验不到元素
这个坑很隐蔽,因为 IDE 不提示,编译也过,运行时也不报错,只是不生效。
Spring MVC 文档里明确写了前提:@Valid 应用于 @ModelAttribute、@RequestBody、@RequestPart 参数,前提是这个参数是一个"命令对象",而不是 Map、Collection 这样的容器。
// 不生效:List 是容器,元素上的约束不会被校验
@PostMapping("/batch")
public Result batch(@RequestBody @Valid List<OrderItemReq> items) { ... }
// 生效:包一层
@PostMapping("/batch")
public Result batch(@RequestBody @Valid OrderBatchReq req) {
// req.items 上再加 @Valid
}
正确做法是包一个对象出来,把集合作为字段,然后在字段上加 @Valid:
public class OrderBatchReq {
@Valid
@NotEmpty(message = "请至少提交一条明细")
@Size(max = 200, message = "单次最多提交 200 条")
private List<OrderItemReq> items;
}
有人会想用 @RequestBody List<@Valid OrderItemReq> items 这种容器元素约束的写法。理论上 Bean Validation 2.0 支持容器元素约束,但在 MVC 的参数解析链路上,这条路并不可靠,而且即使约束生效,错误信息的 propertyPath 会是 items[3].price 这种形式,前端回填表单时要额外解析。包一层对象,成本更低,行为更确定。
五、分组校验:新建和修改的规则本来就不一样
同一个 DTO 用在新建和修改两个接口上,是后台系统里最常见的复用。但这两个场景的校验规则几乎总是冲突的:
- 新建时
id必须为空,修改时id必须非空; - 新建时密码必填,修改时密码留空表示"不修改";
- 新建时编号由后端生成,修改时编号不允许变更。
Bean Validation 提供的解法是分组。@Valid 是 JSR 标准注解,不支持分组;Spring 的 @Validated 支持,这是它相对 @Valid 唯一不可替代的地方。
public interface Create {}
public interface Update {}
public class UserSaveReq {
@Null(groups = Create.class, message = "新建时不能指定 ID")
@NotNull(groups = Update.class, message = "修改时必须指定 ID")
private Long id;
@NotBlank(groups = Create.class, message = "新建时密码必填")
private String password;
@NotBlank(message = "用户名必填") // 不指定 groups = Default 组
private String username;
}
控制器上指定用哪一组:
@PostMapping
public Result create(@RequestBody @Validated(Create.class) UserSaveReq req) { ... }
@PutMapping
public Result update(@RequestBody @Validated(Update.class) UserSaveReq req) { ... }
这里有个必须知道的规则:一旦指定了分组,Default 组的约束就不会被执行。上面 username 上的 @NotBlank 没有指定 groups,属于 Default 组,所以调 create 接口时它根本不校验——新建一个用户可以不填用户名。
这个坑非常容易踩,而且测试很难发现,因为大部分请求都填了用户名。修法是让分组接口继承 Default:
public interface Create extends Default {}
public interface Update extends Default {}
jakarta.validation.groups.Default 是规范里预定义的接口,让自定义分组继承它,指定分组时会一并把 Default 组的约束带上。这一行继承,能省掉后面无数个"为什么这个字段没校验"的排查。
分组还有一个不那么常见但很有用的能力:@GroupSequence。默认情况下,一个对象上多个约束的求值顺序是不确定的。用户提交一个空字符串的名字,可能先报"长度必须 2 到 20 位",而不是"不能为空"——后者显然更合理。用序列可以把"基础校验"和"格式校验"分阶段:
@GroupSequence({Basic.class, Format.class, UserSaveReq.class})
public class UserSaveReq { ... }
这样第一阶段只跑 Basic 组的约束,全部通过才进第二阶段。代价是注解变多,可读性下降,所以只在确实需要控制提示顺序的场合用。多数情况下,前端只展示第一条错误,顺序影响不大。
六、前端校验的定位:它是体验,不是防线
现在说 Vue 这边。el-form 的 rules 写起来很顺手,required: true、min/max、正则、自定义 validator,几乎能覆盖所有场景。于是很自然的一个想法是:"前端都校验过了,后端就不用重复写了吧。"
不能这么想,原因不是"用户可能绕过前端"这种老生常谈,而是更具体的两点:
第一,前端的校验规则和接口的契约是两份东西。 前端改了规则,后端不知道;后端加了约束,前端不知道。加上时间差,就是线上事故。真正可靠的做法只有两个:要么后端是唯一的真相,前端规则从接口文档生成;要么两边都写,但把"后端返回的错误码和字段名"当成正式契约维护,前端必须能正确展示后端返回的校验错误。
第二,前端校验的失败是"拦住了",后端校验的失败是"拒收了"。 前者用户体验上的意义是即时反馈,后者的意义是数据完整性。这两者不能互换。
所以前端该做的事,不是"把校验做全",而是把后端返回的校验错误正确地展示出来。这一步经常被漏掉,导致后端明明返回了 {errors: {phone: "手机号格式不正确"}},页面上却弹一个通用的"操作失败"。
用 el-form 的话,回填的方式是 setFields:
import { ElMessage } from 'element-plus';
async function submit() {
try {
await formRef.value.validate();
} catch {
return; // 前端规则没过,validate 会 reject,直接返回
}
try {
await api.createOrder(form.value);
ElMessage.success('创建成功');
} catch (err) {
const errors = err.response?.data?.errors;
if (errors && Object.keys(errors).length) {
formRef.value.setFields(
Object.entries(errors).map(([prop, message]) => ({ prop, message }))
);
} else {
ElMessage.error(err.response?.data?.msg || '操作失败');
}
}
}
两个细节。一是 validate() 返回的是 Promise,校验不通过时会 reject,必须 catch,否则控制台会冒出 Uncaught (in promise),很多项目里满屏都是这个警告,源头就在这。二是 setFields 的 prop 必须和 el-form-item 的 prop 完全一致,包括嵌套路径 customer.name 这种写法——后端返回的字段名如果是扁平的 name,就对不上了,得在前端做一次映射,或者让后端直接返回嵌套结构。
七、三个前后端"看起来一致、实际上不一致"的细节
这三个都跟字符串有关,都很容易在上线后才被发现。
第一,trim。 前端输入框通常会 v-model.trim,用户输入的空格被吃掉了。但接口是可以直接调的," 张三 " 原样传到后端,@NotBlank 能过(它不是全空格),@Size(max = 10) 也能过(长度 6),然后存进数据库。之后按姓名精确查询查不到,导出报表里带空格,去重逻辑失效。
要么后端统一 trim(推荐,在 DTO 的 setter 或者反序列化层做),要么前后端都做。只做前端是一种幻觉。
第二,长度单位。 @Size(max = 50) 用的是 CharSequence.length(),返回的是 UTF-16 码元数。而 MySQL 的 varchar(50) 在 utf8mb4 下算的是字符数。
一个 emoji(比如 😀,码位 U+1F600)在 Java 里 length() 是 2,在数据库里算 1 个字符。所以 25 个 emoji 就会被 @Size(max = 50) 拦下来,而数据库其实能装 50 个。反过来,中文汉字在两边都是 1,所以纯中文场景下看不出问题——这也是为什么这个 bug 总是等到有用户输入 emoji 才暴露。
如果要求严格对齐,用 @CodePointLength 这类自定义约束,或者直接用 String.codePointCount(0, s.length()) 判断。如果不想折腾,那就把前端的计数器也改成按码位算,至少让三边的口径一致。
第三,@NotNull / @NotEmpty / @NotBlank 的区别。 这三个经常被随手选一个用,但它们的行为差别很大:
@NotNull:只要不是null就行,""和" "都通过。@NotEmpty:不能是null,长度不能是 0。" "通过。可以用于集合、Map、数组。@NotBlank:不能是null,且trim()之后长度大于 0。只能用于字符串。
一个"备注"字段如果用了 @NotNull,用户提交空字符串也能过,数据库里存了一堆空串和 null 混杂,后面 where remark is null 就查不全。字符串字段的必填,语义上几乎总是 @NotBlank。
八、自定义约束:能写成注解的条件比想象中严
写自定义约束的模板是固定的:一个 @Constraint 注解,加一个 ConstraintValidator 实现。
@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 P = Pattern.compile("^1[3-9]\\d{9}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // 空值交给 @NotBlank 管
return P.matcher(value).matches();
}
}
两个容易写错的地方。
一是 isValid 里的 null 判断。Bean Validation 的约定是"所有约束都认为 null 是合法的",空值由 @NotNull 系列负责。如果自定义校验器对 null 返回 false,那么一个非必填字段留空就会报错,而且错误信息说的是"格式不正确",排查方向完全被带偏。
二是 ConstraintValidator 里能不能注入依赖。答案是能——Spring 的 LocalValidatorFactoryBean 默认使用 SpringConstraintValidatorFactory,所以校验器本身可以作为 Spring bean 被创建,@Autowired 有效。这让"用户名是否已存在"这类查库校验写得出来:
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
@Autowired
private UserMapper userMapper;
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true;
return userMapper.selectCount(
Wrappers.<User>lambdaQuery().eq(User::getUsername, value)) == 0;
}
}
但这类校验要想清楚三件事:每个字段一次查询,批量提交时会放大;校验和写入不在一个原子操作里,并发下依然可能重复;修改场景下要排除自己,否则不改用户名也报"已存在"。所以它更适合"给用户一个即时提示",最终一致性还是得靠数据库唯一索引。注解负责体验,索引负责正确。
九、错误消息不是文案,是前后端契约的一部分
这一点在多人协作里价值很高,但很少被当成设计问题。
Bean Validation 的默认消息是英文的(must not be blank、size must be between 1 and 10),直接返回给用户显然不行。加 message 属性是最直接的做法:
@NotBlank(message = "用户名不能为空")
private String username;
但这样写有两个问题:消息硬编码在注解里,改文案要改代码重新发版;同一个约束在十几个 DTO 里重复写,措辞还不一致。
更好的方式是走 MessageSource。Spring 会为每个约束失败生成一组错误码,按从具体到通用的顺序排列。按官方文档,Person 类上 name 字段的 @Size 违规,会生成这几个错误码:
Size.person.name
Size.name
Size.java.lang.String
Size
还有一组消息参数,依次是字段名和约束的属性值。你可以在 messages.properties 里用这些码覆盖文案,并且支持占位符引用参数:
Size.person.name={0} 长度必须在 {2} 到 {1} 之间
person.name=用户名
{0} 会被替换成"用户名"(它本身也是一个可以继续解析的消息码),{1}、{2} 是注解上的 max 和 min。这样一条配置就能覆盖所有 @Size 场景,而且措辞统一。
再往前走一步,就是错误码而不是错误文案。后端返回 USERNAME_SIZE_INVALID,前端自己决定显示中文、英文还是繁体。这样做的好处是多语言、文案调整都不用动后端,代价是前后端要维护一份错误码字典,且新增约束时两边都得改。对一个只服务中文用户的内部系统来说,这属于过度设计;但如果产品要出海,这是唯一干净的方案。
中间路线是现在多数项目的选择:后端返回中文消息,同时带上字段名。前端不解析消息内容,只按字段名定位表单项,把消息原样显示。这条路线的前提是字段名必须前后端一致——这就是前面强调 errors 的 key 要和 el-form-item 的 prop 对齐的原因。它看着像个小细节,实际上是这套方案能不能成立的基础。
十、校验能拦住格式,拦不住业务
最后要说清楚边界,否则很容易把校验当成万能工具。
Bean Validation 能解决的是单条数据的自洽性:字段非空、长度合法、格式正确、数值范围合理。它解决不了这些问题:
- 唯一性。 用户名重复,需要查库。可以用自定义
ConstraintValidator实现,但要清楚它带来的是"多一次查询",而且这个查询和真正插入之间有时间窗——两个并发请求可以同时通过校验。真正的保证只能是数据库唯一索引。校验负责给出友好提示,唯一索引负责兜底,两者都要有。 - 状态机合法性。 只有"待支付"的订单能取消。这是业务规则,跟数据格式无关,应该在 Service 层用一个显式的判断,抛业务异常,而不是想办法塞进注解里。
- 跨字段依赖。 "结束时间必须晚于开始时间",需要类级别的约束(自定义注解加在类上,
ConstraintValidator<Xxx, Form>里同时访问两个字段)。能做,但一旦超过两三个字段,注解的可读性会迅速崩掉,写成 Service 里的一个方法反而更清楚。 - 并发。 校验通过之后,数据可能已经被别人改了。这是乐观锁的领域,不是校验的领域。
判断标准很朴素:这条规则,单看这一个对象就能判断吗? 能,用注解。不能,写代码。
十一、一个可以照着做的最小落地
如果要把上面这些收敛成一套能直接用的东西,大概是四件事:
一、所有请求体 DTO 独立定义,不复用实体类。实体上的约束跟着数据库走,DTO 上的约束跟着接口走,这两者本来就不是一回事。复用一个类,迟早会为了某个接口放宽某个字段的约束,然后所有接口一起放松。
二、控制器上不加类级 @Validated,参数对象统一用 @Valid,嵌套字段记得级联,集合包一层对象。
三、全局异常处理器同时处理 MethodArgumentNotValidException 和 HandlerMethodValidationException,输出结构统一成 {code, msg, errors: {field: message}},并且保证 errors 里的 key 就是前端表单的 prop。
四、前端不做"校验全覆盖",但必须做"错误回填",把后端的 errors 准确落到对应的表单项上。
这四条都不复杂,难点不在写,而在于团队里每个人对"校验该写在哪"有同一个答案。校验代码的价值不在于它拦住了多少请求,而在于它让"数据进来之后是什么样"这件事变得没有歧义。