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