【Spring】对象中参数添加校验注解,但校验不生效

devtools/2025/1/12 5:44:31/

问题复现

  • 在构建 Web 服务时,我们一般都会对一个 HTTP 请求的 Body 内容进行校验,例如我们来看这样一个案例及对应代码。
  • 当开发一个学籍管理系统时,我们会提供了一个 API 接口去添加学生的相关信息,其对象定义参考下面的代码:
    java">import lombok.Data;
    import javax.validation.constraints.Size;
    @Data
    public class Student {@Size(max = 10)private String name;private short age;
    }
    
  • 这里我们使用了 @Size(max = 10) 给学生的姓名做了约束(最大为 10 字节),以拦截姓名过长、不符合“常情”的学生信息的添加。
  • 定义完对象后,我们再定义一个 Controller 去使用它,使用方法如下:
    java">import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    import org.springframework.web.bind.annotation.RestController;
    @RestController
    @Slf4j
    @Validated
    public class StudentController {@RequestMapping(path = "students", method = RequestMethod.POST)public void addStudent(@RequestBody Student student){log.info("add new student: {}", student.toString());//省略业务代码};
    }
    
  • 我们提供了一个支持学生信息添加的接口。启动服务后,使用 IDEA 自带的 HTTP Client 工具来发送下面的请求以添加一个学生,当然,这个学生的姓名会远超想象(即 this_is_my_name_which_is_too_long):
    java">
    POST http://localhost:8080/students
    Content-Type: application/json
    {"name": "this_is_my_name_which_is_too_long","age": 10
    }
    
  • 很明显,发送这样的请求(name 超长)是期待 Spring Validation 能拦截它的,我们的预期响应如下(省略部分响应字段):
    HTTP/1.1 400 
    Content-Type: application/json{"timestamp": "2021-01-03T00:47:23.994+0000","status": 400,"error": "Bad Request","errors": ["defaultMessage": "个数必须在 0 和 10 之间","objectName": "student","field": "name","rejectedValue": "this_is_my_name_which_is_too_long","bindingFailure": false,"code": "Size"}],"message": "Validation failed for object='student'. Error count: 1","path": "/students"
    }
    
  • 但是理想与现实往往有差距。实际测试会发现,使用上述代码构建的 Web 服务并没有做任何拦截。

案例解析

  • 要找到这个问题的根源,我们就需要对 Spring Validation 有一定的了解。首先,我们来看下 RequestBody 接受对象校验发生的位置和条件。
  • 假设我们构建 Web 服务使用的是 Spring Boot 技术,我们可以参考下面的时序图了解它的核心执行步骤:
    在这里插入图片描述
  • 如上图所示,当一个请求来临时,都会进入 DispatcherServlet,执行其 doDispatch(),此方法会根据 Path、Method 等关键信息定位到负责处理的 Controller 层方法(即 addStudent 方法),然后通过反射去执行这个方法,具体反射执行过程参考下面的代码(InvocableHandlerMethod#invokeForRequest):
    java">public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,Object... providedArgs) throws Exception {//根据请求内容和方法定义获取方法参数实例Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);if (logger.isTraceEnabled()) {logger.trace("Arguments: " + Arrays.toString(args));}//携带方法参数实例去“反射”调用方法return doInvoke(args);
    }
    
  • 要使用 Java 反射去执行一个方法,需要先获取调用的参数,上述代码正好验证了这一点:getMethodArgumentValues() 负责获取方法执行参数,doInvoke() 负责使用这些获取到的参数去执行。
  • 而具体到 getMethodArgumentValues() 如何获取方法调用参数,可以参考 addStudent 的方法定义,我们需要从当前的请求(NativeWebRequest )中构建出 Student 这个方法参数的实例。
    java">public void addStudent(@RequestBody Student student)
    
  • 那么如何构建出这个方法参数实例?Spring 内置了相当多的 HandlerMethodArgumentResolver,参考下图: 在这里插入图片描述
  • 当试图构建出一个方法参数时,会遍历所有支持的解析器(Resolver)以找出适合的解析器,查找代码参考 HandlerMethodArgumentResolverComposite#getArgumentResolver:
    java">@Nullable
    private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);if (result == null) {//轮询所有的HandlerMethodArgumentResolverfor (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {//判断是否匹配当前HandlerMethodArgumentResolver if (resolver.supportsParameter(parameter)) {result = resolver;            this.argumentResolverCache.put(parameter, result);break;}}}return result;
    }
    
  • 对于 student 参数而言,它被标记为 @RequestBody,当遍历到 RequestResponseBodyMethodProcessor 时就会匹配上。匹配代码参考其 RequestResponseBodyMethodProcessor 的 supportsParameter 方法:
    java">@Override
    public boolean supportsParameter(MethodParameter parameter) {return parameter.hasParameterAnnotation(RequestBody.class);
    }
    
  • 找到 Resolver 后,就会执行 HandlerMethodArgumentResolver#resolveArgument 方法。它首先会根据当前的请求(NativeWebRequest)组装出 Student 对象并对这个对象进行必要的校验,校验的执行参考 AbstractMessageConverterMethodArgumentResolver#validateIfApplicable:
    java">protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {Annotation[] annotations = parameter.getParameterAnnotations();for (Annotation ann : annotations) {Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);//判断是否需要校验if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});//执行校验binder.validate(validationHints);break;}}
    }
    
  • 如上述代码所示,要对 student 实例进行校验(执行 binder.validate(validationHints) 方法),必须匹配下面两个条件的其中之一:
    • 标记了 org.springframework.validation.annotation.Validated 注解;
    • 标记了其他类型的注解,且注解名称以 Valid 关键字开头。
  • 因此,结合案例程序,我们知道:student 方法参数并不符合这两个条件,所以即使它的内部成员添加了校验(即 @Size(max = 10)),也不能生效。

问题修正

  • 针对这个案例,有了源码的剖析,我们就可以很快地找到解决方案。即对于 RequestBody 接受的对象参数而言,要启动 Validation,必须将对象参数标记上 @Validated 或者其他以 @Valid 关键字开头的注解,因此,我们可以采用对应的策略去修正问题。
  1. 标记 @Validated,修正后关键代码行如下:
    java">public void addStudent(@Validated @RequestBody Student student)
    
  2. 标记 @Valid 关键字开头的注解
    • 这里我们可以直接使用熟识的 javax.validation.Valid 注解,它就是一种以 @Valid 关键字开头的注解,修正后关键代码行如下:
      java">public void addStudent(@Valid @RequestBody Student student)
      
    • 另外,我们也可以自定义一个以 Valid 关键字开头的注解,定义如下:
      java">import java.lang.annotation.Retention;
      import java.lang.annotation.RetentionPolicy;
      @Retention(RetentionPolicy.RUNTIME)
      public @interface ValidCustomized {
      }
      
    • 定义完成后,将它标记给 student 参数对象,关键代码行如下:
      java">public void addStudent(@ValidCustomized @RequestBody Student student)
      
  • 通过上述 2 种策略、3 种具体修正方法,我们最终让参数校验生效且符合预期,不过需要提醒你的是:当使用第 3 种修正方法时,一定要注意自定义的注解要显式标记 @Retention(RetentionPolicy.RUNTIME),否则校验仍不生效。这也是另外一个容易疏忽的地方,究其原因,不显式标记 RetentionPolicy 时,默认使用的是 RetentionPolicy.CLASS,而这种类型的注解信息虽然会被保留在字节码文件(.class)中,但在加载进 JVM 时就会丢失了。所以在运行时,依据这个注解来判断是否校验,肯定会失效。

http://www.ppmy.cn/devtools/149797.html

相关文章

Opencv图片的旋转和图片的模板匹配

图片的旋转和图片的模板匹配 目录 图片的旋转和图片的模板匹配1 图片的旋转1.1 numpy旋转1.1.1 函数1.1.2 测试 1.2 opencv旋转1.2.1 函数1.2.2 测试 2 图片的模板匹配2.1 函数2.2 实际测试 1 图片的旋转 1.1 numpy旋转 1.1.1 函数 np.rot90(kl,k1),k1逆时针旋转9…

seleniun 自动化程序,python编程 我监控 chrome debug数据后 ,怎么获取控制台的信息呢

python 好的,使用 Python 来监控 Chrome 的调试数据并获取控制台信息,可以使用 websocket-client 库来连接 Chrome 的 WebSocket 接口。以下是一个详细的示例: 1. 安装必要的库 首先,你需要安装 websocket-client 库。可以使用…

前端组件开发:组件开发 / 定义配置 / 配置驱动开发 / 爬虫配置 / 组件V2.0 / form表单 / table表单

一、最早的灵感 最早的灵感来自sprider / 网络爬虫 / 爬虫配置,在爬虫爬取网站文章时候,会输入给爬虫一个配置文件,里边的内容是一个json对象。里边包含了所有想要抓取的页面的信息。爬虫通过这个配置就可以抓取目标网站的数据。其实本文要引…

鸿蒙面试 2025-01-09

鸿蒙分布式理念?(个人认为理解就好) 鸿蒙操作系统的分布式理念主要体现在其独特的“流转”能力和相关的分布式操作上。在鸿蒙系统中,“流转”是指涉多端的分布式操作,它打破了设备之间的界限,实现了多设备…

git提交

基本流程:新建分支 → 分支上开发(写代码) → 提交 → 合并到主分支 拉取最新代码因为当前在 master 分支下,你必须拉取最新代码,保证当前代码与线上同步(最新),执行以下命令:bashgit pull orig…

通信与网络安全之网络连接

一.传输介质类型 1.基本概念 计算机总是以二进制的数字(0或1)形式工作 1)数字和模拟 模拟数据一般采用模拟信号(Analog Signal),例如用一系列连续变化的电磁波(如无线电与电视广播中的电磁波),或电压信号(如电话传…

【南京工业大学主办 | JPCS独立出版 | 高届数、会议历史好 | 投稿领域广泛】第八届智能制造与自动化国际学术会议(IMA 2025)

南京工业大学主办 | 高校、学会共同协办;杰青、优青加盟! 已签约JPCS独立出版,确定ISSN:1742-6596 高届数、会议历史好 | EI,Scopus 稳定检索有保障 投稿领域广泛,与制造工程、机械工程、控制工程、自动化系统相关的…

【面试题】技术场景 6、Java 生产环境 bug 排查

生产环境 bug 排查思路 分析日志:首先通过分析日志查看是否存在错误信息,利用之前讲过的 elk 及查看日志的命令缩小查找错误范围,方便定位问题。远程 debug 适用环境:一般公司正式生产环境不允许远程 debug,多在测试环…