diff --git "a/zh-cn/geek courses/spring/11 Spring Web Body \350\275\254\345\214\226\345\270\270\350\247\201\351\224\231\350\257\257.md" "b/zh-cn/geek courses/spring/11 Spring Web Body \350\275\254\345\214\226\345\270\270\350\247\201\351\224\231\350\257\257.md" new file mode 100644 index 00000000..04b3bbc6 --- /dev/null +++ "b/zh-cn/geek courses/spring/11 Spring Web Body \350\275\254\345\214\226\345\270\270\350\247\201\351\224\231\350\257\257.md" @@ -0,0 +1,293 @@ +你好,我是傅健。前面几节课我们学习了 Spring Web 开发中绕不开的 URL 和 Header 处理。这一节课,我们接着讲 Body 的处理。 + +实际上,在 Spring 中,对于 Body 的处理很多是借助第三方编解码器来完成的。例如常见的 JSON 解析,Spring 都是借助于 Jackson、Gson 等常见工具来完成。所以在 Body 处理中,我们遇到的很多错误都是第三方工具使用中的一些问题。 + +真正对于 Spring 而言,错误并不多,特别是 Spring Boot 的自动包装以及对常见问题的不断完善,让我们能犯的错误已经很少了。不过,毕竟不是每个项目都是直接基于 Spring Boot 的,所以还是会存在一些问题,接下来我们就一起梳理下。 + +## 案例 1:No converter found for return value of type + +在直接用 Spring MVC 而非 Spring Boot 来编写 Web 程序时,我们基本都会遇到 “No converter found for return value of type” 这种错误。实际上,我们编写的代码都非常简单,例如下面这段代码: + +```less +//定义的数据对象 @Data @NoArgsConstructor @AllArgsConstructor public class Student { private String name; private Integer age; } //定义的 API 借口 @RestController public class HelloController { @GetMapping("/hi1") public Student hi1() { return new Student("xiaoming", Integer.valueOf(12)); } } +``` + +然后,我们的 pom.xml 文件也都是最基本的必备项,关键配置如下: + +```xml + org.springframework spring-webmvc 5.2.3.RELEASE +``` + +但是当我们运行起程序,执行测试代码,就会报错如下: + +![](11%20Spring%20Web%20Body%20%E8%BD%AC%E5%8C%96%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/ee996f8cfbd743f49719a4556be98195.jpg) + +从上述代码及配置来看,并没有什么明显的错误,可为什么会报错呢?难道框架不支持? + +### 案例解析 + +要了解这个案例出现的原因,需要我们对如何处理响应有一个初步的认识。 + +当我们的请求到达 Controller 层后,我们获取到了一个对象,即案例中的 new Student(“xiaoming”, Integer.valueOf(12)),那么这个对象应该怎么返回给客户端呢? + +用 JSON 还是用 XML,还是其他类型编码?此时就需要一个决策,我们可以先找到这个决策的关键代码所在,参考方法 AbstractMessageConverterMethodProcessor#writeWithMessageConverters: + +```csharp +HttpServletRequest request = inputMessage.getServletRequest(); List acceptableTypes = getAcceptableMediaTypes(request); List producibleTypes = getProducibleMediaTypes(request, valueType, targetType); if (body != null && producibleTypes.isEmpty()) { throw new HttpMessageNotWritableException( "No converter found for return value of type: " + valueType); } List mediaTypesToUse = new ArrayList<>(); for (MediaType requestedType : acceptableTypes) { for (MediaType producibleType : producibleTypes) { if (requestedType.isCompatibleWith(producibleType)) { mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType)); } } } +``` + +实际上节课我们就贴出过相关代码并分析过,所以这里只是带着你简要分析下上述代码的基本逻辑: + +1. 查看请求的头中是否有 ACCEPT 头,如果没有则可以使用任何类型; +2. 查看当前针对返回类型(即 Student 实例)可以采用的编码类型; +3. 取上面两步获取结果的交集来决定用什么方式返回。 + +比较代码,我们可以看出,假设第2步中就没有找到合适的编码方式,则直接报案例中的错误,具体的关键代码行如下: + +```csharp +if (body != null && producibleTypes.isEmpty()) { throw new HttpMessageNotWritableException( "No converter found for return value of type: " + valueType); } +``` + +那么当前可采用的编码类型是怎么决策出来的呢?我们可以进一步查看方法 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes: + +```typescript +protected List getProducibleMediaTypes( HttpServletRequest request, Class valueClass, @Nullable Type targetType) { Set mediaTypes = (Set) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE); if (!CollectionUtils.isEmpty(mediaTypes)) { return new ArrayList<>(mediaTypes); } else if (!this.allSupportedMediaTypes.isEmpty()) { List result = new ArrayList<>(); for (HttpMessageConverter converter : this.messageConverters) { if (converter instanceof GenericHttpMessageConverter && targetType != null) { if (((GenericHttpMessageConverter) converter).canWrite(targetType, valueClass, null)) { result.addAll(converter.getSupportedMediaTypes()); } } else if (converter.canWrite(valueClass, null)) { result.addAll(converter.getSupportedMediaTypes()); } } return result; } else { return Collections.singletonList(MediaType.ALL); } } +``` + +假设当前没有显式指定返回类型(例如给 GetMapping 指定 produces 属性),那么则会遍历所有已经注册的 HttpMessageConverter 查看是否支持当前类型,从而最终返回所有支持的类型。那么这些 MessageConverter 是怎么注册过来的? + +在 Spring MVC(非 Spring Boot)启动后,我们都会构建 RequestMappingHandlerAdapter 类型的 Bean 来负责路由和处理请求。 + +具体而言,当我们使用 [mvc:annotation-driven/](mvc:annotation-driven/) 时,我们会通过 AnnotationDrivenBeanDefinitionParser 来构建这个 Bean。而在它的构建过程中,会决策出以后要使用哪些 HttpMessageConverter,相关代码参考 AnnotationDrivenBeanDefinitionParser#getMessageConverters: + +```csharp +messageConverters.add(createConverterDefinition(ByteArrayHttpMessageConverter.class, source)); RootBeanDefinition stringConverterDef = createConverterDefinition(StringHttpMessageConverter.class, source); stringConverterDef.getPropertyValues().add("writeAcceptCharset", false); messageConverters.add(stringConverterDef); messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source)); //省略其他非关键代码 if (jackson2Present) { Class type = MappingJackson2HttpMessageConverter.class; RootBeanDefinition jacksonConverterDef = createConverterDefinition(type, source); GenericBeanDefinition jacksonFactoryDef = createObjectMapperFactoryDefinition(source); jacksonConverterDef.getConstructorArgumentValues().addIndexedArgumentValue(0, jacksonFactoryDef); messageConverters.add(jacksonConverterDef); } else if (gsonPresent) { messageConverters.add(createConverterDefinition(GsonHttpMessageConverter.class, source)); } //省略其他非关键代码 +``` + +这里我们会默认使用一些编解码器,例如 StringHttpMessageConverter,但是像 JSON、XML 等类型,若要加载编解码,则需要 jackson2Present、gsonPresent 等变量为 true。 + +这里我们可以选取 gsonPresent 看下何时为 true,参考下面的关键代码行: + +> gsonPresent = ClassUtils.isPresent(“com.google.gson.Gson”, classLoader); + +假设我们依赖了 Gson 包,我们就可以添加上 GsonHttpMessageConverter 这种转化器。但是可惜的是,我们的案例并没有依赖上任何 JSON 的库,所以最终在候选的转换器列表里,并不存在 JSON 相关的转化器。最终候选列表示例如下: + +![](11%20Spring%20Web%20Body%20%E8%BD%AC%E5%8C%96%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/9f0953d5943545d6a111cfcd38e952aa.jpg) + +由此可见,并没有任何 JSON 相关的编解码器。而针对 Student 类型的返回对象,上面的这些编解码器又不符合要求,所以最终走入了下面的代码行: + +```csharp +if (body != null && producibleTypes.isEmpty()) { throw new HttpMessageNotWritableException( "No converter found for return value of type: " + valueType); } +``` + +抛出了 “No converter found for return value of type” 这种错误,结果符合案例中的实际测试情况。 + +### 问题修正 + +针对这个案例,有了源码的剖析,可以看出,**不是每种类型的编码器都会与生俱来,而是根据当前项目的依赖情况决定是否支持。**要解析 JSON,我们就要依赖相关的包,所以这里我们可以以 Gson 为例修正下这个问题: + +```xml + com.google.code.gson gson 2.8.6 +``` + +我们添加了 Gson 的依赖到 pom.xml。重新运行程序和测试案例,你会发现不再报错了。 + +另外,这里我们还可以查看下 GsonHttpMessageConverter 这种编码器是如何支持上 Student 这个对象的解析的。 + +通过这个案例,我们可以知道,Spring 给我们提供了很多好用的功能,但是这些功能交织到一起后,我们就很可能入坑,只有深入了解它的运行方式,才能迅速定位问题并解决问题。 + +## 案例 2:变动地返回 Body + +案例1让我们解决了解析问题,那随着不断实践,我们可能还会发现在代码并未改动的情况下,返回结果不再和之前相同了。例如我们看下这段代码: + +```less +@RestController public class HelloController { @PostMapping("/hi2") public Student hi2(@RequestBody Student student) { return student; } } +``` + +上述代码接受了一个 Student 对象,然后原样返回。我们使用下面的测试请求进行测试: + +> POST [http://localhost:8080/springmvc3\_war/app/hi2](http://localhost:8080/springmvc3_war/app/hi2)\- Content-Type: application/json- {- “name”: “xiaoming”- } + +经过测试,我们会得到以下结果: + +> {- “name”: “xiaoming”- } + +但是随着项目的推进,在代码并未改变时,我们可能会返回以下结果: + +> \- {- “name”: “xiaoming”,- “age”: null- } + +即当 age 取不到值,开始并没有序列化它作为响应 Body 的一部分,后来又序列化成 null 作为 Body 返回了。 + +在什么情况下会如此?如何规避这个问题,保证我们的返回始终如一。 + +### 案例解析 + +如果我们发现上述问题,那么很有可能是这样一种情况造成的。即在后续的代码开发中,我们直接依赖或者间接依赖了新的 JSON 解析器,例如下面这种方式就依赖了Jackson: + +```xml + com.fasterxml.jackson.core jackson-databind 2.9.6 +``` + +当存在多个 Jackson 解析器时,我们的 Spring MVC 会使用哪一种呢?这个决定可以参考 + +```bash +if (jackson2Present) { Class type = MappingJackson2HttpMessageConverter.class; RootBeanDefinition jacksonConverterDef = createConverterDefinition(type, source); GenericBeanDefinition jacksonFactoryDef = createObjectMapperFactoryDefinition(source); jacksonConverterDef.getConstructorArgumentValues().addIndexedArgumentValue(0, jacksonFactoryDef); messageConverters.add(jacksonConverterDef); } else if (gsonPresent) { messageConverters.add(createConverterDefinition(GsonHttpMessageConverter.class, source)); } +``` + +从上述代码可以看出,Jackson 是优先于 Gson 的。所以我们的程序不知不觉已经从 Gson 编解码切换成了 Jackson。所以此时,**行为就不见得和之前完全一致了**。 + +针对本案例中序列化值为 null 的字段的行为而言,我们可以分别看下它们的行为是否一致。 + +**1\. 对于 Gson 而言:** + +GsonHttpMessageConverter 默认使用new Gson()来构建 Gson,它的构造器中指明了相关配置: + +```scss +public Gson() { this(Excluder.DEFAULT, FieldNamingPolicy.IDENTITY, Collections.>emptyMap(), DEFAULT_SERIALIZE_NULLS, DEFAULT_COMPLEX_MAP_KEYS, DEFAULT_JSON_NON_EXECUTABLE, DEFAULT_ESCAPE_HTML, DEFAULT_PRETTY_PRINT, DEFAULT_LENIENT, DEFAULT_SPECIALIZE_FLOAT_VALUES, LongSerializationPolicy.DEFAULT, null, DateFormat.DEFAULT, DateFormat.DEFAULT, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); } +``` + +从DEFAULT\_SERIALIZE\_NULLS可以看出,它是默认不序列化 null 的。 + +**2\. 对于 Jackson 而言:** + +MappingJackson2HttpMessageConverter 使用”Jackson2ObjectMapperBuilder.json().build()“来构建 ObjectMapper,它默认只显式指定了下面两个配置: + +> MapperFeature.DEFAULT\_VIEW\_INCLUSION- DeserializationFeature.FAIL\_ON\_UNKNOWN\_PROPERTIES + +Jackson 默认对于 null 的处理是做序列化的,所以本案例中 age 为 null 时,仍然被序列化了。 + +通过上面两种 JSON 序列化的分析可以看出,**返回的内容在依赖项改变的情况下确实可能发生变化。** + +### 问题修正 + +那么针对这个问题,如何修正呢?即保持在 Jackson 依赖项添加的情况下,让它和 Gson 的序列化行为一致吗?这里可以按照以下方式进行修改: + +```less +@Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class Student { private String name; //或直接加在 age 上:@JsonInclude(JsonInclude.Include.NON_NULL) private Integer age; } +``` + +我们可以直接使用 @JsonInclude 这个注解,让 Jackson 和 Gson 的默认行为对于 null 的处理变成一致。 + +上述修改方案虽然看起来简单,但是假设有很多对象如此,万一遗漏了怎么办呢?所以可以从全局角度来修改,修改的关键代码如下: + +> //ObjectMapper mapper = new ObjectMapper();- mapper.setSerializationInclusion(Include.NON\_NULL); + +但是如何修改 ObjectMapper 呢?这个对象是由 MappingJackson2HttpMessageConverter 构建的,看似无法插足去修改。实际上,我们在非 Spring Boot 程序中,可以按照下面这种方式来修改: + +```swift +@RestController public class HelloController { public HelloController(RequestMappingHandlerAdapter requestMappingHandlerAdapter){ List> messageConverters = requestMappingHandlerAdapter.getMessageConverters(); for (HttpMessageConverter messageConverter : messageConverters) { if(messageConverter instanceof MappingJackson2HttpMessageConverter ){ (((MappingJackson2HttpMessageConverter)messageConverter).getObjectMapper()).setSerializationInclusion(JsonInclude.Include.NON_NULL); } } } //省略其他非关键代码 } +``` + +我们用自动注入的方式获取到 RequestMappingHandlerAdapter,然后找到 Jackson 解析器,进行配置即可。 + +通过上述两种修改方案,我们就能做到忽略 null 的 age 字段了。 + +## 案例 3:Required request body is missing + +通过案例 1,我们已经能够解析 Body 了,但是有时候,我们会有一些很好的想法。例如为了查询问题方便,在请求过来时,自定义一个 Filter 来统一输出具体的请求内容,关键代码如下: + +```java +public class ReadBodyFilter implements Filter { //省略其他非关键代码 @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String requestBody = IOUtils.toString(request.getInputStream(), "utf-8"); System.out.println("print request body in filter:" + requestBody); chain.doFilter(request, response); } } +``` + +然后,我们可以把这个 Filter 添加到 web.xml 并配置如下: + +```xml + myFilter com.puzzles.ReadBodyFilter myFilter /app/* +``` + +再测试下 Controller 层中定义的接口: + +```less +@PostMapping("/hi3") public Student hi3(@RequestBody Student student) { return student; } +``` + +运行测试,我们会发现下面的日志: + +> print request body in filter:{- “name”: “xiaoming”,- “age”: 10- }- 25-Mar-2021 11:04:44.906 璀﹀憡 \[http-nio-8080-exec-5\] org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver.logException Resolved \[org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public com.puzzles.Student com.puzzles.HelloController.hi3(com.puzzles.Student)\] + +可以看到,请求的 Body 确实在请求中输出了,但是后续的操作直接报错了,错误提示:Required request body is missing。 + +### 案例解析 + +要了解这个错误的根本原因,你得知道这个错误抛出的源头。查阅请求 Body 转化的相关代码,有这样一段关键逻辑(参考 RequestResponseBodyMethodProcessor#readWithMessageConverters): + +```java +protected Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter, Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException { HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class); ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(servletRequest); //读取 Body 并进行转化 Object arg = readWithMessageConverters(inputMessage, parameter, paramType); if (arg == null && checkRequired(parameter)) { throw new HttpMessageNotReadableException("Required request body is missing: " + parameter.getExecutable().toGenericString(), inputMessage); } return arg; } protected boolean checkRequired(MethodParameter parameter) { RequestBody requestBody = parameter.getParameterAnnotation(RequestBody.class); return (requestBody != null && requestBody.required() && !parameter.isOptional()); } +``` + +当使用了 @RequestBody 且是必须时,如果解析出的 Body 为 null,则报错提示 Required request body is missing。 + +所以我们要继续追踪代码,来查询什么情况下会返回 body 为 null。关键代码参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters: + +```typescript +protected Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType){ //省略非关键代码 Object body = NO_VALUE; EmptyBodyCheckingHttpInputMessage message; try { message = new EmptyBodyCheckingHttpInputMessage(inputMessage); for (HttpMessageConverter converter : this.messageConverters) { Class> converterType = (Class>) converter.getClass(); GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter) converter : null); if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) : (targetClass != null && converter.canRead(targetClass, contentType))) { if (message.hasBody()) { //省略非关键代码:读取并转化 body else { //处理没有 body 情况,默认返回 null body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType); } break; } } } catch (IOException ex) { throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage); } //省略非关键代码 return body; } +``` + +当 message 没有 body 时( message.hasBody()为 false ),则将 body 认为是 null。继续查看 message 本身的定义,它是一种包装了请求 Header 和 Body 流的 EmptyBodyCheckingHttpInputMessage 类型。其代码实现如下: + +```java +public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException { this.headers = inputMessage.getHeaders(); InputStream inputStream = inputMessage.getBody(); if (inputStream.markSupported()) { //省略其他非关键代码 } else { PushbackInputStream pushbackInputStream = new PushbackInputStream(inputStream); int b = pushbackInputStream.read(); if (b == -1) { this.body = null; } else { this.body = pushbackInputStream; pushbackInputStream.unread(b); } } } public InputStream getBody() { return (this.body != null ? this.body : StreamUtils.emptyInput()); } +``` + +Body 为空的判断是由 pushbackInputStream.read() 其值为 -1 来判断出的,即没有数据可以读取。 + +看到这里,你可能会有疑问:假设有Body,read()的执行不就把数据读取走了一点么?确实如此,所以这里我使用了 pushbackInputStream.unread(b) 调用来把读取出来的数据归还回去,这样就完成了是否有 Body 的判断,又保证了 Body 的完整性。 + +分析到这里,再结合前面的案例,你应该能想到造成 Body 缺失的原因了吧? + +1. 本身就没有 Body; +2. 有Body,但是 Body 本身代表的流已经被前面读取过了。 + +很明显,我们的案例属于第2种情况,即在过滤器中,我们就已经将 Body 读取完了,关键代码如下: + +> //request 是 ServletRequest- String requestBody = IOUtils.toString(request.getInputStream(), “utf-8”); + +在这种情况下,作为一个普通的流,已经没有数据可以供给后面的转化器来读取了。 + +### 问题修正 + +所以我们可以直接在过滤器中去掉 Body 读取的代码,这样后续操作就又能读到数据了。但是这样又不满足我们的需求,如果我们坚持如此怎么办呢?这里我先直接给出答案,即定义一个 RequestBodyAdviceAdapter 的 Bean: + +```typescript +@ControllerAdvice public class PrintRequestBodyAdviceAdapter extends RequestBodyAdviceAdapter { @Override public boolean supports(MethodParameter methodParameter, Type type, Class> aClass) { return true; } @Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage,MethodParameter parameter, Type targetType, Class> converterType) { System.out.println("print request body in advice:" + body); return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType); } } +``` + +我们可以看到方法 afterBodyRead 的命名,很明显,这里的 Body 已经是从数据流中转化过的。 + +那么它是如何工作起来的呢?我们可以查看下面的代码(参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters): + +```typescript +protected Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType){ //省略其他非关键代码 if (message.hasBody()) { HttpInputMessage msgToUse = getAdvice().beforeBodyRead(message, parameter, targetType, converterType); body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) : ((HttpMessageConverter)converter).read(targetClass, msgToUse)); body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType); //省略其他非关键代码 } //省略其他非关键代码 return body; } +``` + +当一个 Body 被解析出来后,会调用 getAdvice() 来获取 RequestResponseBodyAdviceChain;然后在这个 Chain 中,寻找合适的 Advice 并执行。 + +正好我们前面定义了 PrintRequestBodyAdviceAdapter,所以它的相关方法就被执行了。从执行时机来看,此时 Body 已经解析完毕了,也就是说,传递给 PrintRequestBodyAdviceAdapter 的 Body 对象已经是一个解析过的对象,而不再是一个流了。 + +通过上面的 Advice 方案,我们满足了类似的需求,又保证了程序的正确执行。至于其他的一些方案,你可以来思考一下。 + +## 重点回顾 + +通过这节课的学习,相信你对 Spring Web 中关于 Body 解析的常见错误已经有所了解了,这里我们再次回顾下关键知识点: + +1. 不同的 Body 需要不同的编解码器,而使用哪一种是协商出来的,协商过程大体如下: + +- 查看请求头中是否有 ACCEPT 头,如果没有则可以使用任何类型; +- 查看当前针对返回类型(即 Student 实例)可以采用的编码类型; +- 取上面两步获取的结果的交集来决定用什么方式返回。 + +1. 在非 Spring Boot 程序中,JSON 等编解码器不见得是内置好的,需要添加相关的 JAR 才能自动依赖上,而自动依赖的实现是通过检查 Class 是否存在来实现的:当依赖上相关的 JAR 后,关键的 Class 就存在了,响应的编解码器功能也就提供上了。 + +2. 不同的编解码器的实现(例如 JSON 工具 Jaskson 和 Gson)可能有一些细节上的不同,所以你一定要注意当依赖一个新的 JAR 时,是否会引起默认编解码器的改变,从而影响到一些局部行为的改变。 + +3. 在尝试读取 HTTP Body 时,你要注意到 Body 本身是一个流对象,不能被多次读取。 + + +以上即为这节课的主要内容,希望能对你有所帮助。 + +## 思考题 + +通过案例 1 的学习,我们知道直接基于 Spring MVC 而非 Spring Boot 时,是需要我们手工添加 JSON 依赖,才能解析出 JSON 的请求或者编码 JSON 响应,那么为什么基于 Spring Boot 就不需要这样做了呢? + +期待你的思考,我们留言区见! \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/12 Spring Web \345\217\202\346\225\260\351\252\214\350\257\201\345\270\270\350\247\201\351\224\231\350\257\257.md" "b/zh-cn/geek courses/spring/12 Spring Web \345\217\202\346\225\260\351\252\214\350\257\201\345\270\270\350\247\201\351\224\231\350\257\257.md" new file mode 100644 index 00000000..bbc0f4b1 --- /dev/null +++ "b/zh-cn/geek courses/spring/12 Spring Web \345\217\202\346\225\260\351\252\214\350\257\201\345\270\270\350\247\201\351\224\231\350\257\257.md" @@ -0,0 +1,242 @@ +你好,我是傅健,这节课我们来聊聊 Spring Web 开发中的参数检验(Validation)。 + +参数检验是我们在Web编程时经常使用的技术之一,它帮助我们完成请求的合法性校验,可以有效拦截无效请求,从而达到节省系统资源、保护系统的目的。 + +相比较其他 Spring 技术,Spring提供的参数检验功能具有独立性强、使用难度不高的特点。但是在实践中,我们仍然会犯一些常见的错误,这些错误虽然不会导致致命的后果,但是会影响我们的使用体验,例如非法操作要在业务处理时才被拒绝且返回的响应码不够清晰友好。而且这些错误不经测试很难发现,接下来我们就具体分析下这些常见错误案例及背后的原理。 + +## 案例1:对象参数校验失效 + +在构建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 去使用它,使用方法如下: + +```kotlin +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): + +```bash +POST http://localhost:8080/students Content-Type: application/json { "name": "this_is_my_name_which_is_too_long", "age": 10 } +``` + +很明显,发送这样的请求(name 超长)是期待 Spring Validation 能拦截它的,我们的预期响应如下(省略部分响应字段): + +```bash +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技术,我们可以参考下面的时序图了解它的核心执行步骤: + +![](12%20Spring%20Web%20%E5%8F%82%E6%95%B0%E9%AA%8C%E8%AF%81%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/936435d2eaba4a30a37276cce2ca80d8.jpg) + +如上图所示,当一个请求来临时,都会进入 DispatcherServlet,执行其 doDispatch(),此方法会根据 Path、Method 等关键信息定位到负责处理的 Controller 层方法(即 addStudent 方法),然后通过反射去执行这个方法,具体反射执行过程参考下面的代码(InvocableHandlerMethod#invokeForRequest): + +```typescript +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 这个方法参数的实例。 + +> public void addStudent(@RequestBody Student student) + +那么如何构建出这个方法参数实例?Spring 内置了相当多的 HandlerMethodArgumentResolver,参考下图: + +![](12%20Spring%20Web%20%E5%8F%82%E6%95%B0%E9%AA%8C%E8%AF%81%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/1e1b6c3f990d4554ad99711af7ca1ed4.jpg) + +当试图构建出一个方法参数时,会遍历所有支持的解析器(Resolver)以找出适合的解析器,查找代码参考HandlerMethodArgumentResolverComposite#getArgumentResolver: + +```kotlin +@Nullable private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); if (result == null) { //轮询所有的HandlerMethodArgumentResolver for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) { //判断是否匹配当前HandlerMethodArgumentResolver if (resolver.supportsParameter(parameter)) { result = resolver; this.argumentResolverCache.put(parameter, result); break; } } } return result; } +``` + +对于 student 参数而言,它被标记为@RequestBody,当遍历到 RequestResponseBodyMethodProcessor 时就会匹配上。匹配代码参考其 RequestResponseBodyMethodProcessor 的supportsParameter 方法: + +```typescript +@Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(RequestBody.class); } +``` + +找到 Resolver 后,就会执行 HandlerMethodArgumentResolver#resolveArgument 方法。它首先会根据当前的请求(NativeWebRequest)组装出 Student 对象并对这个对象进行必要的校验,校验的执行参考AbstractMessageConverterMethodArgumentResolver#validateIfApplicable: + +```typescript +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)方法),必须匹配下面两个条件的其中之一: + +1. 标记了 org.springframework.validation.annotation.Validated 注解; +2. 标记了其他类型的注解,且注解名称以Valid关键字开头。 + +因此,结合案例程序,我们知道:student 方法参数并不符合这两个条件,所以即使它的内部成员添加了校验(即@Size(max = 10)),也不能生效。 + +### 问题修正 + +针对这个案例,有了源码的剖析,我们就可以很快地找到解决方案。即对于 RequestBody 接受的对象参数而言,要启动 Validation,必须将对象参数标记上 @Validated 或者其他以@Valid关键字开头的注解,因此,我们可以采用对应的策略去修正问题。 + +1. 标记 @Validated + +修正后关键代码行如下: + +> public void addStudent(**@Validated** @RequestBody Student student) + +1. 标记@Valid关键字开头的注解 + +这里我们可以直接使用熟识的 javax.validation.Valid 注解,它就是一种以@Valid关键字开头的注解,修正后关键代码行如下: + +> 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 参数对象,关键代码行如下: + +> public void addStudent(**@**ValidCustomized @RequestBody Student student) + +通过上述2种策略、3种具体修正方法,我们最终让参数校验生效且符合预期,不过需要提醒你的是:当使用第3种修正方法时,一定要注意自定义的注解要显式标记@Retention(RetentionPolicy.RUNTIME),否则校验仍不生效。这也是另外一个容易疏忽的地方,究其原因,不显式标记RetentionPolicy 时,默认使用的是 RetentionPolicy.CLASS,而这种类型的注解信息虽然会被保留在字节码文件(.class)中,但在加载进 JVM 时就会丢失了。所以在运行时,依据这个注解来判断是否校验,肯定会失效。 + +## 案例2:嵌套校验失效 + +前面这个案例虽然比较经典,但是,它只是初学者容易犯的错误。实际上,关于 Validation 最容易忽略的是对嵌套对象的校验,我们沿用上面的案例举这样一个例子。 + +学生可能还需要一个联系电话信息,所以我们可以定义一个 Phone 对象,然后关联上学生对象,代码如下: + +```java +public class Student { @Size(max = 10) private String name; private short age; private Phone phone; } @Data class Phone { @Size(max = 10) private String number; } +``` + +这里我们也给 Phone 对象做了合法性要求(@Size(max = 10)),当我们使用下面的请求(请求 body 携带一个联系电话信息超过 10 位),测试校验会发现这个约束并不生效。 + +```bash +POST http://localhost:8080/students Content-Type: application/json { "name": "xiaoming", "age": 10, "phone": {"number":"12306123061230612306"} } +``` + +为什么会不生效? + +### 案例解析 + +在解析案例 1 时,我们提及只要给对象参数 student 加上@Valid(或@Validated 等注解)就可以开启这个对象的校验。但实际上,关于 student 本身的 Phone 类型成员是否校验是在校验过程中(即案例1中的代码行binder.validate(validationHints))决定的。 + +在校验执行时,首先会根据 Student 的类型定义找出所有的校验点,然后对 Student 对象实例执行校验,这个逻辑过程可以参考代码 ValidatorImpl#validate: + +```swift +@Override public final Set> validate(T object, Class... groups) { //省略部分非关键代码 Class rootBeanClass = (Class) object.getClass(); //获取校验对象类型的“信息”(包含“约束”) BeanMetaData rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass ); if ( !rootBeanMetaData.hasConstraints() ) { return Collections.emptySet(); } //省略部分非关键代码 //执行校验 return validateInContext( validationContext, valueContext, validationOrder ); } +``` + +这里语句”beanMetaDataManager.getBeanMetaData( rootBeanClass )“根据 Student 类型组装出 BeanMetaData,BeanMetaData 即包含了需要做的校验(即 Constraint)。 + +在组装 BeanMetaData 过程中,会根据成员字段是否标记了@Valid 来决定(记录)这个字段以后是否做级联校验,参考代码 AnnotationMetaDataProvider#getCascadingMetaData: + +```typescript +private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement, Map, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) { return CascadingMetaDataBuilder.annotatedObject( type, annotatedElement.isAnnotationPresent( Valid.class ), containerElementTypesCascadingMetaData, getGroupConversions( annotatedElement ) ); } +``` + +在上述代码中”annotatedElement.isAnnotationPresent( Valid.class )“决定了 CascadingMetaDataBuilder#cascading 是否为 true。如果是,则在后续做具体校验时,做级联校验,而级联校验的过程与宿主对象(即Student)的校验过程大体相同,即先根据对象类型获取定义再来做校验。 + +在当前案例代码中,phone字段并没有被@Valid标记,所以关于这个字段信息的 cascading 属性肯定是false,因此在校验Student时并不会级联校验它。 + +### 问题修正 + +从源码级别了解了嵌套 Validation 失败的原因后,我们会发现,要让嵌套校验生效,解决的方法只有一种,就是加上@Valid,修正代码如下: + +> @Valid- private Phone phone; + +当修正完问题后,我们会发现校验生效了。而如果此时去调试修正后的案例代码,会看到 phone 字段 MetaData 信息中的 cascading 确实为 true 了,参考下图: + +![](12%20Spring%20Web%20%E5%8F%82%E6%95%B0%E9%AA%8C%E8%AF%81%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/61adff03683345cca71f42e95e6318f6.jpg) + +另外,假设我们不去解读源码,我们很可能会按照案例 1 所述的其他修正方法去修正这个问题。例如,使用 @Validated 来修正这个问题,但是此时你会发现,不考虑源码是否支持,代码本身也编译不过,这主要在于 @Validated 的定义是不允许修饰一个 Field 的: + +```less +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Validated { +``` + +通过上述方法修正问题,最终我们让嵌套验证生效了。但是你可能还是会觉得这个错误看起来不容易犯,那么可以试想一下,我们的案例仅仅是嵌套一层,而产品代码往往都是嵌套 n 层,此时我们是否能保证每一级都不会疏忽漏加@Valid呢?所以这仍然是一个典型的错误,需要你格外注意。 + +## 案例3:误解校验执行 + +通过前面两个案例的填坑,我们一般都能让参数校验生效起来,但是校验本身有时候是一个无止境的完善过程,校验本身已经生效,但是否完美匹配我们所有苛刻的要求是另外一个容易疏忽的地方。例如,我们可能在实践中误解一些校验的使用。这里我们可以继续沿用前面的案例,变形一下。 + +之前我们定义的学生对象的姓名要求是小于 10 字节的(即@Size(max = 10))。此时我们可能想完善校验,例如,我们希望姓名不能是空,此时你可能很容易想到去修改关键行代码如下: + +```python +@Size(min = 1, max = 10) private String name; +``` + +然后,我们以下面的 JSON Body 做测试: + +```json +{ "name": "", "age": 10, "phone": {"number":"12306"} } +``` + +测试结果符合我们的预期,但是假设更进一步,用下面的 JSON Body(去除 name 字段)做测试呢? + +```json +{ "age": 10, "phone": {"number":"12306"} } +``` + +我们会发现校验失败了。这结果难免让我们有一些惊讶,也倍感困惑:@Size(min = 1, max = 10) 都已经要求最小字节为 1 了,难道还只能约束空字符串(即“”),不能约束 null? + +### 案例解析 + +如果我们稍微留心点的话,就会发现其实 @Size 的 Javadoc 已经明确了这种情况,参考下图: + +![](12%20Spring%20Web%20%E5%8F%82%E6%95%B0%E9%AA%8C%E8%AF%81%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/7c04e256f690499b9dd2a30150988dac.jpg) + +如图所示,”null elements are considered valid” 很好地解释了约束不住null的原因。当然纸上得来终觉浅,我们还需要从源码级别解读下@Size 的校验过程。 + +这里我们找到了完成@Size 约束的执行方法,参考 SizeValidatorForCharSequence#isValid 方法: + +```java +public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) { if ( charSequence == null ) { return true; } int length = charSequence.length(); return length >= min && length <= max; } +``` + +如代码所示,当字符串为 null 时,直接通过了校验,而不会做任何进一步的约束检查。 + +### 问题修正 + +关于这个问题的修正,其实很简单,我们可以使用其他的注解(@NotNull 或@NotEmpty)来加强约束,修正代码如下: + +```less +@NotEmpty @Size(min = 1, max = 10) private String name; +``` + +完成代码修改后,重新测试,你就会发现约束已经完全满足我们的需求了。 + +## 重点回顾 + +看完上面的一些案例,我们会发现,这些错误的直接结果都是校验完全失败或者部分失败,并不会造成严重的后果,但是就像本讲开头所讲的那样,这些错误会影响我们的使用体验,所以我们还是需要去规避这些错误,把校验做强最好! + +另外,关于@Valid 和@Validation 是我们经常犯迷糊的地方,不知道到底有什么区别。同时我们也经常产生一些困惑,例如能用其中一种时,能不能用另外一种呢? + +通过解析,我们会发现,在很多场景下,我们不一定要寄希望于搜索引擎去区别,只需要稍微研读下代码,反而更容易理解。例如,对于案例 1,研读完代码后,我们发现它们不仅可以互换,而且完全可以自定义一个以@Valid开头的注解来使用;而对于案例 2,只能用@Valid 去开启级联校验。 + +## 思考题 + +在上面的学籍管理系统中,我们还存在一个接口,负责根据学生的学号删除他的信息,代码如下: + +```less +@RequestMapping(path = "students/{id}", method = RequestMethod.DELETE) public void deleteStudent(@PathVariable("id") @Range(min = 1,max = 10000) String id){ log.info("delete student: {}",id); //省略业务代码 }; +``` + +这个学生的编号是从请求的Path中获取的,而且它做了范围约束,必须在1到10000之间。那么你能找出负责解出 ID 的解析器(HandlerMethodArgumentResolver)是哪一种吗?校验又是如何触发的? + +期待你的思考,我们留言区见! \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/13 Spring Web \350\277\207\346\273\244\345\231\250\344\275\277\347\224\250\345\270\270\350\247\201\351\224\231\350\257\257\357\274\210\344\270\212\357\274\211.md" "b/zh-cn/geek courses/spring/13 Spring Web \350\277\207\346\273\244\345\231\250\344\275\277\347\224\250\345\270\270\350\247\201\351\224\231\350\257\257\357\274\210\344\270\212\357\274\211.md" new file mode 100644 index 00000000..72a3dea7 --- /dev/null +++ "b/zh-cn/geek courses/spring/13 Spring Web \350\277\207\346\273\244\345\231\250\344\275\277\347\224\250\345\270\270\350\247\201\351\224\231\350\257\257\357\274\210\344\270\212\357\274\211.md" @@ -0,0 +1,282 @@ +你好,我是傅健。 + +我们都知道,过滤器是 Servlet 的重要标准之一,其在请求和响应的统一处理、访问日志记录、请求权限审核等方面都有着不可替代的作用。在 Spring 编程中,我们主要就是配合使用 @ServletComponentScan 和 @WebFilter 这两个注解来构建过滤器。 + +说起来比较简单,好像只是标记下这两个注解就一劳永逸了。但是我们还是会遇到各式各样的问题,例如工作不起来、顺序不对、执行多次等等都是常见的问题。这些问题的出现大多都是使用简单致使我们掉以轻心,只要你加强意识,大概率就可以规避了。 + +那么接下来我们就来学习两个典型的案例,并通过分析,带你进一步理解过滤器执行的流程和原理。 + +## 案例 1:@WebFilter 过滤器无法被自动注入 + +假设我们要基于 Spring Boot 去开发一个学籍管理系统。为了统计接口耗时,可以实现一个过滤器如下: + +```java +@WebFilter @Slf4j public class TimeCostFilter implements Filter { public TimeCostFilter(){ System.out.println("construct"); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { log.info("开始计算接口耗时"); long start = System.currentTimeMillis(); chain.doFilter(request, response); long end = System.currentTimeMillis(); long time = end - start; System.out.println("执行时间(ms):" + time); } } +``` + +这个过滤器标记了@WebFilter。所以在启动程序中,我们需要加上扫描注解(即@ServletComponentScan)让其生效,启动程序如下: + +```less +@SpringBootApplication @ServletComponentScan @Slf4j public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); log.info("启动成功"); } } +``` + +然后,我们提供了一个 StudentController 接口来供学生注册: + +```kotlin +@Controller @Slf4j public class StudentController { @PostMapping("/regStudent/{name}") @ResponseBody public String saveUser(String name) throws Exception { System.out.println("用户注册成功"); return "success"; } } +``` + +上述程序完成后,你会发现一切按预期执行。但是假设有一天,我们可能需要把 TimeCostFilter 记录的统计数据输出到专业的度量系统(ElasticeSearch/InfluxDB 等)里面去,我们可能会添加这样一个 Service 类: + +```java +@Service public class MetricsService { @Autowired public TimeCostFilter timeCostFilter; //省略其他非关键代码 } +``` + +完成后你会发现,Spring Boot 都无法启动了: + +> \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*- APPLICATION FAILED TO START- +> +> ## \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*- +> +> ## Description:- +> +> Field timeCostFilter in com.spring.puzzle.web.filter.example1.MetricsService required a bean of type ‘com.spring.puzzle.web.filter.example1.TimeCostFilter’ that could not be found. + +为什么会出现这样的问题?既然 TimeCostFilter 生效了,看起来也像一个普通的 Bean,为什么不能被自动注入? + +### 案例解析 + +这次我们换个方式,我先告诉你结论,你可以暂停几分钟想想关键点。 + +本质上,过滤器被 @WebFilter 修饰后,TimeCostFilter 只会被包装为 FilterRegistrationBean,而 TimeCostFilter 自身,只会作为一个 InnerBean 被实例化,这意味着 **TimeCostFilter 实例并不会作为 Bean 注册到 Spring 容器**。 + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/f812277c45e643979d8f42de4f68791a.jpg) + +所以当我们想自动注入 TimeCostFilter 时,就会失败了。知道这个结论后,我们可以带着两个问题去理清一些关键的逻辑: + +1. FilterRegistrationBean 是什么?它是如何被定义的? +2. TimeCostFilter 是怎么实例化,并和 FilterRegistrationBean 关联起来的? + +我们先来看第一个问题:FilterRegistrationBean 是什么?它是如何定义的? + +实际上,WebFilter 的全名是 javax.servlet.annotation.WebFilter,很明显,它并不属于 Spring,而是 Servlet 的规范。当 Spring Boot 项目中使用它时,Spring Boot 使用了 org.springframework.boot.web.servlet.FilterRegistrationBean 来包装 @WebFilter 标记的实例。从实现上来说,即 FilterRegistrationBean#Filter 属性就是 @WebFilter 标记的实例。这点我们可以从之前给出的截图中看出端倪。 + +另外,当我们定义一个 Filter 类时,我们可能想的是,我们会自动生成它的实例,然后以 Filter 的名称作为 Bean 的名字来指向它。但是调试下你会发现,在 Spring Boot 中,Bean 名字确实是对的,只是 Bean 实例其实是 FilterRegistrationBean。 + +那么这个 FilterRegistrationBean 最早是如何获取的呢?这还得追溯到 @WebFilter 这个注解是如何被处理的。在具体解析之前,我们先看下 @WebFilter 是如何工作起来的。使用 @WebFilter 时,Filter 被加载有两个条件: + +- 声明了 @WebFilter; +- 在能被 @ServletComponentScan 扫到的路径之下。 + +这里我们直接检索对 @WebFilter 的使用,可以发现 WebFilterHandler 类使用了它,直接在 doHandle() 中加入断点,开始调试,执行调用栈如下: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/8c61379a267547038b9644896d601764.jpg) + +从堆栈上,我们可以看出对@WebFilter 的处理是在 Spring Boot 启动时,而处理的触发点是 ServletComponentRegisteringPostProcessor 这个类。它继承了 BeanFactoryPostProcessor 接口,实现对 @WebFilter、@WebListener、@WebServlet 的扫描和处理,其中对于@WebFilter 的处理使用的就是上文中提到的 WebFilterHandler。这个逻辑可以参考下面的关键代码: + +```java +class ServletComponentRegisteringPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware { private static final List HANDLERS; static { List servletComponentHandlers = new ArrayList<>(); servletComponentHandlers.add(new WebServletHandler()); servletComponentHandlers.add(new WebFilterHandler()); servletComponentHandlers.add(new WebListenerHandler()); HANDLERS = Collections.unmodifiableList(servletComponentHandlers); } // 省略非关键代码 @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { if (isRunningInEmbeddedWebServer()) { ClassPathScanningCandidateComponentProvider componentProvider = createComponentProvider(); for (String packageToScan : this.packagesToScan) { scanPackage(componentProvider, packageToScan); } } } private void scanPackage(ClassPathScanningCandidateComponentProvider componentProvider, String packageToScan) { // 扫描注解 for (BeanDefinition candidate : componentProvider.findCandidateComponents(packageToScan)) { if (candidate instanceof AnnotatedBeanDefinition) { // 使用 WebFilterHandler 等进行处理 for (ServletComponentHandler handler : HANDLERS) { handler.handle(((AnnotatedBeanDefinition) candidate), (BeanDefinitionRegistry) this.applicationContext); } } } } +``` + +最终,WebServletHandler 通过父类 ServletComponentHandler 的模版方法模式,处理了所有被 @WebFilter 注解的类,关键代码如下: + +```typescript +public void doHandle(Map attributes, AnnotatedBeanDefinition beanDefinition, BeanDefinitionRegistry registry) { BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class); builder.addPropertyValue("asyncSupported", attributes.get("asyncSupported")); builder.addPropertyValue("dispatcherTypes", extractDispatcherTypes(attributes)); builder.addPropertyValue("filter", beanDefinition); //省略其他非关键代码 builder.addPropertyValue("urlPatterns", extractUrlPatterns(attributes)); registry.registerBeanDefinition(name, builder.getBeanDefinition()); } +``` + +从这里,我们第一次看到了 FilterRegistrationBean。通过调试上述代码的最后一行,可以看到,最终我们注册的 FilterRegistrationBean,其名字就是我们定义的 WebFilter 的名字: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/e82362b2716845ae85cb106f3d36d3a6.jpg) + +后续这个 Bean 的具体创建过程,这里不再赘述,感兴趣的话你可以继续深入研究。 + +现在,我们接着看第二个问题:TimeCostFilter 何时被实例化? + +此时,我们想要的 Bean 被“张冠李戴”成 FilterRegistrationBean,但是 TimeCostFilter 是何时实例化的呢?为什么它没有成为一个普通的 Bean? + +关于这点,我们可以在 TimeCostFilter 的构造器中加个断点,然后使用调试的方式快速定位到它的初始化时机,这里我直接给出了调试截图: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/40597bf10ce84d2bb619e2ec42d0511d.jpg) + +在上述的关键调用栈中,结合源码,你可以找出一些关键信息: + +1. Tomcat 等容器启动时,才会创建 FilterRegistrationBean; +2. FilterRegistrationBean 在被创建时(createBean)会创建 TimeCostFilter 来装配自身,TimeCostFilter 是通过 ResolveInnerBean 来创建的; +3. TimeCostFilter 实例最终是一种 InnerBean,我们可以通过下面的调试视图看到它的一些关键信息: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/3ff7aa99e9d34b76a113e68f87069c8f.jpg) + +通过上述分析,你可以看出**最终 TimeCostFilter 实例是一种 InnerBean**,所以自动注入不到也就非常合理了。 + +### 问题修正 + +找到了问题的根源,解决就变得简单了。 + +从上述的解析中,我们可以了解到,当使用 @WebFilter 修饰过滤器时,TimeCostFilter 类型的 Bean 并没有注册到 Spring 容器中,真正注册的是 FilterRegistrationBean。这里考虑到可能存在多个 Filter,所以我们可以这样修改下案例代码: + +```less +@Controller @Slf4j public class StudentController { @Autowired @Qualifier("com.spring.puzzle.filter.TimeCostFilter") FilterRegistrationBean timeCostFilter; } +``` + +这里的关键点在于: + +- 注入的类型是 FilterRegistrationBean 类型,而不是 TimeCostFilter 类型; +- 注入的名称是包含包名的长名称,即 com.spring.puzzle.filter.TimeCostFilter(不能用 TimeCostFilter),以便于存在多个过滤器时进行精确匹配。 + +经过上述修改后,代码成功运行无任何报错,符合我们的预期。 + +## 案例 2:Filter 中不小心多次执行 doFilter() + +在之前的案例中,我们主要都讨论了使用@ServletComponentScan + @WebFilter 构建过滤器过程中的一些常见问题。 + +而在实际生产过程中,如果我们需要构建的过滤器是针对全局路径有效,且没有任何特殊需求(主要是指对 Servlet 3.0 的一些异步特性支持),那么你完全可以直接使用 Filter 接口(或者继承 Spring 对 Filter 接口的包装类 OncePerRequestFilter),并使用@Component 将其包装为 Spring 中的普通 Bean,也是可以达到预期的需求。 + +不过不管你使用哪一种方式,你都可能会遇到一个共同的问题:**业务代码重复执行多次**。 + +考虑到上一个案例用的是@ServletComponentScan + @WebFilter,这里我们不妨再以@Component + Filter 接口的实现方式来呈现下我们的案例,也好让你对 Filter 的使用能了解到更多。 + +首先,还是需要通过 Spring Boot 创建一个 Web 项目,不过已经不需要 @ServletComponentScan: + +```typescript +@SpringBootApplication() public class LearningApplication { public static void main(String[] args) { SpringApplication.run(LearningApplication.class, args); System.out.println("启动成功"); } } +``` + +StudentController 保持功能不变,所以你可以直接参考之前的代码。另外我们定义一个 DemoFilter 用来模拟问题,这个 Filter 标记了 @Component 且实现了 Filter 接口,已经不同于我们上一个案例的方式: + +```java +@Component public class DemoFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { //模拟异常 System.out.println("Filter 处理中时发生异常"); throw new RuntimeException(); } catch (Exception e) { chain.doFilter(request, response); } chain.doFilter(request, response); } } +``` + +全部代码实现完毕,执行后结果如下: + +```css +Filter 处理中时发生异常 ......用户注册成功 ......用户注册成功 +``` + +这里我们可以看出,业务代码被执行了两次,这并不符合我们的预期。 + +我们本来的设计目标是希望 Filter 的业务执行不会影响到核心业务的执行,所以当抛出异常时,我们还是会调用chain.doFilter。不过往往有时候,我们会忘记及时返回而误入其他的chain.doFilter,最终导致我们的 Filter 执行多次。 + +而检查代码时,我们往往不能立马看出问题。所以说,这是一个典型的错误,虽然原因很简单吧。不过借着这个案例,我们可以分析下为什么会执行两次,以深入了解 Filter 的执行。 + +### 案例解析 + +在解析之前,我先给你讲下 Filter 背后的机制,即责任链模式。 + +以 Tomcat 为例,我们先来看下它的 Filter 实现中最重要的类 ApplicationFilterChain。它采用的是责任(职责)链设计模式,在形式上很像一种递归调用。 + +但区别在于递归调用是同一个对象把子任务交给同一个方法本身去完成,而**职责链则是一个对象把子任务交给其他对象的同名方法去完成**。其核心在于上下文 FilterChain 在不同对象 Filter 间的传递与状态的改变,通过这种链式串联,我们就可以对同一种对象资源实现不同业务场景的处理,达到业务解耦。整个 FilterChain 的结构就像这张图一样: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/8713ccd0e3534d9188943a389176523d.jpg) + +这里我们不妨还是带着两个问题去理解 FilterChain: + +1. FilterChain 在何处被创建,又是在何处进行初始化调用,从而激活责任链开始链式调用? +2. FilterChain 为什么能够被链式调用,其内在的调用细节是什么? + +接下来我们直接查看负责请求处理的 StandardWrapperValve#invoke(),快速解决第一个问题: + +```java +public final void invoke(Request request, Response response) throws IOException, ServletException { // 省略非关键代码 // 创建filterChain ApplicationFilterChain filterChain = ApplicationFilterFactory.createFilterChain(request, wrapper, servlet); // 省略非关键代码 try { if ((servlet != null) && (filterChain != null)) { // Swallow output if needed if (context.getSwallowOutput()) { // 省略非关键代码 //执行filterChain filterChain.doFilter(request.getRequest(), response.getResponse()); // 省略非关键代码 } // 省略非关键代码 } +``` + +通过代码可以看出,Spring 通过 ApplicationFilterFactory.createFilterChain() 创建FilterChain,然后调用其 doFilter() 执行责任链。而这些步骤的起始点正是StandardWrapperValve#invoke()。 + +接下来,我们来一起研究第二个问题,即 FilterChain 能够被链式调用的原因和内部细节。 + +首先查看 ApplicationFilterFactory.createFilterChain(),来看下FilterChain如何被创建,如下所示: + +```java +public static ApplicationFilterChain createFilterChain(ServletRequest request, Wrapper wrapper, Servlet servlet) { // 省略非关键代码 ApplicationFilterChain filterChain = null; if (request instanceof Request) { // 省略非关键代码 // 创建Chain filterChain = new ApplicationFilterChain(); // 省略非关键代码 } // 省略非关键代码 // Add the relevant path-mapped filters to this filter chain for (int i = 0; i < filterMaps.length; i++) { // 省略非关键代码 ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) context.findFilterConfig(filterMaps[i].getFilterName()); if (filterConfig == null) { continue; } // 增加filterConfig到Chain filterChain.addFilter(filterConfig); } // 省略非关键代码 return filterChain; } +``` + +它创建 FilterChain,并将所有 Filter 逐一添加到 FilterChain 中。然后我们继续查看 ApplicationFilterChain 类及其 addFilter(): + +```csharp +// 省略非关键代码 private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0]; private int pos = 0; private int n = 0; // 省略非关键代码 void addFilter(ApplicationFilterConfig filterConfig) { for(ApplicationFilterConfig filter:filters) if(filter==filterConfig) return; if (n == filters.length) { ApplicationFilterConfig[] newFilters = new ApplicationFilterConfig[n + INCREMENT]; System.arraycopy(filters, 0, newFilters, 0, n); filters = newFilters; } filters[n++] = filterConfig; } +``` + +在 ApplicationFilterChain 里,声明了3个变量,类型为 ApplicationFilterConfig 的数组 Filters、过滤器总数计数器 n,以及标识运行过程中被执行过的过滤器个数 pos。 + +每个被初始化的 Filter 都会通过 filterChain.addFilter(),加入到类型为 ApplicationFilterConfig 的类成员数组 Filters 中,并同时更新 Filter 总数计数器 n,使其等于 Filters 数组的长度。到这,**Spring 就完成了 FilterChain 的创建准备工作**。 + +接下来,我们继续看 FilterChain 的执行细节,即 ApplicationFilterChain 的 doFilter(): + +```scss +public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { if( Globals.IS_SECURITY_ENABLED ) { //省略非关键代码 internalDoFilter(request,response); //省略非关键代码 } else { internalDoFilter(request,response); } } +``` + +这里逻辑被委派到了当前类的私有方法 internalDoFilter,具体实现如下: + +```java +private void internalDoFilter(ServletRequest request, ServletResponse response){ if (pos < n) { // pos会递增 ApplicationFilterConfig filterConfig = filters[pos++]; try { Filter filter = filterConfig.getFilter(); // 省略非关键代码 // 执行filter filter.doFilter(request, response, this); // 省略非关键代码 } // 省略非关键代码 return; } // 执行真正实际业务 servlet.service(request, response); } // 省略非关键代码 } +``` + +我们可以归纳下核心知识点: + +- ApplicationFilterChain的internalDoFilter() 是过滤器逻辑的核心; +- ApplicationFilterChain的成员变量 Filters 维护了所有用户定义的过滤器; +- ApplicationFilterChain的类成员变量 n 为过滤器总数,变量 pos 是运行过程中已经执行的过滤器个数; +- internalDoFilter() 每被调用一次,pos 变量值自增 1,即从类成员变量 Filters 中取下一个 Filter; +- filter.doFilter(request, response, this) 会调用过滤器实现的 doFilter(),注意第三个参数值为 this,即为当前ApplicationFilterChain 实例 ,这意味着:用户需要在过滤器中显式调用一次javax.servlet.FilterChain#doFilter,才能完成整个链路; +- pos < n 意味着执行完所有的过滤器,才能通过servlet.service(request, response) 去执行真正的业务。 + +执行完所有的过滤器后,代码调用了 servlet.service(request, response) 方法。从下面这张调用栈的截图中,可以看到,经历了一个很长的看似循环的调用栈,我们终于从 internalDoFilter() 执行到了Controller层的saveUser()。这个过程就不再一一细讲了。 + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/7319f6f84e2440bdbb5bf4f96af07932.jpg) + +分析了这么多,最后我们再来思考一下这个问题案例。 + +DemoFilter 代码中的 doFilter() 在捕获异常的部分执行了一次,随后在 try 外面又执行了一次,因而当抛出异常的时候,doFilter() 明显会被执行两次,相对应的 servlet.service(request, response) 方法以及对应的 Controller 处理方法也被执行了两次。 + +你不妨回过头再次查看上文中的过滤器执行流程图,相信你会有更多的收获。 + +### 问题修正 + +现在就剩下解决这个问题了。其实只需要删掉重复的 filterChain.doFilter(request, response) 就可以了,于是代码就变成了这样: + +```java +@Component public class DemoFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { //模拟异常 System.out.println("Filter 处理中时发生异常"); throw new RuntimeException(); } catch (Exception e) { //去掉下面这行调用 //chain.doFilter(request, response); } chain.doFilter(request, response); } } +``` + +重新运行程序和测试,结果符合预期,业务只执行了一次。回顾这个问题,我想你应该有所警示:在使用过滤器的时候,一定要注意,**不管怎么调用,不能多次调用 FilterChain#doFilter()**。 + +## 重点回顾 + +通过这节课的学习,相信你对过滤器已经有了一个较为深入的了解,这里我们不妨再次梳理下关键知识点: + +1. @WebFilter 这种方式构建的 Filter 是无法直接根据过滤器定义类型来自动注入的,因为这种Filter本身是以内部Bean来呈现的,它最终是通过FilterRegistrationBean来呈现给Spring的。所以我们可以通过自动注入FilterRegistrationBean类型来完成装配工作,示例如下: + +```less +@Autowired @Qualifier("com.spring.puzzle.filter.TimeCostFilter") FilterRegistrationBean timeCostFilter; +``` + +1. 我们在过滤器的执行中,一定要注意避免不要多次调用doFilter(),否则可能会出现业务代码执行多次的问题。这个问题出现的根源往往在于“不小心”,但是要理解这个问题呈现的现象,就必须对过滤器的流程有所了解。可以看过滤器执行的核心流程图: + +![](13%20Spring%20Web%20%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%BF%E7%94%A8%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF%EF%BC%88%E4%B8%8A%EF%BC%89/ccc002f660ed4c06ac30cd50a347c05e.jpg) + +结合这个流程图,我们还可以进一步细化出以下关键步骤: + +- 当一个请求来临时,会执行到 StandardWrapperValve的invoke(),这个方法会创建 ApplicationFilterChain,并通过ApplicationFilterChain#doFilter() 触发过滤器执行; +- ApplicationFilterChain 的 doFilter() 会执行其私有方法 internalDoFilter; +- 在 internalDoFilter 方法中获取下一个Filter,并使用 request、response、this(当前ApplicationFilterChain 实例)作为参数来调用 doFilter(): + +> public void doFilter(ServletRequest request, ServletResponse response,- FilterChain chain) throws IOException, ServletException; + +- 在 Filter 类的 doFilter() 中,执行Filter定义的动作并继续传递,获取第三个参数 ApplicationFilterChain,并执行其 doFilter(); +- 此时会循环执行进入第 2 步、第 3 步、第 4 步,直到第3步中所有的 Filter 类都被执行完毕为止; +- 所有的Filter过滤器都被执行完毕后,会执行 servlet.service(request, response) 方法,最终调用对应的 Controller 层方法 。 + +以上即为过滤器执行的关键流程,希望你能牢牢记住。 + +## 思考题 + +在案例2中,我们提到一定要避免在过滤器中调用多次FilterChain#doFilter()。那么假设一个过滤器因为疏忽,在某种情况下,这个方法一次也没有调用,会出现什么情况呢? + +这样的过滤器可参考改造后的DemoFilter: + +```java +@Component public class DemoFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("do some logic"); } } +``` + +期待你的思考,我们留言区见! \ No newline at end of file diff --git a/zh-cn/geek courses/spring/README.md b/zh-cn/geek courses/spring/README.md index df77094c..fbbea6f3 100644 --- a/zh-cn/geek courses/spring/README.md +++ b/zh-cn/geek courses/spring/README.md @@ -20,3 +20,8 @@ [10 Spring Web Header 解析常见错误]() +[11 Spring Web Body 转化常见错误]() + +[12 Spring Web 参数验证常见错误]() + +[13 Spring Web 过滤器使用常见错误(上)]() \ No newline at end of file