This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,645 @@
<audio id="audio" title="09Spring Web URL 解析常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/60/f6d5eb2743c77f06fb192f97ec0d9460.mp3"></audio>
你好,我是傅健。
上一章节我们讲解了各式各样的错误案例,这些案例都是围绕 Spring 的核心功能展开的例如依赖注入、AOP 等诸多方面。然而,从现实情况来看,在使用上,我们更多地是使用 Spring 来构建一个 Web 服务,所以从这节课开始,我们会重点解析在 Spring Web 开发中经常遇到的一些错误,帮助你规避这些问题。
不言而喻,这里说的 Web 服务就是指使用 HTTP 协议的服务。而对于 HTTP 请求,首先要处理的就是 URL所以今天我们就先来介绍下在 URL 的处理上Spring 都有哪些经典的案例。闲话少叙,下面我们直接开始演示吧。
## 案例 1当@PathVariable 遇到 /
在解析一个 URL 时,我们经常会使用 @PathVariable 这个注解。例如我们会经常见到如下风格的代码:
```
@RestController
@Slf4j
public class HelloWorldController {
@RequestMapping(path = &quot;/hi1/{name}&quot;, method = RequestMethod.GET)
public String hello1(@PathVariable(&quot;name&quot;) String name){
return name;
};
}
```
当我们使用 [http://localhost:8080/hi1/xiaoming](http://localhost:8080/hi1/xiaoming) 访问这个服务时,会返回"xiaoming",即 Spring 会把 name 设置为 URL 中对应的值。
看起来顺风顺水,但是假设这个 name 中含有特殊字符/时(例如[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) ),会如何?如果我们不假思索,或许答案是"xiao/ming"?然而稍微敏锐点的程序员都会判定这个访问是会报错的,具体错误参考:
<img src="https://static001.geekbang.org/resource/image/92/64/92a3c8894b88eec937139f3c858bf664.png" alt="">
如图所示,当 name 中含有/,这个接口不会为 name 获取任何值而是直接报Not Found错误。当然这里的“找不到”并不是指name找不到而是指服务于这个特殊请求的接口。
实际上,这里还存在另外一种错误,即当 name 的字符串以/结尾时,/会被自动去掉。例如我们访问 [http://localhost:8080/hi1/xiaoming/](http://localhost:8080/hi1/xiaoming/)Spring 并不会报错而是返回xiaoming。
针对这两种类型的错误,应该如何理解并修正呢?
### 案例解析
实际上,这两种错误都是 URL 匹配执行方法的相关问题,所以我们有必要先了解下 URL 匹配执行方法的大致过程。参考 AbstractHandlerMethodMapping#lookupHandlerMethod
```
@Nullable
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List&lt;Match&gt; matches = new ArrayList&lt;&gt;();
//尝试按照 URL 进行精准匹配
List&lt;T&gt; directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
if (directPathMatches != null) {
//精确匹配上,存储匹配结果
addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
//没有精确匹配上,尝试根据请求来匹配
addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
}
if (!matches.isEmpty()) {
Comparator&lt;Match&gt; comparator = new MatchComparator(getMappingComparator(request));
matches.sort(comparator);
Match bestMatch = matches.get(0);
if (matches.size() &gt; 1) {
//处理多个匹配的情况
}
//省略其他非关键代码
return bestMatch.handlerMethod;
}
else {
//匹配不上,直接报错
return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
}
```
大体分为这样几个基本步骤。
**1. 根据 Path 进行精确匹配**
这个步骤执行的代码语句是"this.mappingRegistry.getMappingsByUrl(lookupPath)",实际上,它是查询 MappingRegistry#urlLookup,它的值可以用调试视图查看,如下图所示:
<img src="https://static001.geekbang.org/resource/image/d5/80/d579a4557a06ef8a0ba960ed05184b80.png" alt="">
查询 urlLookup 是一个精确匹配 Path 的过程。很明显,[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 的 lookupPath 是"/hi1/xiao/ming",并不能得到任何精确匹配。这里需要补充的是,"/hi1/{name}"这种定义本身也没有出现在 urlLookup 中。
**2. 假设 Path 没有精确匹配上,则执行模糊匹配**
在步骤 1 匹配失败时,会根据请求来尝试模糊匹配,待匹配的匹配方法可参考下图:
<img src="https://static001.geekbang.org/resource/image/1d/2b/1da52225336ec68451471ac4de36db2b.png" alt="">
显然,"/hi1/{name}"这个匹配方法已经出现在待匹配候选中了。具体匹配过程可以参考方法 RequestMappingInfo#getMatchingCondition
```
public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
if (methods == null) {
return null;
}
ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
if (params == null) {
return null;
}
//省略其他匹配条件
PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
if (patterns == null) {
return null;
}
//省略其他匹配条件
return new RequestMappingInfo(this.name, patterns,
methods, params, headers, consumes, produces, custom.getCondition());
}
```
现在我们知道**匹配会查询所有的信息**,例如 Header、Body 类型以及URL 等。如果有一项不符合条件,则不匹配。
在我们的案例中,当使用 [http://localhost:8080/hi1/xiaoming](http://localhost:8080/hi1/xiaoming) 访问时,其中 patternsCondition 是可以匹配上的。实际的匹配方法执行是通过 AntPathMatcher#match 来执行,判断的相关参数可参考以下调试视图:
<img src="https://static001.geekbang.org/resource/image/f2/c6/f224047fd2d4ee0751229415a9ac87c6.png" alt="">
但是当我们使用 [http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 来访问时AntPathMatcher 执行的结果是"/hi1/xiao/ming"匹配不上"/hi1/{name}"。
**3. 根据匹配情况返回结果**
如果找到匹配的方法,则返回方法;如果没有,则返回 null。
在本案例中,[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 因为找不到匹配方法最终报 404 错误。追根溯源就是 AntPathMatcher 匹配不了"/hi1/xiao/ming"和"/hi1/{name}"。
另外,我们再回头思考 [http://localhost:8080/hi1/xiaoming/](http://localhost:8080/hi1/xiaoming/) 为什么没有报错而是直接去掉了/。这里我直接贴出了负责执行 AntPathMatcher 匹配的 PatternsRequestCondition#getMatchingPattern 方法的部分关键代码:
```
private String getMatchingPattern(String pattern, String lookupPath) {
//省略其他非关键代码
if (this.pathMatcher.match(pattern, lookupPath)) {
return pattern;
}
//尝试加一个/来匹配
if (this.useTrailingSlashMatch) {
if (!pattern.endsWith(&quot;/&quot;) &amp;&amp; this.pathMatcher.match(pattern + &quot;/&quot;, lookupPath)) {
return pattern + &quot;/&quot;;
}
}
return null;
}
```
在这段代码中AntPathMatcher 匹配不了"/hi1/xiaoming/"和"/hi1/{name}",所以不会直接返回。进而,在 useTrailingSlashMatch 这个参数启用时(默认启用),会把 Pattern 结尾加上/再尝试匹配一次。如果能匹配上,在最终返回 Pattern 时就隐式自动加/。
很明显,我们的案例符合这种情况,等于说我们最终是用了"/hi1/{name}/"这个 Pattern而不再是"/hi1/{name}"。所以自然 URL 解析 name 结果是去掉/的。
### 问题修正
针对这个案例,有了源码的剖析,我们可能会想到可以先用"**"匹配上路径,等进入方法后再尝试去解析,这样就可以万无一失吧。具体修改代码如下:
```
@RequestMapping(path = &quot;/hi1/**&quot;, method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String requestURI = request.getRequestURI();
return requestURI.split(&quot;/hi1/&quot;)[1];
};
```
但是这种修改方法还是存在漏洞,假设我们路径的 name 中刚好又含有"/hi1/",则 split 后返回的值就并不是我们想要的。实际上,更合适的修订代码示例如下:
```
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@RequestMapping(path = &quot;/hi1/**&quot;, method = RequestMethod.GET)
public String hi1(HttpServletRequest request){
String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
//matchPattern 即为&quot;/hi1/**&quot;
String matchPattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
return antPathMatcher.extractPathWithinPattern(matchPattern, path);
};
```
经过修改,两个错误都得以解决了。当然也存在一些其他的方案,例如对传递的参数进行 URL 编码以避免出现/或者干脆直接把这个变量作为请求参数、Header 等,而不是作为 URL 的一部分。你完全可以根据具体情况来选择合适的方案。
## 案例 2错误使用@RequestParam、@PathVarible 等注解
我们常常使用@RequestParam@PathVarible 来获取请求参数request parameters以及 path 中的部分。但是在频繁使用这些参数时,不知道你有没有觉得它们的使用方式并不友好,例如我们去获取一个请求参数 name我们会定义如下
>
@RequestParam("name") String name
此时,我们会发现变量名称大概率会被定义成 RequestParam值。所以我们是不是可以用下面这种方式来定义
>
@RequestParam String name
这种方式确实是可以的,本地测试也能通过。这里我还给出了完整的代码,你可以感受下这两者的区别。
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestParam(&quot;name&quot;) String name){
return name;
};
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam String name){
return name;
};
```
很明显,对于喜欢追究极致简洁的同学来说,这个酷炫的功能是一个福音。但当我们换一个项目时,有可能上线后就失效了,然后报错 500提示匹配不上。
<img src="https://static001.geekbang.org/resource/image/f3/7f/f377e98e0293e480c4ea249596ec4d7f.png" alt="">
### 案例解析
要理解这个问题出现的原因,首先我们需要把这个问题复现出来。例如我们可以修改下 pom.xml 来关掉两个选项:
```
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-compiler-plugin&lt;/artifactId&gt;
&lt;configuration&gt;
&lt;debug&gt;false&lt;/debug&gt;
&lt;parameters&gt;false&lt;/parameters&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
```
上述配置显示关闭了 parameters 和 debug这 2 个参数的作用你可以参考下面的表格:
<img src="https://static001.geekbang.org/resource/image/c6/a0/c60cabd6a71f02db8663eae8224ddaa0.jpg" alt="">
通过上述描述,我们可以看出这 2 个参数控制了一些 debug 信息是否加进 class 文件中。我们可以开启这两个参数来编译,然后使用下面的命令来查看信息:
>
javap -verbose HelloWorldController.class
执行完命令后,我们会看到以下 class 信息:
<img src="https://static001.geekbang.org/resource/image/1e/e4/1e7fee355e63528c97bbf47e8bdaa6e4.png" alt="">
debug 参数开启的部分信息就是 LocalVaribleTable而 paramters 参数开启的信息就是 MethodParameters。观察它们的信息你会发现它们都含有参数名name。
如果你关闭这两个参数,则 name 这个名称自然就没有了。而这个方法本身在 @RequestParam 中又没有指定名称,那么 Spring 此时还能找到解析的方法么?
答案是否定的,这里我们可以顺带说下 Spring 解析请求参数名称的过程,参考代码 AbstractNamedValueMethodArgumentResolver#updateNamedValueInfo
```
private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) {
String name = info.name;
if (info.name.isEmpty()) {
name = parameter.getParameterName();
if (name == null) {
throw new IllegalArgumentException(
&quot;Name for argument type [&quot; + parameter.getNestedParameterType().getName() +
&quot;] not available, and parameter name information not found in class file either.&quot;);
}
}
String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue);
return new NamedValueInfo(name, info.required, defaultValue);
}
```
其中 NamedValueInfo 的 name 为 @RequestParam 指定的值。很明显,在本案例中,为 null。
所以这里我们就会尝试调用 parameter.getParameterName() 来获取参数名作为解析请求参数的名称。但是,很明显,关掉上面两个开关后,就不可能在 class 文件中找到参数名了,这点可以从下面的调试试图中得到验证:
<img src="https://static001.geekbang.org/resource/image/8d/37/8dc41bf12f0075573bf6b6d13b2a2537.png" alt="">
当参数名不存在,@RequestParam 也没有指明,自然就无法决定到底要用什么名称去获取请求参数,所以就会报本案例的错误。
### 问题修正
模拟出了问题是如何发生的,我们自然可以通过开启这两个参数让其工作起来。但是思考这两个参数的作用,很明显,它可以让我们的程序体积更小,所以很多项目都会青睐去关闭这两个参数。
为了以不变应万变,正确的修正方式是**必须显式在@RequestParam 中指定请求参数名**。具体修改如下:
>
@RequestParam("name") String name
通过这个案例我们可以看出很多功能貌似可以永远工作但是实际上只是在特定的条件下而已。另外这里再拓展下IDE 都喜欢开启相关 debug 参数,所以 IDE 里运行的程序不见得对产线适应,例如针对 parameters 这个参数IDEA 默认就开启了。
另外,本案例围绕的都是 @RequestParam,其实 @PathVarible 也有一样的问题。这里你要注意。
那么说到这里,我顺带提一个可能出现的小困惑:我们这里讨论的参数,和 @QueryParam@PathParam 有什么区别?实际上,后者都是 JAX-RS 自身的注解,不需要额外导包。而 @RequestParam@PathVariable 是 Spring 框架中的注解,需要额外导入依赖包。另外不同注解的参数也不完全一致。
## 案例 3未考虑参数是否可选
在上面的案例中,我们提到了 @RequestParam 的使用。而对于它的使用,我们常常会遇到另外一个问题。当需要特别多的请求参数时,我们往往会忽略其中一些参数是否可选。例如存在类似这样的代码:
```
@RequestMapping(path = &quot;/hi4&quot;, method = RequestMethod.GET)
public String hi4(@RequestParam(&quot;name&quot;) String name, @RequestParam(&quot;address&quot;) String address){
return name + &quot;:&quot; + address;
};
```
在访问 [http://localhost:8080/hi4?name=xiaoming&amp;address=beijing](http://localhost:8080/hi2?name=xiaoming&amp;address=beijing) 时并不会出问题,但是一旦用户仅仅使用 name 做请求(即 [http://localhost:8080/hi4?name=xiaoming](http://localhost:8080/hi4?name=xiaoming) )时,则会直接报错如下:
<img src="https://static001.geekbang.org/resource/image/92/09/9289ddbf7e1b39131662ab3fc1807709.png" alt="">
此时,返回错误码 400提示请求格式错误此处缺少 address 参数。
实际上,部分初学者即使面对这个错误,也会觉得惊讶,既然不存在 addressaddress 应该设置为 null而不应该是直接报错不是么接下来我们就分析下。
### 案例解析
要了解这个错误出现的根本原因,你就需要了解请求参数的发生位置。
实际上,这里我们也能按注解名(@RequestParam)来确定解析发生的位置是在 RequestParamMethodArgumentResolver 中。为什么是它?
追根溯源,针对当前案例,当根据 URL 匹配上要执行的方法是 hi4 后,要反射调用它,必须解析出方法参数 name 和 address 才可以。而它们被 @RequestParam 注解修饰,所以解析器借助 RequestParamMethodArgumentResolver 就成了很自然的事情。
接下来我们看下 RequestParamMethodArgumentResolver 对参数解析的一些关键操作,参考其父类方法 AbstractNamedValueMethodArgumentResolver#resolveArgument
```
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
MethodParameter nestedParameter = parameter.nestedIfOptional();
//省略其他非关键代码
//获取请求参数
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = resolveStringValue(namedValueInfo.defaultValue);
}
else if (namedValueInfo.required &amp;&amp; !nestedParameter.isOptional()) {
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
}
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
}
//省略后续代码:类型转化等工作
return arg;
}
```
如代码所示,当缺少请求参数的时候,通常我们会按照以下几个步骤进行处理。
**1. 查看 namedValueInfo 的默认值,如果存在则使用它**
这个变量实际是通过下面的方法来获取的,参考 RequestParamMethodArgumentResolver#createNamedValueInfo
```
@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
RequestParam ann = parameter.getParameterAnnotation(RequestParam.class);
return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo());
}
```
实际上就是 @RequestParam 的相关信息,我们调试下,就可以验证这个结论,具体如下图所示:
<img src="https://static001.geekbang.org/resource/image/f5/5e/f56f4498bcd078c20e4320yy2353af5e.png" alt="">
**2. 在 @RequestParam 没有指明默认值时,会查看这个参数是否必须,如果必须,则按错误处理**
判断参数是否必须的代码即为下述关键代码行:
>
namedValueInfo.required &amp;&amp; !nestedParameter.isOptional()
很明显,若要判定一个参数是否是必须的,需要同时满足两个条件:条件 1 是@RequestParam 指明了必须(即属性 required 为 true实际上它也是默认值条件 2 是要求 @RequestParam 标记的参数本身不是可选的。
我们可以通过 MethodParameter#isOptional 方法看下可选的具体含义:
```
public boolean isOptional() {
return (getParameterType() == Optional.class || hasNullableAnnotation() ||
(KotlinDetector.isKotlinReflectPresent() &amp;&amp;
KotlinDetector.isKotlinType(getContainingClass()) &amp;&amp;
KotlinDelegate.isOptional(this)));
}
```
在不使用 Kotlin 的情况下,所谓可选,就是参数的类型为 Optional或者任何标记了注解名为 Nullable 且 RetentionPolicy 为 RUNTIM 的注解。
**3. 如果不是必须,则按 null 去做具体处理**
如果接受类型是 boolean返回 false如果是基本类型则直接报错这里不做展开。
结合我们的案例,我们的参数符合步骤 2 中判定为必选的条件,所以最终会执行方法 AbstractNamedValueMethodArgumentResolver#handleMissingValue
```
protected void handleMissingValue(String name, MethodParameter parameter) throws ServletException {
throw new ServletRequestBindingException(&quot;Missing argument '&quot; + name +
&quot;' for method parameter of type &quot; + parameter.getNestedParameterType().getSimpleName());
}
```
### 问题修正
通过案例解析,我们很容易就能修正这个问题,就是让参数有默认值或为非可选即可,具体方法包含以下几种。
**1. 设置 @RequestParam 的默认值**
修改代码如下:
>
@RequestParam(value = "address", defaultValue = "no address") String address
**2. 设置 @RequestParam 的 required 值**
修改代码如下:
>
@RequestParam(value = "address", required = false) String address)
**3. 标记任何名为 Nullable 且 RetentionPolicy 为 RUNTIME 的注解**
修改代码如下:
>
<p>[//org.springframework.lang.Nullable](//org.springframework.lang.Nullable) 可以<br>
[//edu.umd.cs.findbugs.annotations.Nullable](//edu.umd.cs.findbugs.annotations.Nullable) 可以<br>
@RequestParam(value = "address") @Nullable String address</p>
**4. 修改参数类型为 Optional**
修改代码如下:
>
@RequestParam(value = "address") Optional<string> address</string>
从这些修正方法不难看出:假设你不学习源码,解决方法就可能只局限于一两种,但是深入源码后,解决方法就变得格外多了。这里要特别强调的是:**在Spring Web 中,默认情况下,请求参数是必选项。**
## 案例 4请求参数格式错误
当我们使用 Spring URL 相关的注解,会发现 Spring 是能够完成自动转化的。例如在下面的代码中age 可以被直接定义为 int 这种基本类型Integer 也可以),而不是必须是 String 类型。
```
@RequestMapping(path = &quot;/hi5&quot;, method = RequestMethod.GET)
public String hi5(@RequestParam(&quot;name&quot;) String name, @RequestParam(&quot;age&quot;) int age){
return name + &quot; is &quot; + age + &quot; years old&quot;;
};
```
鉴于 Spring 的强大转化功能,我们断定 Spring 也支持日期类型的转化(也确实如此),于是我们可能会写出类似下面这样的代码:
```
@RequestMapping(path = &quot;/hi6&quot;, method = RequestMethod.GET)
public String hi6(@RequestParam(&quot;Date&quot;) Date date){
return &quot;date is &quot; + date ;
};
```
然后,我们使用一些看似明显符合日期格式的 URL 来访问,例如 [http://localhost:8080/hi6?date=2021-5-1 20:26:53](http://localhost:8080/hi6?date=2021-5-1%2020:26:53),我们会发现 Spring 并不能完成转化,而是报错如下:
<img src="https://static001.geekbang.org/resource/image/08/78/085931e6c4c8a01ae5f4f443c0393778.png" alt="">
此时,返回错误码 400错误信息为"Failed to convert value of type 'java.lang.String' to required type 'java.util.Date"。
如何理解这个案例?如果实现自动转化,我们又需要做什么?
### 案例解析
不管是使用 @PathVarible 还是 @RequetParam,我们一般解析出的结果都是一个 String 或 String 数组。例如,使用 @RequetParam 解析的关键代码参考 RequestParamMethodArgumentResolver#resolveName 方法:
```
@Nullable
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
//省略其他非关键代码
if (arg == null) {
String[] paramValues = request.getParameterValues(name);
if (paramValues != null) {
arg = (paramValues.length == 1 ? paramValues[0] : paramValues);
}
}
return arg;
}
```
这里我们调用的"request.getParameterValues(name)",返回的是一个 String 数组,最终给上层调用者返回的是单个 String如果只有一个元素时或者 String 数组。
所以很明显,在这个测试程序中,我们给上层返回的是一个 String这个 String 的值最终是需要做转化才能赋值给其他类型。例如对于案例中的"int age"定义,是需要转化为 int 基本类型的。这个基本流程可以通过 AbstractNamedValueMethodArgumentResolver#resolveArgument 的关键代码来验证:
```
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
//省略其他非关键代码
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
//以此为界,前面代码为解析请求参数,后续代码为转化解析出的参数
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
try {
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
}
//省略其他非关键代码
}
//省略其他非关键代码
return arg;
}
```
实际上在前面我们曾经提到过这个转化的基本逻辑,所以这里不再详述它具体是如何发生的。
在这里你只需要回忆出它是需要**根据源类型和目标类型寻找转化器来执行转化的**。在这里,对于 age 而言,最终找出的转化器是 StringToNumberConverterFactory。而对于 Date 型的 Date 变量,在本案例中,最终找到的是 ObjectToObjectConverter。它的转化过程参考下面的代码
```
public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
if (source == null) {
return null;
}
Class&lt;?&gt; sourceClass = sourceType.getType();
Class&lt;?&gt; targetClass = targetType.getType();
//根据源类型去获取构建出目标类型的方法:可以是工厂方法(例如 valueOf、from 方法)也可以是构造器
Member member = getValidatedMember(targetClass, sourceClass);
try {
if (member instanceof Method) {
//如果是工厂方法,通过反射创建目标实例
}
else if (member instanceof Constructor) {
//如果是构造器,通过反射创建实例
Constructor&lt;?&gt; ctor = (Constructor&lt;?&gt;) member;
ReflectionUtils.makeAccessible(ctor);
return ctor.newInstance(source);
}
}
catch (InvocationTargetException ex) {
throw new ConversionFailedException(sourceType, targetType, source, ex.getTargetException());
}
catch (Throwable ex) {
throw new ConversionFailedException(sourceType, targetType, source, ex);
}
```
当使用 ObjectToObjectConverter 进行转化时,是根据反射机制带着源目标类型来查找可能的构造目标实例方法,例如构造器或者工厂方法,然后再次通过反射机制来创建一个目标对象。所以对于 Date 而言,最终调用的是下面的 Date 构造器:
```
public Date(String s) {
this(parse(s));
}
```
然而,我们传入的 [2021-5-1 20:26:53](http://localhost:8080/hi6?date=2021-5-1%2020:26:53) 虽然确实是一种日期格式,但用来作为 Date 构造器参数是不支持的,最终报错,并被上层捕获,转化为 ConversionFailedException 异常。这就是这个案例背后的故事了。
### 问题修正
那么怎么解决呢?提供两种方法。
**1. 使用 Date 支持的格式**
例如下面的测试 URL 就可以工作起来:
>
[http://localhost:8080/hi6?date=Sat](http://localhost:8080/hi6?date=Sat), 12 Aug 1995 13:30:00 GMT
**2. 使用好内置格式转化器**
实际上在Spring中要完成 String 对于 Date 的转化ObjectToObjectConverter 并不是最好的转化器。我们可以使用更强大的AnnotationParserConverter。**在Spring 初始化时,会构建一些针对日期型的转化器,即相应的一些 AnnotationParserConverter 的实例。**但是为什么有时候用不上呢?
这是因为 AnnotationParserConverter 有目标类型的要求,这点我们可以通过调试角度来看下,参考 FormattingConversionService#addFormatterForFieldAnnotation 方法的调试试图:
<img src="https://static001.geekbang.org/resource/image/0c/34/0c8bd3fc14081710cc411091c8bd4f34.png" alt="">
这是适应于 String 到 Date 类型的转化器 AnnotationParserConverter 实例的构造过程,其需要的 annototationType 参数为 DateTimeFormat。
annototationType 的作用正是为了帮助判断是否能用这个转化器,这一点可以参考代码 AnnotationParserConverter#matches
```
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
return targetType.hasAnnotation(this.annotationType);
}
```
最终构建出来的转化器相关信息可以参考下图:
<img src="https://static001.geekbang.org/resource/image/f0/b1/f068b39c4a3f81b8ccebbfd962e966b1.png" alt="">
图中构造出的转化器是可以用来转化 String 到 Date但是它要求我们标记 @DateTimeFormat。很明显,我们的参数 Date 并没有标记这个注解,所以这里为了使用这个转化器,我们可以使用上它并提供合适的格式。这样就可以让原来不工作的 URL 工作起来,具体修改代码如下:
```
@DateTimeFormat(pattern=&quot;yyyy-MM-dd HH:mm:ss&quot;) Date date
```
以上即为本案例的解决方案。除此之外,我们完全可以制定一个转化器来帮助我们完成转化,这里不再赘述。另外,通过这个案例,我们可以看出:尽管 Spring 给我们提供了很多内置的转化功能,但是我们一定要注意,格式是否符合对应的要求,否则代码就可能会失效。
## 重点回顾
通过这一讲的学习我们了解到了在Spring解析URL中的一些常见错误及其背后的深层原因。这里再次回顾下重点
1. 当我们使用@PathVariable时,一定要注意传递的值是不是含有 / ;
1. 当我们使用@RequestParam@PathVarible等注解时,一定要意识到一个问题,虽然下面这两种方式(以@RequestParam使用示例)都可以,但是后者在一些项目中并不能正常工作,因为很多产线的编译配置会去掉不是必须的调试信息。
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestParam(&quot;name&quot;) String name){
return name;
};
//方式2没有显式指定RequestParam的“name”这种方式有时候会不行
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam String name){
return name;
};
```
1. 任何一个参数我们都需要考虑它是可选的还是必须的。同时你一定要想到参数类型的定义到底能不能从请求中自动转化而来。Spring本身给我们内置了很多转化器但是我们要以合适的方式使用上它。另外Spring对很多类型的转化设计都很贴心例如使用下面的注解就能解决自定义日期格式参数转化问题。
```
@DateTimeFormat(pattern=&quot;yyyy-MM-dd HH:mm:ss&quot;) Date date
```
希望这些核心知识点能帮助你高效解析URL。
## 思考题
关于 URL 解析,其实还有许多让我们惊讶的地方,例如案例 2 的部分代码:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestParam(&quot;name&quot;) String name){
return name;
};
```
在上述代码的应用中,我们可以使用 [http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei) 来测试下,结果会返回什么呢?你猜会是[xiaoming&amp;name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&amp;name=hanmeimei) 么?
我们留言区见!

View File

@@ -0,0 +1,588 @@
<audio id="audio" title="10 | Spring Web Header 解析常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/a4/176b58756c8d9efdb2ea7f5043176da4.mp3"></audio>
你好,我是傅健,这节课我们来聊聊 Spring Web 开发中 Header 相关的常见错误案例。
在上节课,我们梳理了 URL 相关错误。实际上,对于一个 HTTP 请求而言URL 固然重要但是为了便于用户使用URL 的长度有限,所能携带的信息也因此受到了制约。
如果想提供更多的信息Header 往往是不二之举。不言而喻Header 是介于 URL 和 Body 之外的第二大重要组成它提供了更多的信息以及围绕这些信息的相关能力例如Content-Type指定了我们的请求或者响应的内容类型便于我们去做解码。虽然 Spring 对于 Header 的解析,大体流程和 URL 相同,但是 Header 本身具有自己的特点。例如Header 不像 URL 只能出现在请求中。所以Header 处理相关的错误和 URL 又不尽相同。接下来我们看看具体的案例。
## 案例 1接受 Header 使用错 Map 类型
在 Spring 中解析 Header 时我们在多数场合中是直接按需解析的。例如我们想使用一个名为myHeaderName的 Header我们会书写代码如下
```
@RequestMapping(path = &quot;/hi&quot;, method = RequestMethod.GET)
public String hi(@RequestHeader(&quot;myHeaderName&quot;) String name){
//省略 body 处理
};
```
定义一个参数,标记上@RequestHeader,指定要解析的 Header 名即可。但是假设我们需要解析的 Header 很多时,按照上面的方式很明显会使得参数越来越多。在这种情况下,我们一般都会使用 Map 去把所有的 Header 都接收到,然后直接对 Map 进行处理。于是我们可能会写出下面的代码:
```
@RequestMapping(path = &quot;/hi1&quot;, method = RequestMethod.GET)
public String hi1(@RequestHeader() Map map){
return map.toString();
};
```
粗略测试程序,你会发现一切都很好。而且上面的代码也符合针对接口编程的范式,即使用了 Map 这个接口类型。但是上面的接口定义在遇到下面的请求时,就会超出预期。请求如下:
>
<p>GET [http://localhost:8080/hi1](http://localhost:8080/hi1)<br>
myheader: h1<br>
myheader: h2</p>
这里存在一个 Header 名为 myHeader不过这个 Header 有两个值。此时我们执行请求,会发现返回的结果并不能将这两个值如数返回。结果示例如下:
```
{myheader=h1, host=localhost:8080, connection=Keep-Alive, user-agent=Apache-HttpClient/4.5.12 (Java/11.0.6), accept-encoding=gzip,deflate}
```
如何理解这个常见错误及背后原理?接下来我们就具体解析下。
### 案例解析
实际上,当我们看到这个测试结果,大多数同学已经能反应过来了。对于一个多值的 Header在实践中通常有两种方式来实现一种是采用下面的方式
>
Key: value1,value2
而另外一种方式就是我们测试请求中的格式:
>
<p>Key:value1<br>
Key:value2</p>
对于方式 1我们使用 Map 接口自然不成问题。但是如果使用的是方式 2我们就不能拿到所有的值。这里我们可以翻阅代码查下 Map 是如何接收到所有请求的。
对于一个 Header 的解析,主要有两种方式,分别实现在 RequestHeaderMethodArgumentResolver 和 RequestHeaderMapMethodArgumentResolver 中,它们都继承于 AbstractNamedValueMethodArgumentResolver但是应用的场景不同我们可以对比下它们的 supportsParameter(),来对比它们适合的场景:
<img src="https://static001.geekbang.org/resource/image/f7/e6/f7f804ec3e447c95eafde8cc5255bee6.png" alt="">
在上图中,左边是 RequestHeaderMapMethodArgumentResolver 的方法。通过比较可以发现,对于一个标记了 @RequestHeader 的参数,如果它的类型是 Map则使用 RequestHeaderMapMethodArgumentResolver否则一般使用的是 RequestHeaderMethodArgumentResolver。
在我们的案例中,很明显,参数类型定义为 Map所以使用的自然是 RequestHeaderMapMethodArgumentResolver。接下来我们继续查看它是如何解析 Header 的,关键代码参考 resolveArgument()
```
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Class&lt;?&gt; paramType = parameter.getParameterType();
if (MultiValueMap.class.isAssignableFrom(paramType)) {
MultiValueMap&lt;String, String&gt; result;
if (HttpHeaders.class.isAssignableFrom(paramType)) {
result = new HttpHeaders();
}
else {
result = new LinkedMultiValueMap&lt;&gt;();
}
for (Iterator&lt;String&gt; iterator = webRequest.getHeaderNames(); iterator.hasNext();) {
String headerName = iterator.next();
String[] headerValues = webRequest.getHeaderValues(headerName);
if (headerValues != null) {
for (String headerValue : headerValues) {
result.add(headerName, headerValue);
}
}
}
return result;
}
else {
Map&lt;String, String&gt; result = new LinkedHashMap&lt;&gt;();
for (Iterator&lt;String&gt; iterator = webRequest.getHeaderNames(); iterator.hasNext();) {
String headerName = iterator.next();
//只取了一个“值”
String headerValue = webRequest.getHeader(headerName);
if (headerValue != null) {
result.put(headerName, headerValue);
}
}
return result;
}
}
```
针对我们的案例,这里并不是 MultiValueMap所以我们会走入 else 分支。这个分支首先会定义一个 LinkedHashMap然后将请求一一放置进去并返回。其中第 29 行是去解析获取 Header 值的实际调用,在不同的容器下实现不同。例如在 Tomcat 容器下,它的执行方法参考 MimeHeaders#getValue
```
public MessageBytes getValue(String name) {
for (int i = 0; i &lt; count; i++) {
if (headers[i].getName().equalsIgnoreCase(name)) {
return headers[i].getValue();
}
}
return null;
}
```
当一个请求出现多个同名 Header 时,我们只要匹配上任何一个即立马返回。所以在本案例中,只返回了一个 Header 的值。
其实换一个角度思考这个问题,毕竟前面已经定义的接收类型是 LinkedHashMap它的 Value 的泛型类型是 String也不适合去组织多个值的情况。综上不管是结合代码还是常识本案例的代码都不能获取到myHeader的所有值。
### 问题修正
现在我们要修正这个问题。在案例解析部分,其实我已经给出了答案。
在 RequestHeaderMapMethodArgumentResolver 的 resolveArgument() 中,假设我们的参数类型是 MultiValueMap我们一般会创建一个 LinkedMultiValueMap然后使用下面的语句来获取 Header 的值并添加到 Map 中去:
>
String[] headerValues = webRequest.getHeaderValues(headerName)
参考上面的语句,不用细究,我们也能看出,我们是可以获取多个 Header 值的。另外假设我们定义的是 HttpHeaders也是一种 MultiValueMap我们会直接创建一个 HttpHeaders 来存储所有的 Header。
有了上面的解析,我们可以得出这样一个结论:**要完整接收到所有的Header不能直接使用Map而应该使用MultiValueMap。**我们可以采用以下两种方式来修正这个问题:
```
//方式 1
@RequestHeader() MultiValueMap map
//方式 2
@RequestHeader() HttpHeaders map
```
重新运行测试,你会发现结果符合预期:
>
[myheader:"h1", "h2", host:"localhost:8080", connection:"Keep-Alive", user-agent:"Apache-HttpClient/4.5.12 (Java/11.0.6)", accept-encoding:"gzip,deflate"]
对比来说,方式 2 更值得推荐,因为它使用了大多数人常用的 Header 获取方法,例如获取 Content-Type 直接调用它的 getContentType() 即可,诸如此类,非常好用。
反思这个案例,我们为什么会犯这种错误呢?追根溯源,还是在于我们很少看到一个 Header 有多个值的情况,从而让我们疏忽地用错了接收类型。
## 案例 2错认为 Header 名称首字母可以一直忽略大小写
在 HTTP 协议中Header 的名称是无所谓大小写的。在使用各种框架构建 Web 时,我们都会把这个事实铭记于心。我们可以验证下这个想法。例如,我们有一个 Web 服务接口如下:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader){
return myHeader;
};
```
然后,我们使用下面的请求来测试这个接口是可以获取到对应的值的:
>
<p>GET [http://localhost:8080/hi2](http://localhost:8080/hi2)<br>
myheader: myheadervalue</p>
另外结合案例1我们知道可以使用 Map 来接收所有的 Header那么这种方式下是否也可以忽略大小写呢这里我们不妨使用下面的代码来比较下
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader MultiValueMap map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;MyHeader&quot;);
};
```
再次运行之前的测试请求,我们得出下面的结果:
>
myheadervalue compare with : null
综合来看,直接获取 Header 是可以忽略大小写的,但是如果从接收过来的 Map 中获取 Header 是不能忽略大小写的。稍微不注意,我们就很容易认为 Header 在任何情况下,都可以不区分大小写来获取值。
那么针对这个案例,如何去理解?
### 案例解析
我们知道,对于"@RequestHeader("MyHeader") String myHeader"的定义Spring 使用的是 RequestHeaderMethodArgumentResolver 来做解析。解析的方法参考 RequestHeaderMethodArgumentResolver#resolveName
```
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
String[] headerValues = request.getHeaderValues(name);
if (headerValues != null) {
return (headerValues.length == 1 ? headerValues[0] : headerValues);
}
else {
return null;
}
}
```
从上述方法的关键调用"request.getHeaderValues(name)"去按图索骥,我们可以找到查找 Header 的最根本方法,即 org.apache.tomcat.util.http.ValuesEnumerator#findNext
```
private void findNext() {
next=null;
for(; pos&lt; size; pos++ ) {
MessageBytes n1=headers.getName( pos );
if( n1.equalsIgnoreCase( name )) {
next=headers.getValue( pos );
break;
}
}
pos++;
}
```
在上述方法中name 即为查询的 Header 名称,可以看出这里是忽略大小写的。
而如果我们用 Map 来接收所有的 Header我们来看下这个 Map 最后存取的 Header 和获取的方法有没有忽略大小写。
有了案例 1 的解析,针对当前的类似案例,结合具体的代码,我们很容易得出下面两个结论。
**1. 存取 Map 的 Header 是没有忽略大小写的**
参考案例 1 解析部分贴出的代码,可以看出,在存取 Header 时,需要的 key 是遍历 webRequest.getHeaderNames() 的返回结果。而这个方法的执行过程参考 org.apache.tomcat.util.http.NamesEnumerator#findNext
```
private void findNext() {
next=null;
for(; pos&lt; size; pos++ ) {
next=headers.getName( pos ).toString();
for( int j=0; j&lt;pos ; j++ ) {
if( headers.getName( j ).equalsIgnoreCase( next )) {
// duplicate.
next=null;
break;
}
}
if( next!=null ) {
// it's not a duplicate
break;
}
}
// next time findNext is called it will try the
// next element
pos++;
}
```
这里,返回结果并没有针对 Header 的名称做任何大小写忽略或转化工作。
**2. 从 Map 中获取的 Header 也没有忽略大小写**
这点可以从返回是 LinkedHashMap 类型看出LinkedHashMap 的 get() 未忽略大小写。
接下来我们看下怎么解决。
### 问题修正
就从接收类型 Map 中获取 Header 时注意下大小写就可以了,修正代码如下:
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader MultiValueMap map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;myHeader&quot;);
};
```
另外,你可以思考一个问题,如果我们使用 HTTP Headers 来接收请求,那么从它里面获取 Header 是否可以忽略大小写呢?
这点你可以通过它的构造器推测出来,其构造器代码如下:
```
public HttpHeaders() {
this(CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap&lt;&gt;(8, Locale.ENGLISH)));
}
```
可以看出,它使用的是 LinkedCaseInsensitiveMap而不是普通的 LinkedHashMap。所以这里是可以忽略大小写的我们不妨这样修正
```
@RequestMapping(path = &quot;/hi2&quot;, method = RequestMethod.GET)
public String hi2(@RequestHeader(&quot;MyHeader&quot;) String myHeader, @RequestHeader HttpHeaders map){
return myHeader + &quot; compare with : &quot; + map.get(&quot;MyHeader&quot;);
};
```
再运行下程序,结果已经符合我们的预期了:
>
myheadervalue compare with : [myheadervalue]
通过这个案例,我们可以看出:**在实际使用时,虽然 HTTP 协议规范可以忽略大小写,但是不是所有框架提供的接口方法都是可以忽略大小写的。**这点你一定要注意!
## 案例 3试图在 Controller 中随意自定义 CONTENT_TYPE 等
和开头我们提到的 Header 和 URL 不同Header 可以出现在返回中。正因为如此,一些应用会试图去定制一些 Header 去处理。例如使用 Spring Boot 基于 Tomcat 内置容器的开发中,存在下面这样一段代码去设置两个 Header其中一个是常用的 CONTENT_TYPE另外一个是自定义的命名为 myHeader。
```
@RequestMapping(path = &quot;/hi3&quot;, method = RequestMethod.GET)
public String hi3(HttpServletResponse httpServletResponse){
httpServletResponse.addHeader(&quot;myheader&quot;, &quot;myheadervalue&quot;);
httpServletResponse.addHeader(HttpHeaders.CONTENT_TYPE, &quot;application/json&quot;);
return &quot;ok&quot;;
};
```
运行程序测试下(访问 GET [http://localhost:8080/hi3](http://localhost:8080/hi3) ),我们会得到如下结果:
>
<p>GET [http://localhost:8080/hi3](http://localhost:8080/hi3)<br>
&nbsp;<br>
HTTP/1.1 200<br>
myheader: myheadervalue<br>
Content-Type: text/plain;charset=UTF-8<br>
Content-Length: 2<br>
Date: Wed, 17 Mar 2021 08:59:56 GMT<br>
Keep-Alive: timeout=60<br>
Connection: keep-alive</p>
可以看到 myHeader 设置成功了,但是 Content-Type 并没有设置成我们想要的"application/json",而是"text/plain;charset=UTF-8"。为什么会出现这种错误?
### 案例解析
首先我们来看下在 Spring Boot 使用内嵌 Tomcat 容器时,尝试添加 Header 会执行哪些关键步骤。
第一步我们可以查看 org.apache.catalina.connector.Response#addHeader 方法,代码如下:
```
private void addHeader(String name, String value, Charset charset) {
//省略其他非关键代码
char cc=name.charAt(0);
if (cc=='C' || cc=='c') {
//判断是不是 Content-Type如果是不要把这个 Header 作为 header 添加到 org.apache.coyote.Response
if (checkSpecialHeader(name, value))
return;
}
getCoyoteResponse().addHeader(name, value, charset);
}
```
参考代码及注释,正常添加一个 Header 是可以添加到 Header 集里面去的,但是如果这是一个 Content-Type则事情会变得不一样。它并不会如此做而是去做另外一件事即通过 Response#checkSpecialHeader 的调用来设置 org.apache.coyote.Response#contentType 为 application/json关键代码如下
```
private boolean checkSpecialHeader(String name, String value) {
if (name.equalsIgnoreCase(&quot;Content-Type&quot;)) {
setContentType(value);
return true;
}
return false;
}
```
最终我们获取到的 Response 如下:
<img src="https://static001.geekbang.org/resource/image/5f/5e/5f6e7b91eedcbdc98c124cac6f00f85e.png" alt="">
从上图可以看出Headers 里并没有 Content-Type而我们设置的 Content-Type 已经作为 coyoteResponse 成员的值了。当然也不意味着后面一定不会返回,我们可以继续跟踪后续执行。
在案例代码返回ok后我们需要对返回结果进行处理执行方法为RequestResponseBodyMethodProcessor#handleReturnValue,关键代码如下:
```
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
//对返回值(案例中为“ok”)根据返回类型做编码转化处理
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
```
而在上述代码的调用中writeWithMessageConverters 会根据返回值及类型做转化,同时也会做一些额外的事情。它的一些关键实现步骤参考下面几步:
**1. 决定用哪一种 MediaType 返回**
参考下面的关键代码:
```
//决策返回值是何种 MediaType
MediaType selectedMediaType = null;
MediaType contentType = outputMessage.getHeaders().getContentType();
boolean isContentTypePreset = contentType != null &amp;&amp; contentType.isConcrete();
//如果 header 中有 contentType则用其作为选择的 selectedMediaType。
if (isContentTypePreset) {
selectedMediaType = contentType;
}
//没有则根据“Accept”头、返回值等核算用哪一种
else {
HttpServletRequest request = inputMessage.getServletRequest();
List&lt;MediaType&gt; acceptableTypes = getAcceptableMediaTypes(request);
List&lt;MediaType&gt; producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
//省略其他非关键代码
List&lt;MediaType&gt; mediaTypesToUse = new ArrayList&lt;&gt;();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
//省略其他关键代码
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
//省略其他关键代码
}
```
​这里我解释一下,上述代码是先根据是否具有 Content-Type 头来决定返回的 MediaType通过前面的分析它是一种特殊的 Header在 Controller 层并没有被添加到 Header 中去,所以在这里只能根据返回的类型、请求的 Accept 等信息协商出最终用哪种 MediaType。
实际上这里最终使用的是 MediaType#TEXT_PLAIN。这里还需要补充说明下,没有选择 JSON 是因为在都支持的情况下TEXT_PLAIN 默认优先级更高,参考代码 WebMvcConfigurationSupport#addDefaultHttpMessageConverters 可以看出转化器是有优先顺序的,所以用上述代码中的 getProducibleMediaTypes() 遍历 Converter 来收集可用 MediaType 也是有顺序的。
**2. 选择消息转化器并完成转化**
决定完 MediaType 信息后,即可去选择转化器并执行转化,关键代码如下:
```
for (HttpMessageConverter&lt;?&gt; converter : this.messageConverters) {
GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
(GenericHttpMessageConverter&lt;?&gt;) converter : null);
if (genericConverter != null ?
((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
converter.canWrite(valueType, selectedMediaType)) {
//省略其他非关键代码
if (body != null) {
//省略其他非关键代码
if (genericConverter != null) {
genericConverter.write(body, targetType, selectedMediaType, outputMessage);
}
else {
((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
}
}
//省略其他非关键代码
}
}
```
如代码所示,即结合 targetTypeString、valueTypeString、selectedMediaTypeMediaType#TEXT_PLAIN)三个信息来决策可以使用哪种消息 Converter。常见候选 Converter 可以参考下图:
<img src="https://static001.geekbang.org/resource/image/a3/e6/a33b9282baac597d1f3acf74a6874ce6.png" alt="">
最终,本案例选择的是 StringHttpMessageConverter在最终调用父类方法 AbstractHttpMessageConverter#write 执行转化时,会尝试添加 Content-Type。具体代码参考 AbstractHttpMessageConverter#addDefaultHeaders
```
protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException {
if (headers.getContentType() == null) {
MediaType contentTypeToUse = contentType;
if (contentType == null || contentType.isWildcardType() || contentType.isWildcardSubtype()) {
contentTypeToUse = getDefaultContentType(t);
}
else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) {
MediaType mediaType = getDefaultContentType(t);
contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse);
}
if (contentTypeToUse != null) {
if (contentTypeToUse.getCharset() == null) {
//尝试添加字符集
Charset defaultCharset = getDefaultCharset();
if (defaultCharset != null) {
contentTypeToUse = new MediaType(contentTypeToUse, defaultCharset);
}
}
headers.setContentType(contentTypeToUse);
}
}
//省略其他非关键代码
}
```
结合案例,参考代码,我们可以看出,我们使用的是 MediaType#TEXT_PLAIN 作为 Content-Type 的 Header毕竟之前我们添加 Content-Type 这个 Header 并没有成功。最终运行结果也就不出意外了,即"Content-Type: text/plain;charset=UTF-8"。
通过案例分析可以总结出,虽然我们在 Controller 设置了 Content-Type但是它是一种特殊的 Header所以**在 Spring Boot 基于内嵌 Tomcat 开发时并不一定能设置成功,最终返回的 Content-Type 是根据实际的返回值及类型等多个因素来决定的。**
### 问题修正
针对这个问题,如果想设置成功,我们就必须让其真正的返回就是 JSON 类型,这样才能刚好生效。而且从上面的分析也可以看出,返回符合预期也并非是在 Controller 设置的功劳。不过围绕目标,我们也可以这样去修改下:
**1. 修改请求中的 Accept 头,约束返回类型**
参考代码如下:
```
GET http://localhost:8080/hi3
Accept:application/json
```
即带上 Accept 头,这样服务器在最终决定 MediaType 时,会选择 Accept 的值。具体执行可参考方法 AbstractMessageConverterMethodProcessor#getAcceptableMediaTypes
**2. 标记返回类型**
主动显式指明类型,修改方法如下:
```
@RequestMapping(path = &quot;/hi3&quot;, method = RequestMethod.GET, produces = {&quot;application/json&quot;})
```
即使用 produces 属性来指明即可。这样的方式影响的是可以返回的 Media 类型,一旦设置,下面的方法就可以只返回一个指明的类型了。参考 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes
```
protected List&lt;MediaType&gt; getProducibleMediaTypes(
HttpServletRequest request, Class&lt;?&gt; valueClass, @Nullable Type targetType) {
Set&lt;MediaType&gt; mediaTypes =
(Set&lt;MediaType&gt;) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList&lt;&gt;(mediaTypes);
}
//省略其他非关键代码
}
```
上述两种方式,一个修改了 getAcceptableMediaTypes 返回值,一个修改了 getProducibleMediaTypes这样就可以控制最终协商的结果为 JSON 了。从而影响后续的执行结果。
不过这里需要额外注意的是,虽然我们最终结果返回的 Content-Type 头是 JSON 了,但是对于内容的加工,仍然采用的是 StringHttpMessageConverter感兴趣的话你可以自己去研究下原因。
## 重点回顾
通过这节课的学习,我们了解到了在 Spring 解析Header中的一些常见错误及其背后的深层原因。这里带你回顾下重点
1. 要完整接收到所有的 Header不能直接使用Map而应该使用MultiValueMap。常见的两种方式如下
```
//方式 1
@RequestHeader() MultiValueMap map
//方式 2专用于Header的MultiValueMap子类型
@RequestHeader() HttpHeaders map
```
深究原因Spring在底层解析Header时如果接收参数是Map则当请求的Header是多Value时只存下了其中一个Value。
<li>
在 HTTP 协议规定中Header 的名称是无所谓大小写的。但是这并不意味着所有能获取到Header的途径最终得到的Header名称都是统一大小写的。
</li>
<li>
不是所有的Header在响应中都能随意指定虽然表面看起来能生效但是最后返回给客户端的仍然不是你指定的值。例如在Tomcat下CONTENT_TYPE这个Header就是这种情况。
</li>
以上即为这一讲的核心知识点希望你以后在解析Header时会更有信心。
## 思考题
在案例 3 中,我们以 Content-Type 为例,提到在 Controller 层中随意自定义常用头有时候会失效。那么这个结论是不是普适呢?即在使用其他内置容器或者在其他开发框架下,是不是也会存在一样的问题?
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,597 @@
<audio id="audio" title="11 | Spring Web Body 转化常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/4e/be86c1f408267b28909240f67471a84e.mp3"></audio>
你好,我是傅健。前面几节课我们学习了 Spring Web 开发中绕不开的 URL 和 Header 处理。这一节课,我们接着讲 Body 的处理。
实际上,在 Spring 中,对于 Body 的处理很多是借助第三方编解码器来完成的。例如常见的 JSON 解析Spring 都是借助于 Jackson、Gson 等常见工具来完成。所以在 Body 处理中,我们遇到的很多错误都是第三方工具使用中的一些问题。
真正对于 Spring 而言,错误并不多,特别是 Spring Boot 的自动包装以及对常见问题的不断完善,让我们能犯的错误已经很少了。不过,毕竟不是每个项目都是直接基于 Spring Boot 的,所以还是会存在一些问题,接下来我们就一起梳理下。
## 案例 1No converter found for return value of type
在直接用 Spring MVC 而非 Spring Boot 来编写 Web 程序时,我们基本都会遇到 "No converter found for return value of type" 这种错误。实际上,我们编写的代码都非常简单,例如下面这段代码:
```
//定义的数据对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Student {
private String name;
private Integer age;
}
//定义的 API 借口
@RestController
public class HelloController {
@GetMapping(&quot;/hi1&quot;)
public Student hi1() {
return new Student(&quot;xiaoming&quot;, Integer.valueOf(12));
}
}
```
然后,我们的 pom.xml 文件也都是最基本的必备项,关键配置如下:
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework&lt;/groupId&gt;
&lt;artifactId&gt;spring-webmvc&lt;/artifactId&gt;
&lt;version&gt;5.2.3.RELEASE&lt;/version&gt;
&lt;/dependency&gt;
```
但是当我们运行起程序,执行测试代码,就会报错如下:
<img src="https://static001.geekbang.org/resource/image/42/b8/42b44dd7673c9db6828e57566a5af1b8.png" alt="">
从上述代码及配置来看,并没有什么明显的错误,可为什么会报错呢?难道框架不支持?
### 案例解析
要了解这个案例出现的原因,需要我们对如何处理响应有一个初步的认识。
当我们的请求到达 Controller 层后,我们获取到了一个对象,即案例中的 new Student("xiaoming", Integer.valueOf(12)),那么这个对象应该怎么返回给客户端呢?
用 JSON 还是用 XML还是其他类型编码此时就需要一个决策我们可以先找到这个决策的关键代码所在参考方法 AbstractMessageConverterMethodProcessor#writeWithMessageConverters
```
HttpServletRequest request = inputMessage.getServletRequest();
List&lt;MediaType&gt; acceptableTypes = getAcceptableMediaTypes(request);
List&lt;MediaType&gt; producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
List&lt;MediaType&gt; mediaTypesToUse = new ArrayList&lt;&gt;();
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
```
实际上节课我们就贴出过相关代码并分析过,所以这里只是带着你简要分析下上述代码的基本逻辑:
1. 查看请求的头中是否有 ACCET 头,如果没有则可以使用任何类型;
1. 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
1. 取上面两步获取结果的交集来决定用什么方式返回。
比较代码我们可以看出假设第2步中就没有找到合适的编码方式则直接报案例中的错误具体的关键代码行如下
```
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
```
那么当前可采用的编码类型是怎么决策出来的呢?我们可以进一步查看方法 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes
```
protected List&lt;MediaType&gt; getProducibleMediaTypes(
HttpServletRequest request, Class&lt;?&gt; valueClass, @Nullable Type targetType) {
Set&lt;MediaType&gt; mediaTypes =
(Set&lt;MediaType&gt;) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList&lt;&gt;(mediaTypes);
}
else if (!this.allSupportedMediaTypes.isEmpty()) {
List&lt;MediaType&gt; result = new ArrayList&lt;&gt;();
for (HttpMessageConverter&lt;?&gt; converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter &amp;&amp; targetType != null) {
if (((GenericHttpMessageConverter&lt;?&gt;) 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 来负责路由和处理请求。
具体而言,当我们使用 &lt;mvc:annotation-driven/&gt; 时,我们会通过 AnnotationDrivenBeanDefinitionParser 来构建这个 Bean。而在它的构建过程中会决策出以后要使用哪些 HttpMessageConverter相关代码参考 AnnotationDrivenBeanDefinitionParser#getMessageConverters
```
messageConverters.add(createConverterDefinition(ByteArrayHttpMessageConverter.class, source));
RootBeanDefinition stringConverterDef = createConverterDefinition(StringHttpMessageConverter.class, source);
stringConverterDef.getPropertyValues().add(&quot;writeAcceptCharset&quot;, false);
messageConverters.add(stringConverterDef);
messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source));
//省略其他非关键代码
if (jackson2Present) {
Class&lt;?&gt; 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 相关的转化器。最终候选列表示例如下:
<img src="https://static001.geekbang.org/resource/image/d8/d4/d84e2d85c91fd7dd6825e14984b071d4.png" alt="">
由此可见,并没有任何 JSON 相关的编解码器。而针对 Student 类型的返回对象,上面的这些编解码器又不符合要求,所以最终走入了下面的代码行:
```
if (body != null &amp;&amp; producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
&quot;No converter found for return value of type: &quot; + valueType);
}
```
抛出了 "No converter found for return value of type" 这种错误,结果符合案例中的实际测试情况。
### 问题修正
针对这个案例,有了源码的剖析,可以看出,**不是每种类型的编码器都会与生俱来,而是根据当前项目的依赖情况决定是否支持。**要解析 JSON我们就要依赖相关的包所以这里我们可以以 Gson 为例修正下这个问题:
```
&lt;dependency&gt;
&lt;groupId&gt;com.google.code.gson&lt;/groupId&gt;
&lt;artifactId&gt;gson&lt;/artifactId&gt;
&lt;version&gt;2.8.6&lt;/version&gt;
&lt;/dependency&gt;
```
我们添加了 Gson 的依赖到 pom.xml。重新运行程序和测试案例你会发现不再报错了。
另外,这里我们还可以查看下 GsonHttpMessageConverter 这种编码器是如何支持上 Student 这个对象的解析的。
通过这个案例我们可以知道Spring 给我们提供了很多好用的功能,但是这些功能交织到一起后,我们就很可能入坑,只有深入了解它的运行方式,才能迅速定位问题并解决问题。
## 案例 2变动地返回 Body
案例1让我们解决了解析问题那随着不断实践我们可能还会发现在代码并未改动的情况下返回结果不再和之前相同了。例如我们看下这段代码
```
@RestController
public class HelloController {
@PostMapping(&quot;/hi2&quot;)
public Student hi2(@RequestBody Student student) {
return student;
}
}
```
上述代码接受了一个 Student 对象,然后原样返回。我们使用下面的测试请求进行测试:
>
<p>POST [http://localhost:8080/springmvc3_war/app/hi2](http://localhost:8080/springmvc3_war/app/hi2)<br>
Content-Type: application/json<br>
{<br>
"name": "xiaoming"<br>
}</p>
经过测试,我们会得到以下结果:
>
<p>{<br>
"name": "xiaoming"<br>
}</p>
但是随着项目的推进,在代码并未改变时,我们可能会返回以下结果:
>
<p>&nbsp;<br>
{<br>
"name": "xiaoming",<br>
"age": null<br>
}</p>
即当 age 取不到值,开始并没有序列化它作为响应 Body 的一部分,后来又序列化成 null 作为 Body 返回了。
在什么情况下会如此?如何规避这个问题,保证我们的返回始终如一。
### 案例解析
如果我们发现上述问题,那么很有可能是这样一种情况造成的。即在后续的代码开发中,我们直接依赖或者间接依赖了新的 JSON 解析器例如下面这种方式就依赖了Jackson
```
&lt;dependency&gt;
&lt;groupId&gt;com.fasterxml.jackson.core&lt;/groupId&gt;
&lt;artifactId&gt;jackson-databind&lt;/artifactId&gt;
&lt;version&gt;2.9.6&lt;/version&gt;
&lt;/dependency&gt;
```
当存在多个 Jackson 解析器时,我们的 Spring MVC 会使用哪一种呢?这个决定可以参考
```
if (jackson2Present) {
Class&lt;?&gt; 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它的构造器中指明了相关配置
```
public Gson() {
this(Excluder.DEFAULT, FieldNamingPolicy.IDENTITY,
Collections.&lt;Type, InstanceCreator&lt;?&gt;&gt;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.&lt;TypeAdapterFactory&gt;emptyList(), Collections.&lt;TypeAdapterFactory&gt;emptyList(),
Collections.&lt;TypeAdapterFactory&gt;emptyList());
}
```
从DEFAULT_SERIALIZE_NULLS可以看出它是默认不序列化 null 的。
**2. 对于 Jackson 而言:**
MappingJackson2HttpMessageConverter 使用"Jackson2ObjectMapperBuilder.json().build()"来构建 ObjectMapper它默认只显式指定了下面两个配置
>
<p>MapperFeature.DEFAULT_VIEW_INCLUSION<br>
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES</p>
Jackson 默认对于 null 的处理是做序列化的,所以本案例中 age 为 null 时,仍然被序列化了。
通过上面两种 JSON 序列化的分析可以看出,**返回的内容在依赖项改变的情况下确实可能发生变化。**
### 问题修正
那么针对这个问题,如何修正呢?即保持在 Jackson 依赖项添加的情况下,让它和 Gson 的序列化行为一致吗?这里可以按照以下方式进行修改:
```
@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 的处理变成一致。
上述修改方案虽然看起来简单,但是假设有很多对象如此,万一遗漏了怎么办呢?所以可以从全局角度来修改,修改的关键代码如下:
>
<p>//ObjectMapper mapper = new ObjectMapper();<br>
mapper.setSerializationInclusion(Include.NON_NULL);</p>
但是如何修改 ObjectMapper 呢?这个对象是由 MappingJackson2HttpMessageConverter 构建的,看似无法插足去修改。实际上,我们在非 Spring Boot 程序中,可以按照下面这种方式来修改:
```
@RestController
public class HelloController {
public HelloController(RequestMappingHandlerAdapter requestMappingHandlerAdapter){
List&lt;HttpMessageConverter&lt;?&gt;&gt; messageConverters =
requestMappingHandlerAdapter.getMessageConverters();
for (HttpMessageConverter&lt;?&gt; messageConverter : messageConverters) {
if(messageConverter instanceof MappingJackson2HttpMessageConverter ){
(((MappingJackson2HttpMessageConverter)messageConverter).getObjectMapper()).setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
}
}
//省略其他非关键代码
}
```
我们用自动注入的方式获取到 RequestMappingHandlerAdapter然后找到 Jackson 解析器,进行配置即可。
通过上述两种修改方案,我们就能做到忽略 null 的 age 字段了。
## 案例 3Required request body is missing
通过案例 1我们已经能够解析 Body 了,但是有时候,我们会有一些很好的想法。例如为了查询问题方便,在请求过来时,自定义一个 Filter 来统一输出具体的请求内容,关键代码如下:
```
public class ReadBodyFilter implements Filter {
//省略其他非关键代码
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String requestBody = IOUtils.toString(request.getInputStream(), &quot;utf-8&quot;);
System.out.println(&quot;print request body in filter:&quot; + requestBody);
chain.doFilter(request, response);
}
}
```
然后,我们可以把这个 Filter 添加到 web.xml 并配置如下:
```
&lt;filter&gt;
&lt;filter-name&gt;myFilter&lt;/filter-name&gt;
&lt;filter-class&gt;com.puzzles.ReadBodyFilter&lt;/filter-class&gt;
&lt;/filter&gt;
&lt;filter-mapping&gt;
&lt;filter-name&gt;myFilter&lt;/filter-name&gt;
&lt;url-pattern&gt;/app/*&lt;/url-pattern&gt;
&lt;/filter-mapping&gt;
```
再测试下 Controller 层中定义的接口:
```
@PostMapping(&quot;/hi3&quot;)
public Student hi3(@RequestBody Student student) {
return student;
}
```
运行测试,我们会发现下面的日志:
>
<p>print request body in filter:{<br>
"name": "xiaoming",<br>
"age": 10<br>
}<br>
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)]</p>
可以看到,请求的 Body 确实在请求中输出了但是后续的操作直接报错了错误提示Required request body is missing。
### 案例解析
要了解这个错误的根本原因,你得知道这个错误抛出的源头。查阅请求 Body 转化的相关代码,有这样一段关键逻辑(参考 RequestResponseBodyMethodProcessor#readWithMessageConverters
```
protected &lt;T&gt; 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 &amp;&amp; checkRequired(parameter)) {
throw new HttpMessageNotReadableException(&quot;Required request body is missing: &quot; +
parameter.getExecutable().toGenericString(), inputMessage);
}
return arg;
}
protected boolean checkRequired(MethodParameter parameter) {
RequestBody requestBody = parameter.getParameterAnnotation(RequestBody.class);
return (requestBody != null &amp;&amp; requestBody.required() &amp;&amp; !parameter.isOptional());
}
```
当使用了 @RequestBody 且是必须时,如果解析出的 Body 为 null则报错提示 Required request body is missing。
所以我们要继续追踪代码,来查询什么情况下会返回 body 为 null。关键代码参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
```
protected &lt;T&gt; Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType){
//省略非关键代码
Object body = NO_VALUE;
EmptyBodyCheckingHttpInputMessage message;
try {
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter&lt;?&gt; converter : this.messageConverters) {
Class&lt;HttpMessageConverter&lt;?&gt;&gt; converterType = (Class&lt;HttpMessageConverter&lt;?&gt;&gt;) converter.getClass();
GenericHttpMessageConverter&lt;?&gt; genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter&lt;?&gt;) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null &amp;&amp; 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(&quot;I/O error while reading input message&quot;, ex, inputMessage);
}
//省略非关键代码
return body;
}
```
当 message 没有 body 时( message.hasBody()为 false ),则将 body 认为是 null。继续查看 message 本身的定义,它是一种包装了请求 Header 和 Body 流的 EmptyBodyCheckingHttpInputMessage 类型。其代码实现如下:
```
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 来判断出的,即没有数据可以读取。
看到这里你可能会有疑问假设有Bodyread()的执行不就把数据读取走了一点么?确实如此,所以这里我使用了 pushbackInputStream.unread(b) 调用来把读取出来的数据归还回去,这样就完成了是否有 Body 的判断,又保证了 Body 的完整性。
分析到这里,再结合前面的案例,你应该能想到造成 Body 缺失的原因了吧?
1. 本身就没有 Body
1. 有Body但是 Body 本身代表的流已经被前面读取过了。
很明显我们的案例属于第2种情况即在过滤器中我们就已经将 Body 读取完了,关键代码如下:
>
<p>//request 是 ServletRequest<br>
String requestBody = IOUtils.toString(request.getInputStream(), "utf-8");</p>
在这种情况下,作为一个普通的流,已经没有数据可以供给后面的转化器来读取了。
### 问题修正
所以我们可以直接在过滤器中去掉 Body 读取的代码,这样后续操作就又能读到数据了。但是这样又不满足我们的需求,如果我们坚持如此怎么办呢?这里我先直接给出答案,即定义一个 RequestBodyAdviceAdapter 的 Bean
```
@ControllerAdvice
public class PrintRequestBodyAdviceAdapter extends RequestBodyAdviceAdapter {
@Override
public boolean supports(MethodParameter methodParameter, Type type, Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; aClass) {
return true;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage,MethodParameter parameter, Type targetType,
Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; converterType) {
System.out.println(&quot;print request body in advice:&quot; + body);
return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
}
}
```
我们可以看到方法 afterBodyRead 的命名,很明显,这里的 Body 已经是从数据流中转化过的。
那么它是如何工作起来的呢?我们可以查看下面的代码(参考 AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
```
protected &lt;T&gt; 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&lt;T&gt;)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 需要不同的编解码器,而使用哪一种是协商出来的,协商过程大体如下:
- 查看请求头中是否有 ACCET 头,如果没有则可以使用任何类型;
- 查看当前针对返回类型(即 Student 实例)可以采用的编码类型;
- 取上面两步获取的结果的交集来决定用什么方式返回。
<li>
在非 Spring Boot 程序中JSON 等编解码器不见得是内置好的,需要添加相关的 JAR 才能自动依赖上,而自动依赖的实现是通过检查 Class 是否存在来实现的:当依赖上相关的 JAR 后,关键的 Class 就存在了,响应的编解码器功能也就提供上了。
</li>
<li>
不同的编解码器的实现(例如 JSON 工具 Jaskson 和 Gson可能有一些细节上的不同所以你一定要注意当依赖一个新的 JAR 时,是否会引起默认编解码器的改变,从而影响到一些局部行为的改变。
</li>
<li>
在尝试读取 HTTP Body 时,你要注意到 Body 本身是一个流对象,不能被多次读取。
</li>
以上即为这节课的主要内容,希望能对你有所帮助。
## 思考题
通过案例 1 的学习,我们知道直接基于 Spring MVC 而非 Spring Boot 时,是需要我们手工添加 JSON 依赖,才能解析出 JSON 的请求或者编码 JSON 响应,那么为什么基于 Spring Boot 就不需要这样做了呢?
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,421 @@
<audio id="audio" title="12Spring Web 参数验证常见错误" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/37/9c2f87183eeb7733d371ee1176f73a37.mp3"></audio>
你好,我是傅健,这节课我们来聊聊 Spring Web 开发中的参数检验Validation
参数检验是我们在Web编程时经常使用的技术之一它帮助我们完成请求的合法性校验可以有效拦截无效请求从而达到节省系统资源、保护系统的目的。
相比较其他 Spring 技术Spring提供的参数检验功能具有独立性强、使用难度不高的特点。但是在实践中我们仍然会犯一些常见的错误这些错误虽然不会导致致命的后果但是会影响我们的使用体验例如非法操作要在业务处理时才被拒绝且返回的响应码不够清晰友好。而且这些错误不经测试很难发现接下来我们就具体分析下这些常见错误案例及背后的原理。
## 案例1对象参数校验失效
在构建Web服务时我们一般都会对一个HTTP请求的 Body 内容进行校验,例如我们来看这样一个案例及对应代码。
当开发一个学籍管理系统时,我们会提供了一个 API 接口去添加学生的相关信息,其对象定义参考下面的代码:
```
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 去使用它,使用方法如下:
```
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 = &quot;students&quot;, method = RequestMethod.POST)
public void addStudent(@RequestBody Student student){
log.info(&quot;add new student: {}&quot;, student.toString());
//省略业务代码
};
}
```
我们提供了一个支持学生信息添加的接口。启动服务后,使用 IDEA 自带的 HTTP Client 工具来发送下面的请求以添加一个学生当然这个学生的姓名会远超想象即this_is_my_name_which_is_too_long
```
POST http://localhost:8080/students
Content-Type: application/json
{
&quot;name&quot;: &quot;this_is_my_name_which_is_too_long&quot;,
&quot;age&quot;: 10
}
```
很明显发送这样的请求name 超长)是期待 Spring Validation 能拦截它的,我们的预期响应如下(省略部分响应字段):
```
HTTP/1.1 400
Content-Type: application/json
{
&quot;timestamp&quot;: &quot;2021-01-03T00:47:23.994+0000&quot;,
&quot;status&quot;: 400,
&quot;error&quot;: &quot;Bad Request&quot;,
&quot;errors&quot;: [
&quot;defaultMessage&quot;: &quot;个数必须在 0 和 10 之间&quot;,
&quot;objectName&quot;: &quot;student&quot;,
&quot;field&quot;: &quot;name&quot;,
&quot;rejectedValue&quot;: &quot;this_is_my_name_which_is_too_long&quot;,
&quot;bindingFailure&quot;: false,
&quot;code&quot;: &quot;Size&quot;
}
],
&quot;message&quot;: &quot;Validation failed for object='student'. Error count: 1&quot;,
&quot;path&quot;: &quot;/students&quot;
}
```
但是理想与现实往往有差距。实际测试会发现使用上述代码构建的Web服务并没有做任何拦截。
### 案例解析
要找到这个问题的根源,我们就需要对 Spring Validation 有一定的了解。首先,我们来看下 RequestBody 接受对象校验发生的位置和条件。
假设我们构建Web服务使用的是Spring Boot技术我们可以参考下面的时序图了解它的核心执行步骤
<img src="https://static001.geekbang.org/resource/image/5f/09/5fbea419f5ced363b27c2b71ac35e009.png" alt="">
如上图所示,当一个请求来临时,都会进入 DispatcherServlet执行其 doDispatch(),此方法会根据 Path、Method 等关键信息定位到负责处理的 Controller 层方法(即 addStudent 方法然后通过反射去执行这个方法具体反射执行过程参考下面的代码InvocableHandlerMethod#invokeForRequest
```
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
//根据请求内容和方法定义获取方法参数实例
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace(&quot;Arguments: &quot; + Arrays.toString(args));
}
//携带方法参数实例去“反射”调用方法
return doInvoke(args);
}
```
要使用 Java 反射去执行一个方法需要先获取调用的参数上述代码正好验证了这一点getMethodArgumentValues() 负责获取方法执行参数doInvoke() 负责使用这些获取到的参数去执行。
而具体到getMethodArgumentValues() 如何获取方法调用参数,可以参考 addStudent 的方法定义我们需要从当前的请求NativeWebRequest )中构建出 Student 这个方法参数的实例。
>
public void addStudent(@RequestBody Student student)
那么如何构建出这个方法参数实例Spring 内置了相当多的 HandlerMethodArgumentResolver参考下图
<img src="https://static001.geekbang.org/resource/image/5c/8c/5c69fc306e942872dc0a4fba3047668c.png" alt="">
当试图构建出一个方法参数时会遍历所有支持的解析器Resolver以找出适合的解析器查找代码参考HandlerMethodArgumentResolverComposite#getArgumentResolver
```
@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 方法:
```
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(RequestBody.class);
}
```
找到 Resolver 后,就会执行 HandlerMethodArgumentResolver#resolveArgument 方法。它首先会根据当前的请求NativeWebRequest组装出 Student 对象并对这个对象进行必要的校验校验的执行参考AbstractMessageConverterMethodArgumentResolver#validateIfApplicable
```
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(&quot;Valid&quot;)) {
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 注解;
1. 标记了其他类型的注解且注解名称以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关键字开头的注解定义如下
```
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 对象,然后关联上学生对象,代码如下:
```
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 位),测试校验会发现这个约束并不生效。
```
POST http://localhost:8080/students
Content-Type: application/json
{
&quot;name&quot;: &quot;xiaoming&quot;,
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306123061230612306&quot;}
}
```
为什么会不生效?
### 案例解析
在解析案例 1 时,我们提及只要给对象参数 student 加上@Valid(或@Validated 等注解)就可以开启这个对象的校验。但实际上,关于 student 本身的 Phone 类型成员是否校验是在校验过程中即案例1中的代码行binder.validate(validationHints))决定的。
在校验执行时,首先会根据 Student 的类型定义找出所有的校验点,然后对 Student 对象实例执行校验,这个逻辑过程可以参考代码 ValidatorImpl#validate
```
@Override
public final &lt;T&gt; Set&lt;ConstraintViolation&lt;T&gt;&gt; validate(T object, Class&lt;?&gt;... groups) {
//省略部分非关键代码
Class&lt;T&gt; rootBeanClass = (Class&lt;T&gt;) object.getClass();
//获取校验对象类型的“信息”(包含“约束”)
BeanMetaData&lt;T&gt; rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
if ( !rootBeanMetaData.hasConstraints() ) {
return Collections.emptySet();
}
//省略部分非关键代码
//执行校验
return validateInContext( validationContext, valueContext, validationOrder );
}
```
这里语句"beanMetaDataManager.getBeanMetaData( rootBeanClass )"根据 Student 类型组装出 BeanMetaDataBeanMetaData 即包含了需要做的校验(即 Constraint
在组装 BeanMetaData 过程中,会根据成员字段是否标记了@Valid 来决定(记录)这个字段以后是否做级联校验,参考代码 AnnotationMetaDataProvider#getCascadingMetaData
```
private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement,
Map&lt;TypeVariable&lt;?&gt;, CascadingMetaDataBuilder&gt; 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,修正代码如下:
>
<p>@Valid<br>
private Phone phone;</p>
当修正完问题后,我们会发现校验生效了。而如果此时去调试修正后的案例代码,会看到 phone 字段 MetaData 信息中的 cascading 确实为 true 了,参考下图:
<img src="https://static001.geekbang.org/resource/image/46/56/4637447e4534c8f28d5541da0f8f0d56.png" alt="">
另外,假设我们不去解读源码,我们很可能会按照案例 1 所述的其他修正方法去修正这个问题。例如,使用 @Validated 来修正这个问题,但是此时你会发现,不考虑源码是否支持,代码本身也编译不过,这主要在于 @Validated 的定义是不允许修饰一个 Field 的:
```
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
```
通过上述方法修正问题,最终我们让嵌套验证生效了。但是你可能还是会觉得这个错误看起来不容易犯,那么可以试想一下,我们的案例仅仅是嵌套一层,而产品代码往往都是嵌套 n 层,此时我们是否能保证每一级都不会疏忽漏加@Valid呢?所以这仍然是一个典型的错误,需要你格外注意。
## 案例3误解校验执行
通过前面两个案例的填坑,我们一般都能让参数校验生效起来,但是校验本身有时候是一个无止境的完善过程,校验本身已经生效,但是否完美匹配我们所有苛刻的要求是另外一个容易疏忽的地方。例如,我们可能在实践中误解一些校验的使用。这里我们可以继续沿用前面的案例,变形一下。
之前我们定义的学生对象的姓名要求是小于 10 字节的(即@Size(max = 10))。此时我们可能想完善校验,例如,我们希望姓名不能是空,此时你可能很容易想到去修改关键行代码如下:
```
@Size(min = 1, max = 10)
private String name;
```
然后,我们以下面的 JSON Body 做测试:
```
{
&quot;name&quot;: &quot;&quot;,
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306&quot;}
}
```
测试结果符合我们的预期,但是假设更进一步,用下面的 JSON Body去除 name 字段)做测试呢?
```
{
&quot;age&quot;: 10,
&quot;phone&quot;: {&quot;number&quot;:&quot;12306&quot;}
}
```
我们会发现校验失败了。这结果难免让我们有一些惊讶,也倍感困惑:@Size(min = 1, max = 10) 都已经要求最小字节为 1 了,难道还只能约束空字符串(即“”),不能约束 null?
### 案例解析
如果我们稍微留心点的话,就会发现其实 @Size 的 Javadoc 已经明确了这种情况,参考下图:
<img src="https://static001.geekbang.org/resource/image/12/1f/12c9df125a788yyea25d191b9250d11f.png" alt="">
如图所示,"null elements are considered valid" 很好地解释了约束不住null的原因。当然纸上得来终觉浅我们还需要从源码级别解读下@Size 的校验过程。
这里我们找到了完成@Size 约束的执行方法,参考 SizeValidatorForCharSequence#isValid 方法:
```
public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
if ( charSequence == null ) {
return true;
}
int length = charSequence.length();
return length &gt;= min &amp;&amp; length &lt;= max;
}
```
如代码所示,当字符串为 null 时,直接通过了校验,而不会做任何进一步的约束检查。
### 问题修正
关于这个问题的修正,其实很简单,我们可以使用其他的注解(@NotNull@NotEmpty)来加强约束,修正代码如下:
```
@NotEmpty
@Size(min = 1, max = 10)
private String name;
```
完成代码修改后,重新测试,你就会发现约束已经完全满足我们的需求了。
## 重点回顾
看完上面的一些案例,我们会发现,这些错误的直接结果都是校验完全失败或者部分失败,并不会造成严重的后果,但是就像本讲开头所讲的那样,这些错误会影响我们的使用体验,所以我们还是需要去规避这些错误,把校验做强最好!
另外,关于@Valid@Validation 是我们经常犯迷糊的地方,不知道到底有什么区别。同时我们也经常产生一些困惑,例如能用其中一种时,能不能用另外一种呢?
通过解析,我们会发现,在很多场景下,我们不一定要寄希望于搜索引擎去区别,只需要稍微研读下代码,反而更容易理解。例如,对于案例 1研读完代码后我们发现它们不仅可以互换而且完全可以自定义一个以@Valid开头的注解来使用;而对于案例 2只能用@Valid 去开启级联校验。
## 思考题
在上面的学籍管理系统中,我们还存在一个接口,负责根据学生的学号删除他的信息,代码如下:
```
@RequestMapping(path = &quot;students/{id}&quot;, method = RequestMethod.DELETE)
public void deleteStudent(@PathVariable(&quot;id&quot;) @Range(min = 1,max = 10000) String id){
log.info(&quot;delete student: {}&quot;,id);
//省略业务代码
};
```
这个学生的编号是从请求的Path中获取的而且它做了范围约束必须在1到10000之间。那么你能找出负责解出 ID 的解析器HandlerMethodArgumentResolver是哪一种吗校验又是如何触发的
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,522 @@
<audio id="audio" title="13 | Spring Web 过滤器使用常见错误(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/f1/3dfbdf66b16a09037887f16f76a52bf1.mp3"></audio>
你好,我是傅健。
我们都知道,过滤器是 Servlet 的重要标准之一,其在请求和响应的统一处理、访问日志记录、请求权限审核等方面都有着不可替代的作用。在 Spring 编程中,我们主要就是配合使用 @ServletComponentScan@WebFilter 这两个注解来构建过滤器。
说起来比较简单,好像只是标记下这两个注解就一劳永逸了。但是我们还是会遇到各式各样的问题,例如工作不起来、顺序不对、执行多次等等都是常见的问题。这些问题的出现大多都是使用简单致使我们掉以轻心,只要你加强意识,大概率就可以规避了。
那么接下来我们就来学习两个典型的案例,并通过分析,带你进一步理解过滤器执行的流程和原理。
## 案例 1@WebFilter 过滤器无法被自动注入
假设我们要基于 Spring Boot 去开发一个学籍管理系统。为了统计接口耗时,可以实现一个过滤器如下:
```
@WebFilter
@Slf4j
public class TimeCostFilter implements Filter {
public TimeCostFilter(){
System.out.println(&quot;construct&quot;);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info(&quot;开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;执行时间(ms)&quot; + time);
}
}
```
这个过滤器标记了@WebFilter。所以在启动程序中,我们需要加上扫描注解(即@ServletComponentScan)让其生效,启动程序如下:
```
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info(&quot;启动成功&quot;);
}
}
```
然后,我们提供了一个 StudentController 接口来供学生注册:
```
@Controller
@Slf4j
public class StudentController {
@PostMapping(&quot;/regStudent/{name}&quot;)
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println(&quot;用户注册成功&quot;);
return &quot;success&quot;;
}
}
```
上述程序完成后,你会发现一切按预期执行。但是假设有一天,我们可能需要把 TimeCostFilter 记录的统计数据输出到专业的度量系统ElasticeSearch/InfluxDB 等)里面去,我们可能会添加这样一个 Service 类:
```
@Service
public class MetricsService {
@Autowired
public TimeCostFilter timeCostFilter;
//省略其他非关键代码
}
```
完成后你会发现Spring Boot 都无法启动了:
>
<p>***************************<br>
APPLICATION FAILED TO START<br>
***************************<br>
&nbsp;<br>
Description:<br>
&nbsp;<br>
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.</p>
为什么会出现这样的问题?既然 TimeCostFilter 生效了,看起来也像一个普通的 Bean为什么不能被自动注入
### 案例解析
这次我们换个方式,我先告诉你结论,你可以暂停几分钟想想关键点。
本质上,过滤器被 @WebFilter 修饰后TimeCostFilter 只会被包装为 FilterRegistrationBean而 TimeCostFilter 自身,只会作为一个 InnerBean 被实例化,这意味着 **TimeCostFilter 实例并不会作为 Bean 注册到 Spring 容器**
<img src="https://static001.geekbang.org/resource/image/61/6b/615a6049ec924b596cfb6b0abd795c6b.png" alt="">
所以当我们想自动注入 TimeCostFilter 时,就会失败了。知道这个结论后,我们可以带着两个问题去理清一些关键的逻辑:
1. FilterRegistrationBean 是什么?它是如何被定义的?
1. 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 是如何工作起来的。使用 @WebFilterFilter 被加载有两个条件:
- 声明了 @WebFilter
- 在能被 @ServletComponentScan 扫到的路径之下。
这里我们直接检索对 @WebFilter 的使用,可以发现 WebFilterHandler 类使用了它,直接在 doHandle() 中加入断点,开始调试,执行调用栈如下:
<img src="https://static001.geekbang.org/resource/image/67/35/67ff2f20993e9de7cdfc5c73acbe1035.png" alt="">
从堆栈上,我们可以看出对@WebFilter 的处理是在 Spring Boot 启动时,而处理的触发点是 ServletComponentRegisteringPostProcessor 这个类。它继承了 BeanFactoryPostProcessor 接口,实现对 @WebFilter@WebListener@WebServlet 的扫描和处理,其中对于@WebFilter 的处理使用的就是上文中提到的 WebFilterHandler。这个逻辑可以参考下面的关键代码
```
class ServletComponentRegisteringPostProcessor implements BeanFactoryPostProcessor, ApplicationContextAware {
private static final List&lt;ServletComponentHandler&gt; HANDLERS;
static {
List&lt;ServletComponentHandler&gt; servletComponentHandlers = new ArrayList&lt;&gt;();
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 注解的类,关键代码如下:
```
public void doHandle(Map&lt;String, Object&gt; attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue(&quot;asyncSupported&quot;, attributes.get(&quot;asyncSupported&quot;));
builder.addPropertyValue(&quot;dispatcherTypes&quot;, extractDispatcherTypes(attributes));
builder.addPropertyValue(&quot;filter&quot;, beanDefinition);
//省略其他非关键代码
builder.addPropertyValue(&quot;urlPatterns&quot;, extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
```
从这里,我们第一次看到了 FilterRegistrationBean。通过调试上述代码的最后一行可以看到最终我们注册的 FilterRegistrationBean其名字就是我们定义的 WebFilter 的名字:
<img src="https://static001.geekbang.org/resource/image/00/1d/009c909439cdb317063ff49b2460e41d.png" alt="">
后续这个 Bean 的具体创建过程,这里不再赘述,感兴趣的话你可以继续深入研究。
现在我们接着看第二个问题TimeCostFilter 何时被实例化?
此时,我们想要的 Bean 被“张冠李戴”成 FilterRegistrationBean但是 TimeCostFilter 是何时实例化的呢?为什么它没有成为一个普通的 Bean?
关于这点,我们可以在 TimeCostFilter 的构造器中加个断点,然后使用调试的方式快速定位到它的初始化时机,这里我直接给出了调试截图:
<img src="https://static001.geekbang.org/resource/image/1c/b0/1cb7346dda1e06e68f092ab789e230b0.png" alt="">
在上述的关键调用栈中,结合源码,你可以找出一些关键信息:
1. Tomcat 等容器启动时,才会创建 FilterRegistrationBean
1. FilterRegistrationBean 在被创建时createBean会创建 TimeCostFilter 来装配自身TimeCostFilter 是通过 ResolveInnerBean 来创建的;
1. TimeCostFilter 实例最终是一种 InnerBean我们可以通过下面的调试视图看到它的一些关键信息
<img src="https://static001.geekbang.org/resource/image/d1/41/d1637b371db3b4fd1c192e2dcaa6b541.png" alt="">
通过上述分析,你可以看出**最终 TimeCostFilter 实例是一种 InnerBean**,所以自动注入不到也就非常合理了。
### 问题修正
找到了问题的根源,解决就变得简单了。
从上述的解析中,我们可以了解到,当使用 @WebFilter 修饰过滤器时TimeCostFilter 类型的 Bean 并没有注册到 Spring 容器中,真正注册的是 FilterRegistrationBean。这里考虑到可能存在多个 Filter所以我们可以这样修改下案例代码
```
@Controller
@Slf4j
public class StudentController {
@Autowired
@Qualifier(&quot;com.spring.puzzle.filter.TimeCostFilter&quot;)
FilterRegistrationBean timeCostFilter;
}
```
这里的关键点在于:
- 注入的类型是 FilterRegistrationBean 类型,而不是 TimeCostFilter 类型;
- 注入的名称是包含包名的长名称,即 com.spring.puzzle.filter.TimeCostFilter不能用 TimeCostFilter以便于存在多个过滤器时进行精确匹配。
经过上述修改后,代码成功运行无任何报错,符合我们的预期。
## 案例 2Filter 中不小心多次执行 doFilter()
在之前的案例中,我们主要都讨论了使用@ServletComponentScan + @WebFilter 构建过滤器过程中的一些常见问题。
而在实际生产过程中,如果我们需要构建的过滤器是针对全局路径有效,且没有任何特殊需求(主要是指对 Servlet 3.0 的一些异步特性支持),那么你完全可以直接使用 Filter 接口(或者继承 Spring 对 Filter 接口的包装类 OncePerRequestFilter并使用@Component 将其包装为 Spring 中的普通 Bean也是可以达到预期的需求。
不过不管你使用哪一种方式,你都可能会遇到一个共同的问题:**业务代码重复执行多次**。
考虑到上一个案例用的是@ServletComponentScan + @WebFilter,这里我们不妨再以@Component + Filter 接口的实现方式来呈现下我们的案例,也好让你对 Filter 的使用能了解到更多。
首先,还是需要通过 Spring Boot 创建一个 Web 项目,不过已经不需要 @ServletComponentScan
```
@SpringBootApplication()
public class LearningApplication {
public static void main(String[] args) {
SpringApplication.run(LearningApplication.class, args);
System.out.println(&quot;启动成功&quot;);
}
}
```
StudentController 保持功能不变,所以你可以直接参考之前的代码。另外我们定义一个 DemoFilter 用来模拟问题,这个 Filter 标记了 @Component 且实现了 Filter 接口,已经不同于我们上一个案例的方式:
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println(&quot;Filter 处理中时发生异常&quot;);
throw new RuntimeException();
} catch (Exception e) {
chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
```
全部代码实现完毕,执行后结果如下:
```
Filter 处理中时发生异常
......用户注册成功
......用户注册成功
```
这里我们可以看出,业务代码被执行了两次,这并不符合我们的预期。
我们本来的设计目标是希望 Filter 的业务执行不会影响到核心业务的执行所以当抛出异常时我们还是会调用chain.doFilter。不过往往有时候我们会忘记及时返回而误入其他的chain.doFilter最终导致我们的 Filter 执行多次。
而检查代码时,我们往往不能立马看出问题。所以说,这是一个典型的错误,虽然原因很简单吧。不过借着这个案例,我们可以分析下为什么会执行两次,以深入了解 Filter 的执行。
### 案例解析
在解析之前,我先给你讲下 Filter 背后的机制,即责任链模式。
以 Tomcat 为例,我们先来看下它的 Filter 实现中最重要的类 ApplicationFilterChain。它采用的是责任职责链设计模式在形式上很像一种递归调用。
但区别在于递归调用是同一个对象把子任务交给同一个方法本身去完成,而**职责链则是一个对象把子任务交给其他对象的同名方法去完成**。其核心在于上下文 FilterChain 在不同对象 Filter 间的传递与状态的改变,通过这种链式串联,我们就可以对同一种对象资源实现不同业务场景的处理,达到业务解耦。整个 FilterChain 的结构就像这张图一样:
<img src="https://static001.geekbang.org/resource/image/77/de/7791d08ec8212d86ba4b4bc217bf35de.png" alt="">
这里我们不妨还是带着两个问题去理解 FilterChain
1. FilterChain 在何处被创建,又是在何处进行初始化调用,从而激活责任链开始链式调用?
1. FilterChain 为什么能够被链式调用,其内在的调用细节是什么?
接下来我们直接查看负责请求处理的 StandardWrapperValve#invoke(),快速解决第一个问题:
```
public final void invoke(Request request, Response response)
throws IOException, ServletException {
// 省略非关键代码
// 创建filterChain
ApplicationFilterChain filterChain =
ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
// 省略非关键代码
try {
if ((servlet != null) &amp;&amp; (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如何被创建如下所示
```
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 &lt; 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()
```
// 省略非关键代码
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()
```
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {
if( Globals.IS_SECURITY_ENABLED ) {
//省略非关键代码
internalDoFilter(request,response);
//省略非关键代码
} else {
internalDoFilter(request,response);
}
}
```
这里逻辑被委派到了当前类的私有方法 internalDoFilter具体实现如下
```
private void internalDoFilter(ServletRequest request,
ServletResponse response){
if (pos &lt; 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 &lt; n 意味着执行完所有的过滤器才能通过servlet.service(request, response) 去执行真正的业务。
执行完所有的过滤器后,代码调用了 servlet.service(request, response) 方法。从下面这张调用栈的截图中,可以看到,经历了一个很长的看似循环的调用栈,我们终于从 internalDoFilter() 执行到了Controller层的saveUser()。这个过程就不再一一细讲了。
<img src="https://static001.geekbang.org/resource/image/9c/f3/9ce67a5bec2dd4686b428ec88126fbf3.png" alt="">
分析了这么多,最后我们再来思考一下这个问题案例。
DemoFilter 代码中的 doFilter() 在捕获异常的部分执行了一次,随后在 try 外面又执行了一次因而当抛出异常的时候doFilter() 明显会被执行两次,相对应的 servlet.service(request, response) 方法以及对应的 Controller 处理方法也被执行了两次。
你不妨回过头再次查看上文中的过滤器执行流程图,相信你会有更多的收获。
### 问题修正
现在就剩下解决这个问题了。其实只需要删掉重复的 filterChain.doFilter(request, response) 就可以了,于是代码就变成了这样:
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
//模拟异常
System.out.println(&quot;Filter 处理中时发生异常&quot;);
throw new RuntimeException();
} catch (Exception e) {
//去掉下面这行调用
//chain.doFilter(request, response);
}
chain.doFilter(request, response);
}
}
```
重新运行程序和测试,结果符合预期,业务只执行了一次。回顾这个问题,我想你应该有所警示:在使用过滤器的时候,一定要注意,**不管怎么调用,不能多次调用 FilterChain#doFilter()**。
## 重点回顾
通过这节课的学习,相信你对过滤器已经有了一个较为深入的了解,这里我们不妨再次梳理下关键知识点:
1. @WebFilter 这种方式构建的 Filter 是无法直接根据过滤器定义类型来自动注入的因为这种Filter本身是以内部Bean来呈现的它最终是通过FilterRegistrationBean来呈现给Spring的。所以我们可以通过自动注入FilterRegistrationBean类型来完成装配工作示例如下
```
@Autowired
@Qualifier(&quot;com.spring.puzzle.filter.TimeCostFilter&quot;)
FilterRegistrationBean timeCostFilter;
```
1. 我们在过滤器的执行中一定要注意避免不要多次调用doFilter(),否则可能会出现业务代码执行多次的问题。这个问题出现的根源往往在于“不小心”,但是要理解这个问题呈现的现象,就必须对过滤器的流程有所了解。可以看过滤器执行的核心流程图:
<img src="https://static001.geekbang.org/resource/image/77/de/7791d08ec8212d86ba4b4bc217bf35de.png" alt="">
结合这个流程图,我们还可以进一步细化出以下关键步骤:
- 当一个请求来临时,会执行到 StandardWrapperValve的invoke(),这个方法会创建 ApplicationFilterChain并通过ApplicationFilterChain#doFilter() 触发过滤器执行;
- ApplicationFilterChain 的 doFilter() 会执行其私有方法 internalDoFilter
- 在 internalDoFilter 方法中获取下一个Filter并使用 request、response、this当前ApplicationFilterChain 实例)作为参数来调用 doFilter()
>
<p>public void doFilter(ServletRequest request, ServletResponse response,<br>
FilterChain chain) throws IOException, ServletException;</p>
- 在 Filter 类的 doFilter() 中执行Filter定义的动作并继续传递获取第三个参数 ApplicationFilterChain并执行其 doFilter()
- 此时会循环执行进入第 2 步、第 3 步、第 4 步直到第3步中所有的 Filter 类都被执行完毕为止;
- 所有的Filter过滤器都被执行完毕后会执行 servlet.service(request, response) 方法,最终调用对应的 Controller 层方法 。
以上即为过滤器执行的关键流程,希望你能牢牢记住。
## 思考题
在案例2中我们提到一定要避免在过滤器中调用多次FilterChain#doFilter()。那么假设一个过滤器因为疏忽,在某种情况下,这个方法一次也没有调用,会出现什么情况呢?
这样的过滤器可参考改造后的DemoFilter
```
@Component
public class DemoFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(&quot;do some logic&quot;);
}
}
```
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,598 @@
<audio id="audio" title="14 | Spring Web 过滤器使用常见错误(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/6f/666f5bfcd124f4c58fa619148679176f.mp3"></audio>
你好,我是傅健。
通过上节课的两个案例,我们了解了容器运行时过滤器的工作原理,那么这节课我们还是通过两个错误案例,来学习下容器启动时过滤器初始化以及排序注册等相关逻辑。了解了它们,你会对如何使用好过滤器更有信心。下面,我们具体来看一下。
## 案例1@WebFilter过滤器使用@Order无效
假设我们还是基于Spring Boot去开发上节课的学籍管理系统这里我们简单复习下上节课用到的代码。
首先,创建启动程序的代码如下:
```
@SpringBootApplication
@ServletComponentScan
@Slf4j
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
log.info(&quot;启动成功&quot;);
}
}
```
实现的Controller代码如下
```
@Controller
@Slf4j
public class StudentController {
@PostMapping(&quot;/regStudent/{name)}&quot;)
@ResponseBody
public String saveUser(String name) throws Exception {
System.out.println(&quot;......用户注册成功&quot;);
return &quot;success&quot;;
}
}
```
上述代码提供了一个 Restful 接口 "/regStudent"。该接口只有一个参数 name注册成功会返回"success"。
现在,我们来实现两个新的过滤器,代码如下:
AuthFilter例如限制特定IP地址段例如校园网内的用户方可注册为新用户当然这里我们仅仅Sleep 1秒来模拟这个过程。
```
@WebFilter
@Slf4j
@Order(2)
public class AuthFilter implements Filter {
@SneakyThrows
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
if(isPassAuth()){
System.out.println(&quot;通过授权&quot;);
chain.doFilter(request, response);
}else{
System.out.println(&quot;未通过授权&quot;);
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println(&quot;执行检查权限&quot;);
Thread.sleep(1000);
return true;
}
}
```
TimeCostFilter计算注册学生的执行耗时需要包括授权过程。
```
@WebFilter
@Slf4j
@Order(1)
public class TimeCostFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(&quot;#开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;#执行时间(ms)&quot; + time);
}
}
```
在上述代码中,我们使用了@Order期望TimeCostFilter先被执行因为TimeCostFilter设计的初衷是统计这个接口的性能所以是需要统计AuthFilter执行的授权过程的。
全部代码实现完毕,执行结果如下:
```
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms)33
```
从结果来看,执行时间并不包含授权过程,所以这并不符合我们的预期,毕竟我们是加了@Order的。但是如果我们交换Order指定的值你会发现也不见效果为什么会如此难道Order不能用来排序WebFilter么下面我们来具体解析下这个问题及其背后的原理。
### 案例解析
通过上节课的学习,我们得知:当一个请求来临时,会执行到 StandardWrapperValve 的 invoke(),这个方法会创建 ApplicationFilterChain并通过ApplicationFilterChain#doFilter() 触发过滤器执行并最终执行到内部私有方法internalDoFilter() 我们可以尝试在internalDoFilter()中寻找一些启示:
```
private void internalDoFilter(ServletRequest request,
ServletResponse response)
throws IOException, ServletException {
// Call the next filter if there is one
if (pos &lt; n) {
ApplicationFilterConfig filterConfig = filters[pos++];
try {
Filter filter = filterConfig.getFilter();
```
从上述代码我们得知过滤器的执行顺序是由类成员变量Filters决定的而Filters变量则是createFilterChain()在容器启动时顺序遍历StandardContext中的成员变量FilterMaps获得的
```
public static ApplicationFilterChain createFilterChain(ServletRequest request,
Wrapper wrapper, Servlet servlet) {
// 省略非关键代码
// Acquire the filter mappings for this Context
StandardContext context = (StandardContext) wrapper.getParent();
FilterMap filterMaps[] = context.findFilterMaps();
// 省略非关键代码
// Add the relevant path-mapped filters to this filter chain
for (int i = 0; i &lt; filterMaps.length; i++) {
if (!matchDispatcher(filterMaps[i] ,dispatcher)) {
continue;
}
if (!matchFiltersURL(filterMaps[i], requestPath))
continue;
ApplicationFilterConfig filterConfig = (ApplicationFilterConfig)
context.findFilterConfig(filterMaps[i].getFilterName());
if (filterConfig == null) {
continue;
}
filterChain.addFilter(filterConfig);
}
// 省略非关键代码
// Return the completed filter chain
return filterChain;
}
```
下面继续查找对StandardContext成员变量FilterMaps的写入引用我们找到了addFilterMapBefore()
```
public void addFilterMapBefore(FilterMap filterMap) {
validateFilterMap(filterMap);
// Add this filter mapping to our registered set
filterMaps.addBefore(filterMap);
fireContainerEvent(&quot;addFilterMap&quot;, filterMap);
}
```
到这我们已经知道过滤器的执行顺序是由StandardContext类成员变量FilterMaps的顺序决定而FilterMaps则是一个包装过的数组所以我们只要进一步弄清楚**FilterMaps中各元素的排列顺序**即可。
我们继续在addFilterMapBefore()中加入断点,尝试从调用栈中找到一些线索:
```
addFilterMapBefore:2992, StandardContext
addMappingForUrlPatterns:107, ApplicationFilterRegistration
configure:229, AbstractFilterRegistrationBean
configure:44, AbstractFilterRegistrationBean
register:113, DynamicRegistrationBean
onStartup:53, RegistrationBean
selfInitialize:228, ServletWebServerApplicationContext
// 省略非关键代码
```
可知Spring从selfInitialize()一直依次调用到addFilterMapBefore()稍微分析下selfInitialize()我们可以了解到这里是通过调用getServletContextInitializerBeans()获取所有的ServletContextInitializer类型的Bean并调用该Bean的onStartup(),从而一步步以调用栈显示的顺序,最终调用到 addFilterMapBefore()。
```
private void selfInitialize(ServletContext servletContext) throws ServletException {
prepareWebApplicationContext(servletContext);
registerApplicationScope(servletContext);
WebApplicationContextUtils.registerEnvironmentBeans(getBeanFactory(), servletContext);
for (ServletContextInitializer beans : getServletContextInitializerBeans()) {
beans.onStartup(servletContext);
}
}
```
那么上述的selfInitialize()又从何处调用过来呢?这里你可以先想想,我会在思考题中给你做进一步解释。
现在我们继续查看selfInitialize()的细节。
首先查看上述代码中的getServletContextInitializerBeans()因为此方法返回的ServletContextInitializer类型的Bean集合顺序决定了addFilterMapBefore()调用的顺序从而决定了FilterMaps内元素的顺序最终决定了过滤器的执行顺序。
getServletContextInitializerBeans()的实现非常简单只是返回了ServletContextInitializerBeans类的一个实例参考代码如下
```
protected Collection&lt;ServletContextInitializer&gt; getServletContextInitializerBeans() {
return new ServletContextInitializerBeans(getBeanFactory());
}
```
上述方法的返回值是个Collection可见ServletContextInitializerBeans类是一个集合类它继承了AbstractCollection抽象类。也因为如此上述selfInitialize()才可以遍历 ServletContextInitializerBeans的实例对象。
既然ServletContextInitializerBeans是集合类那么我们就可以先查看其iterator(),看看它遍历的是什么。
```
@Override
public Iterator&lt;ServletContextInitializer&gt; iterator() {
return this.sortedList.iterator();
}
```
此集合类对外暴露的集合遍历元素为sortedList成员变量也就是说上述selfInitialize()最终遍历的即为sortedList成员变量。
到这我们可以进一步确定下结论selfInitialize()中是通过getServletContextInitializerBeans()获取到的ServletContextInitializer类型的Beans集合即为ServletContextInitializerBeans的类型成员变量sortedList。反过来说**sortedList中的过滤器Bean元素顺序决定了最终过滤器的执行顺序**。
现在我们继续查看ServletContextInitializerBeans的构造方法如下
```
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
Class&lt;? extends ServletContextInitializer&gt;... initializerTypes) {
this.initializers = new LinkedMultiValueMap&lt;&gt;();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List&lt;ServletContextInitializer&gt; sortedInitializers = this.initializers.values().stream()
.flatMap((value) -&gt; value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
.collect(Collectors.toList());
this.sortedList = Collections.unmodifiableList(sortedInitializers);
logMappings(this.initializers);
}
```
通过第8行可以得知我们关心的类成员变量this.sortedList其元素顺序是由类成员变量this.initializers的values通过比较器AnnotationAwareOrderComparator进行排序的。
继续查看AnnotationAwareOrderComparator比较器忽略比较器调用的细节过程其最终是通过两种方式获取比较器需要的order值来决定sortedInitializers的排列顺序
- 待排序的对象元素自身实现了Order接口则直接通过getOrder()获取order值
- 否则执行OrderUtils.findOrder()获取该对象类@Order的属性
这里多解释一句因为this.initializers的values类型为ServletContextInitializer其实现了Ordered接口所以这里的比较器显然是使用了getOrder()获取比较器所需的order值对应的类成员变量即为order。
继续查看this.initializers中的元素在何处被添加我们最终得知addServletContextInitializerBeans()以及addAdaptableBeans()这两个方法均构建了ServletContextInitializer子类的实例并添加到了this.initializers成员变量中。在这里我们只研究addServletContextInitializerBeans毕竟我们使用的添加过滤器方式使用@WebFilter标记)最终只会通过这个方法生效。
在这个方法中Spring通过getOrderedBeansOfType()实例化了所有ServletContextInitializer的子类
```
private void addServletContextInitializerBeans(ListableBeanFactory beanFactory) {
for (Class&lt;? extends ServletContextInitializer&gt; initializerType : this.initializerTypes) {
for (Entry&lt;String, ? extends ServletContextInitializer&gt; initializerBean : getOrderedBeansOfType(beanFactory,
initializerType)) {
addServletContextInitializerBean(initializerBean.getKey(), initializerBean.getValue(), beanFactory);
}
}
}
```
根据其不同类型调用addServletContextInitializerBean()我们可以看出ServletContextInitializer的子类包括了ServletRegistrationBean、FilterRegistrationBean以及ServletListenerRegistrationBean正好对应了Servlet的三大要素。
而这里我们只需要关心对应于Filter的FilterRegistrationBean显然FilterRegistrationBean是ServletContextInitializer的子类实现了Ordered接口同样由**成员变量order的值决定其执行的优先级。**
```
private void addServletContextInitializerBean(String beanName, ServletContextInitializer initializer,
ListableBeanFactory beanFactory) {
if (initializer instanceof ServletRegistrationBean) {
Servlet source = ((ServletRegistrationBean&lt;?&gt;) initializer).getServlet();
addServletContextInitializerBean(Servlet.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof FilterRegistrationBean) {
Filter source = ((FilterRegistrationBean&lt;?&gt;) initializer).getFilter();
addServletContextInitializerBean(Filter.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof DelegatingFilterProxyRegistrationBean) {
String source = ((DelegatingFilterProxyRegistrationBean) initializer).getTargetBeanName();
addServletContextInitializerBean(Filter.class, beanName, initializer, beanFactory, source);
}
else if (initializer instanceof ServletListenerRegistrationBean) {
EventListener source = ((ServletListenerRegistrationBean&lt;?&gt;) initializer).getListener();
addServletContextInitializerBean(EventListener.class, beanName, initializer, beanFactory, source);
}
else {
addServletContextInitializerBean(ServletContextInitializer.class, beanName, initializer, beanFactory,
initializer);
}
}
```
最终添加到this.initializers成员变量中
```
private void addServletContextInitializerBean(Class&lt;?&gt; type, String beanName, ServletContextInitializer initializer,
ListableBeanFactory beanFactory, Object source) {
this.initializers.add(type, initializer);
// 省略非关键代码
}
```
通过上述代码我们再次看到了FilterRegistrationBean。但问题来了我们没有定义FilterRegistrationBean那么这里的FilterRegistrationBean是在哪里被定义的呢其order类成员变量是否有特定的取值逻辑
不妨回想下上节课的案例1它是在WebFilterHandler类的doHandle()动态构建了FilterRegistrationBean的BeanDefinition
```
class WebFilterHandler extends ServletComponentHandler {
WebFilterHandler() {
super(WebFilter.class);
}
@Override
public void doHandle(Map&lt;String, Object&gt; attributes, AnnotatedBeanDefinition beanDefinition,
BeanDefinitionRegistry registry) {
BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(FilterRegistrationBean.class);
builder.addPropertyValue(&quot;asyncSupported&quot;, attributes.get(&quot;asyncSupported&quot;));
builder.addPropertyValue(&quot;dispatcherTypes&quot;, extractDispatcherTypes(attributes));
builder.addPropertyValue(&quot;filter&quot;, beanDefinition);
builder.addPropertyValue(&quot;initParameters&quot;, extractInitParameters(attributes));
String name = determineName(attributes, beanDefinition);
builder.addPropertyValue(&quot;name&quot;, name);
builder.addPropertyValue(&quot;servletNames&quot;, attributes.get(&quot;servletNames&quot;));
builder.addPropertyValue(&quot;urlPatterns&quot;, extractUrlPatterns(attributes));
registry.registerBeanDefinition(name, builder.getBeanDefinition());
}
// 省略非关键代码
```
这里我再次贴出了WebFilterHandler中doHandle()的逻辑(即通过 BeanDefinitionBuilder动态构建了FilterRegistrationBean类型的BeanDefinition。然而遗憾的是**此处并没有设置order的值更没有根据@Order指定的值去设置。**
到这里我们终于看清楚了问题的本质,所有被@WebFilter注解的类最终都会在此处被包装为FilterRegistrationBean类的BeanDefinition。虽然FilterRegistrationBean也拥有Ordered接口但此处却并没有填充值因为这里所有的属性都是从@WebFilter对应的属性获取的,而@WebFilter本身没有指定可以辅助排序的属性
现在我们来总结下,过滤器的执行顺序是由下面这个串联决定的:
>
<p>RegistrationBean中order属性的值-&gt;<br>
ServletContextInitializerBeans类成员变量sortedList中元素的顺序-&gt;<br>
ServletWebServerApplicationContext 中selfInitialize()遍历FilterRegistrationBean的顺序-&gt;<br>
addFilterMapBefore()调用的顺序-&gt;<br>
filterMaps内元素的顺序-&gt;<br>
过滤器的执行顺序</p>
可见RegistrationBean中order属性的值最终可以决定过滤器的执行顺序。但是可惜的是当使用@WebFilter时构建的FilterRegistrationBean并没有依据@Order的值去设置order属性,所以@Order失效了
### 问题修正
现在我们理清了Spring启动Web服务之前的一些必要类的初始化流程同时也弄清楚了@Order和@WebFilter同时使用失效的原因,但这个问题想要解决却并非那么简单。
这里我先提供给你一个常见的做法即实现自己的FilterRegistrationBean来配置添加过滤器不再使用@WebFilter。具体代码如下:
```
@Configuration
public class FilterConfiguration {
@Bean
public FilterRegistrationBean authFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new AuthFilter());
registration.addUrlPatterns(&quot;/*&quot;);
registration.setOrder(2);
return registration;
}
@Bean
public FilterRegistrationBean timeCostFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new TimeCostFilter());
registration.addUrlPatterns(&quot;/*&quot;);
registration.setOrder(1);
return registration;
}
}
```
按照我们查看的源码中的逻辑虽然WebFilterHandler中doHandle()构建了FilterRegistrationBean类型的BeanDefinition但**没有设置order的值**。
所以在这里我们直接手工实例化了FilterRegistrationBean实例而且设置了其setOrder()。同时不要忘记去掉AuthFilter和TimeCostFilter类中的@WebFilter,这样问题就得以解决了。
## 案例2过滤器被多次执行
我们继续沿用上面的案例代码,要解决排序问题,可能有人就想了是不是有其他的解决方案呢?比如我们能否在两个过滤器中增加@Component,从而让@Order生效呢?代码如下。
AuthFilter
```
@WebFilter
@Slf4j
@Order(2)
@Component
public class AuthFilter implements Filter {
@SneakyThrows
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain){
if(isPassAuth()){
System.out.println(&quot;通过授权&quot;);
chain.doFilter(request, response);
}else{
System.out.println(&quot;未通过授权&quot;);
((HttpServletResponse)response).sendError(401);
}
}
private boolean isPassAuth() throws InterruptedException {
System.out.println(&quot;执行检查权限&quot;);
Thread.sleep(1000);
return true;
}
}
```
TimeCostFilter类如下
```
@WebFilter
@Slf4j
@Order(1)
@Component
public class TimeCostFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(&quot;#开始计算接口耗时&quot;);
long start = System.currentTimeMillis();
chain.doFilter(request, response);
long end = System.currentTimeMillis();
long time = end - start;
System.out.println(&quot;#执行时间(ms)&quot; + time);
}
}
```
最终执行结果如下:
```
#开始计算接口耗时
执行检查权限
通过授权
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms)73
#执行时间(ms)2075
```
更改 AuthFilter 类中的Order值为0继续测试得到结果如下
```
执行检查权限
通过授权
#开始计算接口耗时
执行检查权限
通过授权
#开始计算接口耗时
......用户注册成功
#执行时间(ms)96
#执行时间(ms)1100
```
显然通过Order的值我们已经可以随意调整Filter的执行顺序但是我们会惊奇地发现过滤器本身被执行了2次这明显不符合我们的预期那么如何理解这个现象呢
### 案例解析
从案例1中我们已经得知被@WebFilter的过滤器会在WebServletHandler类中被重新包装为FilterRegistrationBean类的BeanDefinition而并非是Filter类型。
而当我们在自定义过滤器中增加@Component时我们可以大胆猜测下理论上Spring会根据当前类再次包装一个新的过滤器因而doFIlter()被执行两次。因此看似奇怪的测试结果,也在情理之中了。
我们继续从源码中寻找真相继续查阅ServletContextInitializerBeans的构造方法如下
```
public ServletContextInitializerBeans(ListableBeanFactory beanFactory,
Class&lt;? extends ServletContextInitializer&gt;... initializerTypes) {
this.initializers = new LinkedMultiValueMap&lt;&gt;();
this.initializerTypes = (initializerTypes.length != 0) ? Arrays.asList(initializerTypes)
: Collections.singletonList(ServletContextInitializer.class);
addServletContextInitializerBeans(beanFactory);
addAdaptableBeans(beanFactory);
List&lt;ServletContextInitializer&gt; sortedInitializers = this.initializers.values().stream()
.flatMap((value) -&gt; value.stream().sorted(AnnotationAwareOrderComparator.INSTANCE))
.collect(Collectors.toList());
this.sortedList = Collections.unmodifiableList(sortedInitializers);
logMappings(this.initializers);
}
```
上一个案例中我们关注了addServletContextInitializerBeans()了解了它的作用是实例化并注册了所有FilterRegistrationBean类型的过滤器严格说是实例化并注册了所有的ServletRegistrationBean、FilterRegistrationBean以及ServletListenerRegistrationBean但这里我们只关注FilterRegistrationBean
而第7行的addAdaptableBeans()其作用则是实例化所有实现Filter接口的类严格说是实例化并注册了所有实现Servlet、Filter以及EventListener接口的类然后再逐一包装为FilterRegistrationBean。
之所以Spring能够直接实例化FilterRegistrationBean类型的过滤器这是因为
- WebFilterHandler相关类通过扫描@WebFilter动态构建了FilterRegistrationBean类型的BeanDefinition并注册到Spring
- 或者我们自己使用@Bean来显式实例化FilterRegistrationBean并注册到Spring如案例1中的解决方案。
但Filter类型的过滤器如何才能被Spring直接实例化呢相信你已经有答案了**任何通过@Component修饰的的类都可以自动注册到Spring且能被Spring直接实例化。**
现在我们直接查看addAdaptableBeans()其调用了addAsRegistrationBean()其beanType为Filter.class
```
protected void addAdaptableBeans(ListableBeanFactory beanFactory) {
// 省略非关键代码
addAsRegistrationBean(beanFactory, Filter.class, new FilterRegistrationBeanAdapter());
// 省略非关键代码
}
```
继续查看最终调用到的方法addAsRegistrationBean()
```
private &lt;T, B extends T&gt; void addAsRegistrationBean(ListableBeanFactory beanFactory, Class&lt;T&gt; type,
Class&lt;B&gt; beanType, RegistrationBeanAdapter&lt;T&gt; adapter) {
List&lt;Map.Entry&lt;String, B&gt;&gt; entries = getOrderedBeansOfType(beanFactory, beanType, this.seen);
for (Entry&lt;String, B&gt; entry : entries) {
String beanName = entry.getKey();
B bean = entry.getValue();
if (this.seen.add(bean)) {
// One that we haven't already seen
RegistrationBean registration = adapter.createRegistrationBean(beanName, bean, entries.size());
int order = getOrder(bean);
registration.setOrder(order);
this.initializers.add(type, registration);
if (logger.isTraceEnabled()) {
logger.trace(&quot;Created &quot; + type.getSimpleName() + &quot; initializer for bean '&quot; + beanName + &quot;'; order=&quot;
+ order + &quot;, resource=&quot; + getResourceDescription(beanName, beanFactory));
}
}
}
}
```
主要逻辑如下:
- 通过getOrderedBeansOfType()创建了所有 Filter 子类的实例即所有实现Filter接口且被@Component修饰的类
- 依次遍历这些Filter类实例并通过RegistrationBeanAdapter将这些类包装为RegistrationBean
- 获取Filter类实例的Order值并设置到包装类 RegistrationBean中
- 将RegistrationBean添加到this.initializers。
到这,我们了解到,当过滤器同时被@WebFilter和@Component修饰时会导致两个FilterRegistrationBean实例的产生。addServletContextInitializerBeans()和addAdaptableBeans()最终都会创建FilterRegistrationBean的实例但不同的是
- @WebFilter会让addServletContextInitializerBeans()实例化并注册所有动态生成的FilterRegistrationBean类型的过滤器
- @Component会让addAdaptableBeans()实例化所有实现Filter接口的类然后再逐一包装为FilterRegistrationBean类型的过滤器。
### 问题修正
解决这个问题提及的顺序问题自然可以继续参考案例1的问题修正部分。另外我们也可以去掉@WebFilter保留@Component的方式进行修改修改后的Filter示例如下
```
//@WebFilter
@Slf4j
@Order(1)
@Component
public class TimeCostFilter implements Filter {
//省略非关键代码
}
```
## 重点回顾
这节课我们分析了过滤器在Spring框架中注册、包装以及实例化的整个流程最后我们再次回顾下重点。
@WebFilter和@Component的相同点是
- 它们最终都被包装并实例化成为了FilterRegistrationBean
- 它们最终都是在 ServletContextInitializerBeans的构造器中开始被实例化。
@WebFilter和@Component的不同点是
-@WebFilter修饰的过滤器会被提前在BeanFactoryPostProcessors扩展点包装成FilterRegistrationBean类型的BeanDefinition然后在ServletContextInitializerBeans.addServletContextInitializerBeans() 进行实例化;而使用@Component修饰的过滤器类是在ServletContextInitializerBeans.addAdaptableBeans() 中被实例化成Filter类型后再包装为RegistrationBean类型。
-@WebFilter修饰的过滤器不会注入Order属性,但被@Component修饰的过滤器会在ServletContextInitializerBeans.addAdaptableBeans() 中注入Order属性。
## 思考题
这节课的两个案例它们都是在Tomcat容器启动时发生的但你了解Spring是如何整合Tomcat使其在启动时注册这些过滤器吗
期待你的思考,我们留言区见!

View File

@@ -0,0 +1,276 @@
<audio id="audio" title="导读5分钟轻松了解一个HTTP请求的处理过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/fa/c989d3797f4ff57a89611196c6a6fffa.mp3"></audio>
你好,我是傅健。
上一章节我们学习了自动注入、AOP 等 Spring 核心知识运用上的常见错误案例。然而,我们**使用 Spring 大多还是为了开发一个 Web 应用程序**所以从这节课开始我们将学习Spring Web 的常见错误案例。
在这之前,我想有必要先给你简单介绍一下 Spring Web 最核心的流程,这可以让我们后面的学习进展更加顺利一些。
那什么是 Spring Web 最核心的流程呢?无非就是一个 HTTP 请求的处理过程。这里我以 Spring Boot 的使用为例,以尽量简单的方式带你梳理下。
首先,回顾下我们是怎么添加一个 HTTP 接口的,示例如下:
```
@RestController
public class HelloWorldController {
@RequestMapping(path = &quot;hi&quot;, method = RequestMethod.GET)
public String hi(){
return &quot;helloworld&quot;;
};
}
```
这是我们最喜闻乐见的一个程序,但是对于很多程序员而言,其实完全不知道为什么这样就工作起来了。毕竟,不知道原理,它也能工作起来。
但是,假设你是一个严谨且有追求的人,你大概率是有好奇心去了解它的。而且相信我,这个问题面试也可能会问到。我们一起来看看它背后的故事。
其实仔细看这段程序,你会发现一些**关键的“元素”**
1. 请求的 Path: hi
1. 请求的方法Get
1. 对应方法的执行hi()
那么,假设让你自己去实现 HTTP 的请求处理,你可能会写出这样一段伪代码:
```
public class HttpRequestHandler{
Map&lt;RequestKey, Method&gt; mapper = new HashMap&lt;&gt;();
public Object handle(HttpRequest httpRequest){
RequestKey requestKey = getRequestKey(httpRequest);
Method method = this.mapper.getValue(requestKey);
Object[] args = resolveArgsAccordingToMethod(httpRequest, method);
return method.invoke(controllerObject, args);
};
}
```
那么现在需要哪些组件来完成一个请求的对应和执行呢?
1. 需要有一个地方(例如 Map去维护从 HTTP path/method 到具体执行方法的映射;
1. 当一个请求来临时,根据请求的关键信息来获取对应的需要执行的方法;
1. 根据方法定义解析出调用方法的参数值,然后通过反射调用方法,获取返回结果。
除此之外,你还需要一个东西,就是利用底层通信层来解析出你的 HTTP 请求。只有解析出请求了,才能知道 path/method 等信息,才有后续的执行,否则也是“巧妇难为无米之炊”了。
所以综合来看,你大体上需要这些过程才能完成一个请求的解析和处理。那么接下来我们就按照处理顺序分别看下 Spring Boot 是如何实现的,对应的一些关键实现又长什么样。
首先,解析 HTTP 请求。对于 Spring 而言它本身并不提供通信层的支持它是依赖于Tomcat、Jetty等容器来完成通信层的支持例如当我们引入Spring Boot时我们就间接依赖了Tomcat。依赖关系图如下
<img src="https://static001.geekbang.org/resource/image/bf/71/bf28efcd2d8dc920dddbe4dabaeefb71.png" alt="">
另外正是这种自由组合的关系让我们可以做到直接置换容器而不影响功能。例如我们可以通过下面的配置从默认的Tomcat切换到Jetty
```
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-web&lt;/artifactId&gt;
&lt;exclusions&gt;
&lt;exclusion&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-tomcat&lt;/artifactId&gt;
&lt;/exclusion&gt;
&lt;/exclusions&gt;-
&lt;/dependency&gt;
&lt;!-- Use Jetty instead --&gt;
&lt;dependency&gt;
&lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
&lt;artifactId&gt;spring-boot-starter-jetty&lt;/artifactId&gt;
&lt;/dependency&gt;
```
依赖了Tomcat后Spring Boot在启动的时候就会把Tomcat启动起来做好接收连接的准备。
关于Tomcat如何被启动你可以通过下面的调用栈来大致了解下它的过程
<img src="https://static001.geekbang.org/resource/image/45/44/456dc47793b0f99c9c2d193027f0ed44.png" alt="">
说白了就是调用下述代码行就会启动Tomcat
```
SpringApplication.run(Application.class, args);
```
那为什么使用的是Tomcat你可以看下面这个类或许就明白了
```
//org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryConfiguration
class ServletWebServerFactoryConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedTomcat {
@Bean
public TomcatServletWebServerFactory tomcatServletWebServerFactory(
//省略非关键代码
return factory;
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Servlet.class, Server.class, Loader.class, WebAppContext.class })
@ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
public static class EmbeddedJetty {
@Bean
public JettyServletWebServerFactory JettyServletWebServerFactory(
ObjectProvider&lt;JettyServerCustomizer&gt; serverCustomizers) {
//省略非关键代码
return factory;
}
}
//省略其他容器配置
}
```
前面我们默认依赖了Tomcat内嵌容器的JAR所以下面的条件会成立进而就依赖上了Tomcat
```
@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
```
有了Tomcat后当一个HTTP请求访问时会触发Tomcat底层提供的NIO通信来完成数据的接收这点我们可以从下面的代码org.apache.tomcat.util.net.NioEndpoint.Poller#run)中看出来:
```
@Override
public void run() {
while (true) {
//省略其他非关键代码
//轮询注册的兴趣事件
if (wakeupCounter.getAndSet(-1) &gt; 0) {
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
//省略其他非关键代码
Iterator&lt;SelectionKey&gt; iterator =
keyCount &gt; 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null &amp;&amp; iterator.hasNext()) {
SelectionKey sk = iterator.next();
NioSocketWrapper socketWrapper = (NioSocketWrapper)
//处理事件
processKey(sk, socketWrapper);
//省略其他非关键代码
}
//省略其他非关键代码
}
}
```
上述代码会完成请求事件的监听和处理最终在processKey中把请求事件丢入线程池去处理。请求事件的接收具体调用栈如下
<img src="https://static001.geekbang.org/resource/image/f4/e3/f4b3febfced888415038f4b7cccb2fe3.png" alt="">
线程池对这个请求的处理的调用栈如下:
<img src="https://static001.geekbang.org/resource/image/99/e0/99021847afb18bf522860cf2a42aa3e0.png" alt="">
在上述调用中最终会进入Spring Boot的处理核心即DispatcherServlet上述调用栈没有继续截取完整调用所以未显示。可以说DispatcherServlet是用来处理HTTP请求的中央调度入口程序为每一个 Web 请求映射一个请求的处理执行体API controller/method
我们可以看下它的核心是什么它本质上就是一种Servlet所以它是由下面的Servlet核心方法触发
>
javax.servlet.http.HttpServlet#service(javax.servlet.ServletRequest, javax.servlet.ServletResponse)
最终它执行到的是下面的doService(),这个方法完成了请求的分发和处理:
```
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
doDispatch(request, response);
}
```
我们可以看下它是如何分发和执行的:
```
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 省略其他非关键代码
// 1. 分发Determine handler for the current request.
HandlerExecutionChain mappedHandler = getHandler(processedRequest);
// 省略其他非关键代码
//Determine handler adapter for the current request.
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 省略其他非关键代码
// 2. 执行Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
// 省略其他非关键代码
}
```
在上述代码中,很明显有两个关键步骤:
**1. 分发,即根据请求寻找对应的执行方法**
寻找方法参考DispatcherServlet#getHandler具体的查找远比开始给出的Map查找来得复杂但是无非还是一个根据请求寻找候选执行方法的过程这里我们可以通过一个调试视图感受下这种对应关系
<img src="https://static001.geekbang.org/resource/image/58/dc/58f9b4c2ac68e8648f441381f1ff88dc.png" alt="">
这里的关键映射Map其实就是上述调试视图中的RequestMappingHandlerMapping。
**2. 执行,反射执行寻找到的执行方法**
这点可以参考下面的调试视图来验证这个结论参考代码org.springframework.web.method.support.InvocableHandlerMethod#doInvoke
<img src="https://static001.geekbang.org/resource/image/6d/94/6d83528c381441a11bfc111f0f645794.png" alt="">
最终我们是通过反射来调用执行方法的。
通过上面的梳理你应该基本了解了一个HTTP请求是如何执行的。但是你可能会产生这样一个疑惑Handler的映射是如何构建出来的呢
说白了核心关键就是RequestMappingHandlerMapping这个Bean的构建过程。
它的构建完成后会调用afterPropertiesSet来做一些额外的事这里我们可以先看下它的调用栈
<img src="https://static001.geekbang.org/resource/image/f1/16/f106c25aed5f62fce28d589390891b16.png" alt="">
其中关键的操作是AbstractHandlerMethodMapping#processCandidateBean方法
```
protected void processCandidateBean(String beanName) {
//省略非关键代码
if (beanType != null &amp;&amp; isHandler(beanType)) {
detectHandlerMethods(beanName);
}
}
```
isHandler(beanType)的实现参考以下关键代码:
```
@Override
protected boolean isHandler(Class&lt;?&gt; beanType) {
return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
}
```
这里你会发现判断的关键条件是是否标记了合适的注解Controller或者RequestMapping。只有标记了才能添加到Map信息。换言之Spring在构建RequestMappingHandlerMapping时会处理所有标记Controller和RequestMapping的注解然后解析它们构建出请求到处理的映射关系。
以上即为Spring Boot处理一个HTTP请求的核心过程无非就是绑定一个内嵌容器Tomcat/Jetty/其他来接收请求然后为请求寻找一个合适的方法最后反射执行它。当然这中间还会掺杂无数的细节不过这不重要抓住这个核心思想对你接下来理解Spring Web中各种类型的错误案例才是大有裨益的