参数校验:一条 @Valid 拦不住的那一半

作者:忆笙智云官方 | 发布时间:2026-11-05 00:56 | 更新时间:2026-11-05 00:56

参数校验:一条 @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),还包括:

所以升级到 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 用在新建和修改两个接口上,是后台系统里最常见的复用。但这两个场景的校验规则几乎总是冲突的:

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 混杂,后面 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 能解决的是单条数据的自洽性:字段非空、长度合法、格式正确、数值范围合理。它解决不了这些问题:

判断标准很朴素:这条规则,单看这一个对象就能判断吗? 能,用注解。不能,写代码。

十一、一个可以照着做的最小落地

如果要把上面这些收敛成一套能直接用的东西,大概是四件事:

一、所有请求体 DTO 独立定义,不复用实体类。实体上的约束跟着数据库走,DTO 上的约束跟着接口走,这两者本来就不是一回事。复用一个类,迟早会为了某个接口放宽某个字段的约束,然后所有接口一起放松。

二、控制器上不加类级 @Validated,参数对象统一用 @Valid,嵌套字段记得级联,集合包一层对象。

三、全局异常处理器同时处理 MethodArgumentNotValidException 和 HandlerMethodValidationException,输出结构统一成 {code, msg, errors: {field: message}},并且保证 errors 里的 key 就是前端表单的 prop。

四、前端不做"校验全覆盖",但必须做"错误回填",把后端的 errors 准确落到对应的表单项上。

这四条都不复杂,难点不在写,而在于团队里每个人对"校验该写在哪"有同一个答案。校验代码的价值不在于它拦住了多少请求,而在于它让"数据进来之后是什么样"这件事变得没有歧义。