From a3ceb3c92c62cafdd3329f04242ea9db1bf6175b Mon Sep 17 00:00:00 2001 From: qinzy Date: Fri, 31 May 2024 18:37:01 +0800 Subject: [PATCH] =?UTF-8?q?9,10=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...70\350\247\201\351\224\231\350\257\257.md" | 390 ++++++++++++++++++ ...70\350\247\201\351\224\231\350\257\257.md" | 312 ++++++++++++++ zh-cn/geek courses/spring/README.md | 4 + 3 files changed, 706 insertions(+) create mode 100644 "zh-cn/geek courses/spring/09 Spring Web URL \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" create mode 100644 "zh-cn/geek courses/spring/10 Spring Web Header \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" diff --git "a/zh-cn/geek courses/spring/09 Spring Web URL \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" "b/zh-cn/geek courses/spring/09 Spring Web URL \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" new file mode 100644 index 00000000..241d1b06 --- /dev/null +++ "b/zh-cn/geek courses/spring/09 Spring Web URL \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" @@ -0,0 +1,390 @@ +你好,我是傅健。 + +上一章节我们讲解了各式各样的错误案例,这些案例都是围绕 Spring 的核心功能展开的,例如依赖注入、AOP 等诸多方面。然而,从现实情况来看,在使用上,我们更多地是使用 Spring 来构建一个 Web 服务,所以从这节课开始,我们会重点解析在 Spring Web 开发中经常遇到的一些错误,帮助你规避这些问题。 + +不言而喻,这里说的 Web 服务就是指使用 HTTP 协议的服务。而对于 HTTP 请求,首先要处理的就是 URL,所以今天我们就先来介绍下,在 URL 的处理上,Spring 都有哪些经典的案例。闲话少叙,下面我们直接开始演示吧。 + +## 案例 1:当@PathVariable 遇到 / + +在解析一个 URL 时,我们经常会使用 @PathVariable 这个注解。例如我们会经常见到如下风格的代码: + +```less +@RestController @Slf4j public class HelloWorldController { @RequestMapping(path = "/hi1/{name}", method = RequestMethod.GET) public String hello1(@PathVariable("name") 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”?然而稍微敏锐点的程序员都会判定这个访问是会报错的,具体错误参考: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/da282ca3e0c942c6a124b3672d9962b1.jpg) + +如图所示,当 name 中含有/,这个接口不会为 name 获取任何值,而是直接报Not Found错误。当然这里的“找不到”并不是指name找不到,而是指服务于这个特殊请求的接口。 + +实际上,这里还存在另外一种错误,即当 name 的字符串以/结尾时,/会被自动去掉。例如我们访问 [http://localhost:8080/hi1/xiaoming/](http://localhost:8080/hi1/xiaoming/),Spring 并不会报错,而是返回xiaoming。 + +针对这两种类型的错误,应该如何理解并修正呢? + +### 案例解析 + +实际上,这两种错误都是 URL 匹配执行方法的相关问题,所以我们有必要先了解下 URL 匹配执行方法的大致过程。参考 AbstractHandlerMethodMapping#lookupHandlerMethod: + +```kotlin +@Nullable protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception { List matches = new ArrayList<>(); //尝试按照 URL 进行精准匹配 List 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 comparator = new MatchComparator(getMappingComparator(request)); matches.sort(comparator); Match bestMatch = matches.get(0); if (matches.size() > 1) { //处理多个匹配的情况 } //省略其他非关键代码 return bestMatch.handlerMethod; } else { //匹配不上,直接报错 return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request); } +``` + +大体分为这样几个基本步骤。 + +**1\. 根据 Path 进行精确匹配** + +这个步骤执行的代码语句是”this.mappingRegistry.getMappingsByUrl(lookupPath)“,实际上,它是查询 MappingRegistry#urlLookup,它的值可以用调试视图查看,如下图所示: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/e5228329c945474fa388b57f65e7c22f.jpg) + +查询 urlLookup 是一个精确匹配 Path 的过程。很明显,[http://localhost:8080/hi1/xiao/ming](http://localhost:8080/hi1/xiaoming) 的 lookupPath 是”/hi1/xiao/ming”,并不能得到任何精确匹配。这里需要补充的是,”/hi1/{name}“这种定义本身也没有出现在 urlLookup 中。 + +**2\. 假设 Path 没有精确匹配上,则执行模糊匹配** + +在步骤 1 匹配失败时,会根据请求来尝试模糊匹配,待匹配的匹配方法可参考下图: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/658f673383934de990aa676c755d9072.jpg) + +显然,”/hi1/{name}“这个匹配方法已经出现在待匹配候选中了。具体匹配过程可以参考方法 RequestMappingInfo#getMatchingCondition: + +```kotlin +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 来执行,判断的相关参数可参考以下调试视图: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/351194df4a6c441b86408010aea66437.jpg) + +但是当我们使用 [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 方法的部分关键代码: + +```kotlin +private String getMatchingPattern(String pattern, String lookupPath) { //省略其他非关键代码 if (this.pathMatcher.match(pattern, lookupPath)) { return pattern; } //尝试加一个/来匹配 if (this.useTrailingSlashMatch) { if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) { return pattern + "/"; } } return null; } +``` + +在这段代码中,AntPathMatcher 匹配不了”/hi1/xiaoming/“和”/hi1/{name}“,所以不会直接返回。进而,在 useTrailingSlashMatch 这个参数启用时(默认启用),会把 Pattern 结尾加上/再尝试匹配一次。如果能匹配上,在最终返回 Pattern 时就隐式自动加/。 + +很明显,我们的案例符合这种情况,等于说我们最终是用了”/hi1/{name}/“这个 Pattern,而不再是”/hi1/{name}“。所以自然 URL 解析 name 结果是去掉/的。 + +### 问题修正 + +针对这个案例,有了源码的剖析,我们可能会想到可以先用”\*\*“匹配上路径,等进入方法后再尝试去解析,这样就可以万无一失吧。具体修改代码如下: + +```typescript +@RequestMapping(path = "/hi1/**", method = RequestMethod.GET) public String hi1(HttpServletRequest request){ String requestURI = request.getRequestURI(); return requestURI.split("/hi1/")[1]; }; +``` + +但是这种修改方法还是存在漏洞,假设我们路径的 name 中刚好又含有”/hi1/“,则 split 后返回的值就并不是我们想要的。实际上,更合适的修订代码示例如下: + +```typescript +private AntPathMatcher antPathMatcher = new AntPathMatcher(); @RequestMapping(path = "/hi1/**", method = RequestMethod.GET) public String hi1(HttpServletRequest request){ String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); //matchPattern 即为"/hi1/**" 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 + +这种方式确实是可以的,本地测试也能通过。这里我还给出了完整的代码,你可以感受下这两者的区别。 + +```less +@RequestMapping(path = "/hi1", method = RequestMethod.GET) public String hi1(@RequestParam("name") String name){ return name; }; @RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestParam String name){ return name; }; +``` + +很明显,对于喜欢追究极致简洁的同学来说,这个酷炫的功能是一个福音。但当我们换一个项目时,有可能上线后就失效了,然后报错 500,提示匹配不上。 + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/76e5ba7f6d314bcba1bc7552bddaa35e.jpg) + +### 案例解析 + +要理解这个问题出现的原因,首先我们需要把这个问题复现出来。例如我们可以修改下 pom.xml 来关掉两个选项: + +```xml + org.apache.maven.plugins maven-compiler-plugin false false +``` + +上述配置显示关闭了 parameters 和 debug,这 2 个参数的作用你可以参考下面的表格: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/873d203454bb488fbf5ea1f768dd0d69.jpg) + +通过上述描述,我们可以看出这 2 个参数控制了一些 debug 信息是否加进 class 文件中。我们可以开启这两个参数来编译,然后使用下面的命令来查看信息: + +> javap -verbose HelloWorldController.class + +执行完命令后,我们会看到以下 class 信息: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/455c0aa051114134bfaa848a2ed20978.jpg) + +debug 参数开启的部分信息就是 LocalVaribleTable,而 paramters 参数开启的信息就是 MethodParameters。观察它们的信息,你会发现它们都含有参数名name。 + +如果你关闭这两个参数,则 name 这个名称自然就没有了。而这个方法本身在 @RequestParam 中又没有指定名称,那么 Spring 此时还能找到解析的方法么? + +答案是否定的,这里我们可以顺带说下 Spring 解析请求参数名称的过程,参考代码 AbstractNamedValueMethodArgumentResolver#updateNamedValueInfo: + +```typescript +private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { String name = info.name; if (info.name.isEmpty()) { name = parameter.getParameterName(); if (name == null) { throw new IllegalArgumentException( "Name for argument type [" + parameter.getNestedParameterType().getName() + "] not available, and parameter name information not found in class file either."); } } 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 文件中找到参数名了,这点可以从下面的调试试图中得到验证: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/d9288d7982c648e2816dc181cf985069.jpg) + +当参数名不存在,@RequestParam 也没有指明,自然就无法决定到底要用什么名称去获取请求参数,所以就会报本案例的错误。 + +### 问题修正 + +模拟出了问题是如何发生的,我们自然可以通过开启这两个参数让其工作起来。但是思考这两个参数的作用,很明显,它可以让我们的程序体积更小,所以很多项目都会青睐去关闭这两个参数。 + +为了以不变应万变,正确的修正方式是**必须显式在@RequestParam 中指定请求参数名**。具体修改如下: + +> @RequestParam(“name”) String name + +通过这个案例,我们可以看出:很多功能貌似可以永远工作,但是实际上,只是在特定的条件下而已。另外,这里再拓展下,IDE 都喜欢开启相关 debug 参数,所以 IDE 里运行的程序不见得对产线适应,例如针对 parameters 这个参数,IDEA 默认就开启了。 + +另外,本案例围绕的都是 @RequestParam,其实 @PathVarible 也有一样的问题。这里你要注意。 + +那么说到这里,我顺带提一个可能出现的小困惑:我们这里讨论的参数,和 @QueryParam、@PathParam 有什么区别?实际上,后者都是 JAX-RS 自身的注解,不需要额外导包。而 @RequestParam 和 @PathVariable 是 Spring 框架中的注解,需要额外导入依赖包。另外不同注解的参数也不完全一致。 + +## 案例 3:未考虑参数是否可选 + +在上面的案例中,我们提到了 @RequestParam 的使用。而对于它的使用,我们常常会遇到另外一个问题。当需要特别多的请求参数时,我们往往会忽略其中一些参数是否可选。例如存在类似这样的代码: + +```typescript +@RequestMapping(path = "/hi4", method = RequestMethod.GET) public String hi4(@RequestParam("name") String name, @RequestParam("address") String address){ return name + ":" + address; }; +``` + +在访问 [http://localhost:8080/hi4?name=xiaoming&address=beijing](http://localhost:8080/hi2?name=xiaoming&address=beijing) 时并不会出问题,但是一旦用户仅仅使用 name 做请求(即 [http://localhost:8080/hi4?name=xiaoming](http://localhost:8080/hi4?name=xiaoming) )时,则会直接报错如下: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/87b6a2663d5148bc8c316a93f38a009a.jpg) + +此时,返回错误码 400,提示请求格式错误:此处缺少 address 参数。 + +实际上,部分初学者即使面对这个错误,也会觉得惊讶,既然不存在 address,address 应该设置为 null,而不应该是直接报错不是么?接下来我们就分析下。 + +### 案例解析 + +要了解这个错误出现的根本原因,你就需要了解请求参数的发生位置。 + +实际上,这里我们也能按注解名(@RequestParam)来确定解析发生的位置是在 RequestParamMethodArgumentResolver 中。为什么是它? + +追根溯源,针对当前案例,当根据 URL 匹配上要执行的方法是 hi4 后,要反射调用它,必须解析出方法参数 name 和 address 才可以。而它们被 @RequestParam 注解修饰,所以解析器借助 RequestParamMethodArgumentResolver 就成了很自然的事情。 + +接下来我们看下 RequestParamMethodArgumentResolver 对参数解析的一些关键操作,参考其父类方法 AbstractNamedValueMethodArgumentResolver#resolveArgument: + +```java +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 && !nestedParameter.isOptional()) { handleMissingValue(namedValueInfo.name, nestedParameter, webRequest); } arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); } //省略后续代码:类型转化等工作 return arg; } +``` + +如代码所示,当缺少请求参数的时候,通常我们会按照以下几个步骤进行处理。 + +**1\. 查看 namedValueInfo 的默认值,如果存在则使用它** + +这个变量实际是通过下面的方法来获取的,参考 RequestParamMethodArgumentResolver#createNamedValueInfo: + +```java +@Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { RequestParam ann = parameter.getParameterAnnotation(RequestParam.class); return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo()); } +``` + +实际上就是 @RequestParam 的相关信息,我们调试下,就可以验证这个结论,具体如下图所示: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/e5c02e8275a6492b8bcefed577e07845.jpg) + +**2\. 在 @RequestParam 没有指明默认值时,会查看这个参数是否必须,如果必须,则按错误处理** + +判断参数是否必须的代码即为下述关键代码行: + +> namedValueInfo.required && !nestedParameter.isOptional() + +很明显,若要判定一个参数是否是必须的,需要同时满足两个条件:条件 1 是@RequestParam 指明了必须(即属性 required 为 true,实际上它也是默认值),条件 2 是要求 @RequestParam 标记的参数本身不是可选的。 + +我们可以通过 MethodParameter#isOptional 方法看下可选的具体含义: + +```csharp +public boolean isOptional() { return (getParameterType() == Optional.class || hasNullableAnnotation() || (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(getContainingClass()) && KotlinDelegate.isOptional(this))); } +``` + +在不使用 Kotlin 的情况下,所谓可选,就是参数的类型为 Optional,或者任何标记了注解名为 Nullable 且 RetentionPolicy 为 RUNTIM 的注解。 + +**3\. 如果不是必须,则按 null 去做具体处理** + +如果接受类型是 boolean,返回 false,如果是基本类型则直接报错,这里不做展开。 + +结合我们的案例,我们的参数符合步骤 2 中判定为必选的条件,所以最终会执行方法 AbstractNamedValueMethodArgumentResolver#handleMissingValue: + +```java +protected void handleMissingValue(String name, MethodParameter parameter) throws ServletException { throw new ServletRequestBindingException("Missing argument '" + name + "' for method parameter of type " + 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 的注解** + +修改代码如下: + +> [//org.springframework.lang.Nullable](https://org.springframework.lang.nullable/) 可以- [//edu.umd.cs.findbugs.annotations.Nullable](https://edu.umd.cs.findbugs.annotations.nullable/) 可以- @RequestParam(value = “address”) @Nullable String address + +**4\. 修改参数类型为 Optional** + +修改代码如下: + +> @RequestParam(value = “address”) Optional address + +从这些修正方法不难看出:假设你不学习源码,解决方法就可能只局限于一两种,但是深入源码后,解决方法就变得格外多了。这里要特别强调的是:**在Spring Web 中,默认情况下,请求参数是必选项。** + +## 案例 4:请求参数格式错误 + +当我们使用 Spring URL 相关的注解,会发现 Spring 是能够完成自动转化的。例如在下面的代码中,age 可以被直接定义为 int 这种基本类型(Integer 也可以),而不是必须是 String 类型。 + +```typescript +@RequestMapping(path = "/hi5", method = RequestMethod.GET) public String hi5(@RequestParam("name") String name, @RequestParam("age") int age){ return name + " is " + age + " years old"; }; +``` + +鉴于 Spring 的强大转化功能,我们断定 Spring 也支持日期类型的转化(也确实如此),于是我们可能会写出类似下面这样的代码: + +```typescript +@RequestMapping(path = "/hi6", method = RequestMethod.GET) public String hi6(@RequestParam("Date") Date date){ return "date is " + 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 并不能完成转化,而是报错如下: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/0b3885184edf4b5a986f8613169ceabd.jpg) + +此时,返回错误码 400,错误信息为”Failed to convert value of type ‘java.lang.String’ to required type ‘java.util.Date”。 + +如何理解这个案例?如果实现自动转化,我们又需要做什么? + +### 案例解析 + +不管是使用 @PathVarible 还是 @RequetParam,我们一般解析出的结果都是一个 String 或 String 数组。例如,使用 @RequetParam 解析的关键代码参考 RequestParamMethodArgumentResolver#resolveName 方法: + +```typescript +@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 的关键代码来验证: + +```java +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。它的转化过程参考下面的代码: + +```typescript +public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } Class sourceClass = sourceType.getType(); Class targetClass = targetType.getType(); //根据源类型去获取构建出目标类型的方法:可以是工厂方法(例如 valueOf、from 方法)也可以是构造器 Member member = getValidatedMember(targetClass, sourceClass); try { if (member instanceof Method) { //如果是工厂方法,通过反射创建目标实例 } else if (member instanceof Constructor) { //如果是构造器,通过反射创建实例 Constructor ctor = (Constructor) 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 构造器: + +```scss +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 方法的调试试图: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/63dcc9f1056c4dc38d3a06d9511ba6bf.jpg) + +这是适应于 String 到 Date 类型的转化器 AnnotationParserConverter 实例的构造过程,其需要的 annototationType 参数为 DateTimeFormat。 + +annototationType 的作用正是为了帮助判断是否能用这个转化器,这一点可以参考代码 AnnotationParserConverter#matches: + +```typescript +@Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { return targetType.hasAnnotation(this.annotationType); } +``` + +最终构建出来的转化器相关信息可以参考下图: + +![](09%20Spring%20Web%20URL%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/df679f0f62984769b2457455626cf616.jpg) + +图中构造出的转化器是可以用来转化 String 到 Date,但是它要求我们标记 @DateTimeFormat。很明显,我们的参数 Date 并没有标记这个注解,所以这里为了使用这个转化器,我们可以使用上它并提供合适的格式。这样就可以让原来不工作的 URL 工作起来,具体修改代码如下: + +```sql +@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date date +``` + +以上即为本案例的解决方案。除此之外,我们完全可以制定一个转化器来帮助我们完成转化,这里不再赘述。另外,通过这个案例,我们可以看出:尽管 Spring 给我们提供了很多内置的转化功能,但是我们一定要注意,格式是否符合对应的要求,否则代码就可能会失效。 + +## 重点回顾 + +通过这一讲的学习,我们了解到了在Spring解析URL中的一些常见错误及其背后的深层原因。这里再次回顾下重点: + +1. 当我们使用@PathVariable时,一定要注意传递的值是不是含有 / ; +2. 当我们使用@RequestParam、@PathVarible等注解时,一定要意识到一个问题,虽然下面这两种方式(以@RequestParam使用示例)都可以,但是后者在一些项目中并不能正常工作,因为很多产线的编译配置会去掉不是必须的调试信息。 + +```less +@RequestMapping(path = "/hi1", method = RequestMethod.GET) public String hi1(@RequestParam("name") String name){ return name; }; //方式2:没有显式指定RequestParam的“name”,这种方式有时候会不行 @RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestParam String name){ return name; }; +``` + +1. 任何一个参数,我们都需要考虑它是可选的还是必须的。同时,你一定要想到参数类型的定义到底能不能从请求中自动转化而来。Spring本身给我们内置了很多转化器,但是我们要以合适的方式使用上它。另外,Spring对很多类型的转化设计都很贴心,例如使用下面的注解就能解决自定义日期格式参数转化问题。 + +```sql +@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date date +``` + +希望这些核心知识点,能帮助你高效解析URL。 + +## 思考题 + +关于 URL 解析,其实还有许多让我们惊讶的地方,例如案例 2 的部分代码: + +```less +@RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestParam("name") String name){ return name; }; +``` + +在上述代码的应用中,我们可以使用 [http://localhost:8080/hi2?name=xiaoming&name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&name=hanmeimei) 来测试下,结果会返回什么呢?你猜会是[xiaoming&name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&name=hanmeimei) 么? + +我们留言区见! \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/10 Spring Web Header \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" "b/zh-cn/geek courses/spring/10 Spring Web Header \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" new file mode 100644 index 00000000..03cdbea6 --- /dev/null +++ "b/zh-cn/geek courses/spring/10 Spring Web Header \350\247\243\346\236\220\345\270\270\350\247\201\351\224\231\350\257\257.md" @@ -0,0 +1,312 @@ +你好,我是傅健,这节课我们来聊聊 Spring Web 开发中 Header 相关的常见错误案例。 + +在上节课,我们梳理了 URL 相关错误。实际上,对于一个 HTTP 请求而言,URL 固然重要,但是为了便于用户使用,URL 的长度有限,所能携带的信息也因此受到了制约。 + +如果想提供更多的信息,Header 往往是不二之举。不言而喻,Header 是介于 URL 和 Body 之外的第二大重要组成,它提供了更多的信息以及围绕这些信息的相关能力,例如Content-Type指定了我们的请求或者响应的内容类型,便于我们去做解码。虽然 Spring 对于 Header 的解析,大体流程和 URL 相同,但是 Header 本身具有自己的特点。例如,Header 不像 URL 只能出现在请求中。所以,Header 处理相关的错误和 URL 又不尽相同。接下来我们看看具体的案例。 + +在 Spring 中解析 Header 时,我们在多数场合中是直接按需解析的。例如,我们想使用一个名为myHeaderName的 Header,我们会书写代码如下: + +```less +@RequestMapping(path = "/hi", method = RequestMethod.GET) public String hi(@RequestHeader("myHeaderName") String name){ //省略 body 处理 }; +``` + +定义一个参数,标记上@RequestHeader,指定要解析的 Header 名即可。但是假设我们需要解析的 Header 很多时,按照上面的方式很明显会使得参数越来越多。在这种情况下,我们一般都会使用 Map 去把所有的 Header 都接收到,然后直接对 Map 进行处理。于是我们可能会写出下面的代码: + +```less +@RequestMapping(path = "/hi1", method = RequestMethod.GET) public String hi1(@RequestHeader() Map map){ return map.toString(); }; +``` + +粗略测试程序,你会发现一切都很好。而且上面的代码也符合针对接口编程的范式,即使用了 Map 这个接口类型。但是上面的接口定义在遇到下面的请求时,就会超出预期。请求如下: + +> GET [http://localhost:8080/hi1](http://localhost:8080/hi1)\- myheader: h1- myheader: h2 + +这里存在一个 Header 名为 myHeader,不过这个 Header 有两个值。此时我们执行请求,会发现返回的结果并不能将这两个值如数返回。结果示例如下: + +```perl +{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 + +而另外一种方式就是我们测试请求中的格式: + +> Key:value1- Key:value2 + +对于方式 1,我们使用 Map 接口自然不成问题。但是如果使用的是方式 2,我们就不能拿到所有的值。这里我们可以翻阅代码查下 Map 是如何接收到所有请求的。 + +对于一个 Header 的解析,主要有两种方式,分别实现在 RequestHeaderMethodArgumentResolver 和 RequestHeaderMapMethodArgumentResolver 中,它们都继承于 AbstractNamedValueMethodArgumentResolver,但是应用的场景不同,我们可以对比下它们的 supportsParameter(),来对比它们适合的场景: + +![](10%20Spring%20Web%20Header%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/0cbf5aa76e434cac8a00a95cfc52fe17.jpg) + +在上图中,左边是 RequestHeaderMapMethodArgumentResolver 的方法。通过比较可以发现,对于一个标记了 @RequestHeader 的参数,如果它的类型是 Map,则使用 RequestHeaderMapMethodArgumentResolver,否则一般使用的是 RequestHeaderMethodArgumentResolver。 + +在我们的案例中,很明显,参数类型定义为 Map,所以使用的自然是 RequestHeaderMapMethodArgumentResolver。接下来,我们继续查看它是如何解析 Header 的,关键代码参考 resolveArgument(): + +```typescript +@Override public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception { Class paramType = parameter.getParameterType(); if (MultiValueMap.class.isAssignableFrom(paramType)) { MultiValueMap result; if (HttpHeaders.class.isAssignableFrom(paramType)) { result = new HttpHeaders(); } else { result = new LinkedMultiValueMap<>(); } for (Iterator 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 result = new LinkedHashMap<>(); for (Iterator 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: + +```scss +public MessageBytes getValue(String name) { for (int i = 0; i < 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。**我们可以采用以下两种方式来修正这个问题: + +```less +//方式 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 有多个值的情况,从而让我们疏忽地用错了接收类型。 + +在 HTTP 协议中,Header 的名称是无所谓大小写的。在使用各种框架构建 Web 时,我们都会把这个事实铭记于心。我们可以验证下这个想法。例如,我们有一个 Web 服务接口如下: + +```less +@RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestHeader("MyHeader") String myHeader){ return myHeader; }; +``` + +然后,我们使用下面的请求来测试这个接口是可以获取到对应的值的: + +> GET [http://localhost:8080/hi2](http://localhost:8080/hi2)\- myheader: myheadervalue + +另外,结合案例1,我们知道可以使用 Map 来接收所有的 Header,那么这种方式下是否也可以忽略大小写呢?这里我们不妨使用下面的代码来比较下: + +```typescript +@RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader MultiValueMap map){ return myHeader + " compare with : " + map.get("MyHeader"); }; +``` + +再次运行之前的测试请求,我们得出下面的结果: + +> myheadervalue compare with : null + +综合来看,直接获取 Header 是可以忽略大小写的,但是如果从接收过来的 Map 中获取 Header 是不能忽略大小写的。稍微不注意,我们就很容易认为 Header 在任何情况下,都可以不区分大小写来获取值。 + +那么针对这个案例,如何去理解? + +### 案例解析 + +我们知道,对于”@RequestHeader(“MyHeader”) String myHeader”的定义,Spring 使用的是 RequestHeaderMethodArgumentResolver 来做解析。解析的方法参考 RequestHeaderMethodArgumentResolver#resolveName: + +```typescript +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: + +```perl +private void findNext() { next=null; for(; pos< 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: + +```csharp +private void findNext() { next=null; for(; pos< size; pos++ ) { next=headers.getName( pos ).toString(); for( int j=0; j(8, Locale.ENGLISH))); } +``` + +可以看出,它使用的是 LinkedCaseInsensitiveMap,而不是普通的 LinkedHashMap。所以这里是可以忽略大小写的,我们不妨这样修正: + +```typescript +@RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader HttpHeaders map){ return myHeader + " compare with : " + map.get("MyHeader"); }; +``` + +再运行下程序,结果已经符合我们的预期了: + +> myheadervalue compare with : \[myheadervalue\] + +通过这个案例,我们可以看出:**在实际使用时,虽然 HTTP 协议规范可以忽略大小写,但是不是所有框架提供的接口方法都是可以忽略大小写的。**这点你一定要注意! + +## 案例 3:试图在 Controller 中随意自定义 CONTENT\_TYPE 等 + +和开头我们提到的 Header 和 URL 不同,Header 可以出现在返回中。正因为如此,一些应用会试图去定制一些 Header 去处理。例如使用 Spring Boot 基于 Tomcat 内置容器的开发中,存在下面这样一段代码去设置两个 Header,其中一个是常用的 CONTENT\_TYPE,另外一个是自定义的,命名为 myHeader。 + +```typescript +@RequestMapping(path = "/hi3", method = RequestMethod.GET) public String hi3(HttpServletResponse httpServletResponse){ httpServletResponse.addHeader("myheader", "myheadervalue"); httpServletResponse.addHeader(HttpHeaders.CONTENT_TYPE, "application/json"); return "ok"; }; +``` + +运行程序测试下(访问 GET [http://localhost:8080/hi3](http://localhost:8080/hi3) ),我们会得到如下结果: + +> ## GET [http://localhost:8080/hi3](http://localhost:8080/hi3)\- +> +> HTTP/1.1 200- myheader: myheadervalue- Content-Type: text/plain;charset=UTF-8- Content-Length: 2- Date: Wed, 17 Mar 2021 08:59:56 GMT- Keep-Alive: timeout=60- Connection: keep-alive + +可以看到 myHeader 设置成功了,但是 Content-Type 并没有设置成我们想要的”application/json”,而是”text/plain;charset=UTF-8”。为什么会出现这种错误? + +### 案例解析 + +首先我们来看下在 Spring Boot 使用内嵌 Tomcat 容器时,尝试添加 Header 会执行哪些关键步骤。 + +第一步我们可以查看 org.apache.catalina.connector.Response#addHeader 方法,代码如下: + +```typescript +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,关键代码如下: + +```typescript +private boolean checkSpecialHeader(String name, String value) { if (name.equalsIgnoreCase("Content-Type")) { setContentType(value); return true; } return false; } +``` + +最终我们获取到的 Response 如下: + +![](10%20Spring%20Web%20Header%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/e24f8fde8cdd4dd08be4fc3f337c74d2.jpg) + +从上图可以看出,Headers 里并没有 Content-Type,而我们设置的 Content-Type 已经作为 coyoteResponse 成员的值了。当然也不意味着后面一定不会返回,我们可以继续跟踪后续执行。 + +在案例代码返回ok后,我们需要对返回结果进行处理,执行方法为RequestResponseBodyMethodProcessor#handleReturnValue,关键代码如下: + +```java +@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 返回** + +参考下面的关键代码: + +```java +//决策返回值是何种 MediaType MediaType selectedMediaType = null; MediaType contentType = outputMessage.getHeaders().getContentType(); boolean isContentTypePreset = contentType != null && contentType.isConcrete(); //如果 header 中有 contentType,则用其作为选择的 selectedMediaType。 if (isContentTypePreset) { selectedMediaType = contentType; } //没有,则根据“Accept”头、返回值等核算用哪一种 else { HttpServletRequest request = inputMessage.getServletRequest(); List acceptableTypes = getAcceptableMediaTypes(request); List producibleTypes = getProducibleMediaTypes(request, valueType, targetType); //省略其他非关键代码 List mediaTypesToUse = new ArrayList<>(); 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 信息后,即可去选择转化器并执行转化,关键代码如下: + +```java +for (HttpMessageConverter converter : this.messageConverters) { GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter) 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); } } //省略其他非关键代码 } } +``` + +如代码所示,即结合 targetType(String)、valueType(String)、selectedMediaType(MediaType#TEXT\_PLAIN)三个信息来决策可以使用哪种消息 Converter。常见候选 Converter 可以参考下图: + +![](10%20Spring%20Web%20Header%20%E8%A7%A3%E6%9E%90%E5%B8%B8%E8%A7%81%E9%94%99%E8%AF%AF/4730528ead9b4c92a29dfa3ef10bb648.jpg) + +最终,本案例选择的是 StringHttpMessageConverter,在最终调用父类方法 AbstractHttpMessageConverter#write 执行转化时,会尝试添加 Content-Type。具体代码参考 AbstractHttpMessageConverter#addDefaultHeaders: + +```java +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 头,约束返回类型** + +参考代码如下: + +```bash +GET http://localhost:8080/hi3 Accept:application/json +``` + +即带上 Accept 头,这样服务器在最终决定 MediaType 时,会选择 Accept 的值。具体执行可参考方法 AbstractMessageConverterMethodProcessor#getAcceptableMediaTypes。 + +**2\. 标记返回类型** + +主动显式指明类型,修改方法如下: + +```kotlin +@RequestMapping(path = "/hi3", method = RequestMethod.GET, produces = {"application/json"}) +``` + +即使用 produces 属性来指明即可。这样的方式影响的是可以返回的 Media 类型,一旦设置,下面的方法就可以只返回一个指明的类型了。参考 AbstractMessageConverterMethodProcessor#getProducibleMediaTypes: + +```typescript +protected List getProducibleMediaTypes( HttpServletRequest request, Class valueClass, @Nullable Type targetType) { Set mediaTypes = (Set) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE); if (!CollectionUtils.isEmpty(mediaTypes)) { return new ArrayList<>(mediaTypes); } //省略其他非关键代码 } +``` + +上述两种方式,一个修改了 getAcceptableMediaTypes 返回值,一个修改了 getProducibleMediaTypes,这样就可以控制最终协商的结果为 JSON 了。从而影响后续的执行结果。 + +不过这里需要额外注意的是,虽然我们最终结果返回的 Content-Type 头是 JSON 了,但是对于内容的加工,仍然采用的是 StringHttpMessageConverter,感兴趣的话你可以自己去研究下原因。 + +## 重点回顾 + +通过这节课的学习,我们了解到了在 Spring 解析Header中的一些常见错误及其背后的深层原因。这里带你回顾下重点: + +1. 要完整接收到所有的 Header,不能直接使用Map而应该使用MultiValueMap。常见的两种方式如下: + +```less +//方式 1 @RequestHeader() MultiValueMap map //方式 2:专用于Header的MultiValueMap子类型 @RequestHeader() HttpHeaders map +``` + +深究原因,Spring在底层解析Header时如果接收参数是Map,则当请求的Header是多Value时,只存下了其中一个Value。 + +1. 在 HTTP 协议规定中,Header 的名称是无所谓大小写的。但是这并不意味着所有能获取到Header的途径,最终得到的Header名称都是统一大小写的。 + +2. 不是所有的Header在响应中都能随意指定,虽然表面看起来能生效,但是最后返回给客户端的仍然不是你指定的值。例如,在Tomcat下,CONTENT\_TYPE这个Header就是这种情况。 + + +以上即为这一讲的核心知识点,希望你以后在解析Header时会更有信心。 + +## 思考题 + +在案例 3 中,我们以 Content-Type 为例,提到在 Controller 层中随意自定义常用头有时候会失效。那么这个结论是不是普适呢?即在使用其他内置容器或者在其他开发框架下,是不是也会存在一样的问题? + +期待你的思考,我们留言区见! \ No newline at end of file diff --git a/zh-cn/geek courses/spring/README.md b/zh-cn/geek courses/spring/README.md index d6aa0905..df77094c 100644 --- a/zh-cn/geek courses/spring/README.md +++ b/zh-cn/geek courses/spring/README.md @@ -16,3 +16,7 @@ [08 答疑现场:Spring Core 篇思考题合集]() +[09 Spring Web URL 解析常见错误]() + +[10 Spring Web Header 解析常见错误]() +