Spring Boot 加了 @Valid,嵌套参数为什么还没校验?
开篇
请求对象里包着登录参数,用户名明明为空,却没有提示必填。嵌套对象需要级联校验,而校验本身也得被调用。只在字段上写注解,不能保证所有入口都会自动执行。
环境:JDK 21、Spring Boot 3、Spring Framework 6、Jakarta Validation 3、Hibernate Validator 8、Lombok 1。下面的请求包装类和参数处理器均为应用自有类型。
坑点
坑点一:约束写在里面,外层却没有级联入口
外层检查通过,不代表内部属性也检查过。下面这个包装类在 bizParam 上同时使用 @NotNull 和 @Valid:
java
@Data
@Schema
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class InternalBizParamReq<T extends Param> extends InternalReq {
@NotNull
@Valid
@Schema(title = "业务参数", requiredMode = REQUIRED)
private T bizParam;
}
@NotNull 检查业务参数对象是否存在;@Valid 让校验器继续检查对象内部。只有 @Valid 时,null 本身通常不会因为级联而被拒绝。
坑点二:处理器拿到空校验结果,就以为参数已经合法
读取 BindingResult 的处理器不会主动创建校验结果。没有触发校验,或者没有把结果传进来,它就无从判断内部字段是否合格。
正确写法
避坑一:外层负责级联,内部负责字段约束
业务参数各自声明自己的规则,避免在包装类里写用户名、密码等业务知识:
java
@Data
@Schema
public class LoginParam implements Param {
@NotBlank
@Schema(title = "用户名", requiredMode = REQUIRED)
private String userName;
@NotBlank
@Schema(title = "密码", requiredMode = REQUIRED)
private String password;
}
调用示例直接使用校验器,方便把"注解有没有生效"与 HTTP 参数绑定分开观察。父类的 provider、consumer 也是必填项,因此先补齐它们:
java
InternalBizParamReq<LoginParam> request = new InternalBizParamReq<>();
request.setProvider("account-service");
request.setConsumer("demo-client");
LoginParam login = new LoginParam();
login.setPassword("demo-password");
request.setBizParam(login);
try (ValidatorFactory factory = Validation.byDefaultProvider()
.configure()
.messageInterpolator(new ParameterMessageInterpolator())
.buildValidatorFactory()) {
Validator validator = factory.getValidator();
System.out.println(validator.validate(request).stream()
.map(v -> v.getPropertyPath().toString())
.sorted().toList());
// [bizParam.userName]
login.setUserName("demo");
System.out.println(validator.validate(request).isEmpty());
// true
request.setBizParam(null);
System.out.println(validator.validate(request).stream()
.map(v -> v.getPropertyPath().toString())
.sorted().toList());
// [bizParam]
}
ParameterMessageInterpolator 来自 Hibernate Validator,示例用它避免依赖表达式语言引擎;这是直接调用验证器的局部测试,不是一次真实 HTTP 请求。
避坑二:先触发校验,再让处理器读取结果
处理器的执行逻辑如下。它拿到 FieldError 后,区分自定义提示、必填缺失和格式错误:
java
@Override
public Resp preHandle(AspectInfo aspectInfo) {
// 从参数列表中提取BindingResult
BindingResult bindingResult = aspectInfo.findParam(BindingResult.class);
if (bindingResult == null || !bindingResult.hasErrors()) {
return Resp.ok();
}
for (FieldError fieldError : bindingResult.getFieldErrors()) {
String message = fieldError.getDefaultMessage();
String fieldName = fieldError.getField();
//自定义的message以@开头
if (Strings.CS.startsWith(message, "@")) {
Resp resp = Resp.error(ErrorCode.PARAM_INVALID);
resp.setMessage(StringUtils.substringAfter(message, "@"));
return resp;
}
//必填参数未传
if (Strings.CS.equalsAny(fieldError.getCode(), PARAM_REQUIRED_CODES)) {
return Resp.error(ErrorCode.PARAM_REQUIRED, fieldName);
}
//参数格式不对
return Resp.error(ErrorCode.PARAM_INVALID, fieldName);
}
return Resp.ok();
}
实际接在 MVC 控制器时,应由 @Valid 等机制触发参数校验。需要 BindingResult 时,它的位置要紧跟被校验的参数,且请求要经过对应的切面入口。
下面是接入形式示例,不是完整控制器;其中操作仍由业务方法完成:
java
public Resp submit(
@Valid @RequestBody InternalBizParamReq<LoginParam> request,
BindingResult bindingResult) {
return Resp.ok();
}
这个方法签名本身不包含路由、切点和业务逻辑,不能复制后就当成完整接口。没有经过切面的入口,应由控制器或其他既有机制处理 BindingResult,不能直接返回成功。
还要注意:字符串里装着的 JSON 不是嵌套 JavaBean。若先接收字符串、再自行反序列化出业务对象,就要确认后续确实调用了校验器;外层 @Valid 不会自动钻进一段字符串校验其 JSON 内容。
总结
嵌套校验要同时满足三件事:外层有级联标记,内部有约束,调用链真正触发验证。最后的错误处理器只负责解释结果,不会替前面的步骤补做校验。
如果这篇文章对您有用,欢迎关注。后续会继续拆解 Java 后端的实用代码,讲清写法、原理和使用时要注意的细节。