# 17|答疑现场:Spring Web 篇思考题合集 你好,我是傅健。 欢迎来到第二次答疑现场,恭喜你,已经完成了三分之二的课程。到今天为止,我们已经解决了 38 个线上问题,不知道你在工作中有所应用了吗?老话说得好,“纸上得来终觉浅,绝知此事要躬行”。希望你能用行动把知识从“我的”变成“你的”。 闲话少叙,接下来我就开始逐一解答第二章的课后思考题了,有任何想法欢迎到留言区补充。 ## **[第9课](https://time.geekbang.org/column/article/373215)** 关于 URL 解析,其实还有许多让我们惊讶的地方,例如案例 2 的部分代码: ``` @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) 么? 针对这个测试,返回的结果其实是"xiaoming,hanmeimei"。这里我们可以追溯到请求参数的解析代码,参考 org.apache.tomcat.util.http.Parameters#addParameter: ``` public void addParameter( String key, String value ) throws IllegalStateException { //省略其他非关键代码 ArrayList values = paramHashValues.get(key); if (values == null) { values = new ArrayList<>(1); paramHashValues.put(key, values); } values.add(value); } ``` 可以看出当使用 [name=xiaoming&name=hanmeimei](http://localhost:8080/hi2?name=xiaoming&name=hanmeimei) 这种形式访问时,name 解析出的参数值是一个 ArrayList 集合,它包含了所有的值(此处为xiaoming和hanmeimei)。但是这个数组在最终是需要转化给我们的 String 类型的。转化执行可参考其对应转化器 ArrayToStringConverter 所做的转化,关键代码如下: ``` public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { return this.helperConverter.convert(Arrays.asList(ObjectUtils.toObjectArray(source)), sourceType, targetType); } ``` 其中 helperConverter 为 CollectionToStringConverter,它使用了 "," 作为分隔将集合转化为 String 类型,分隔符定义如下: ``` private static final String DELIMITER = ","; ``` 通过上述分析可知,对于参数解析,解析出的结果其实是一个数组,只是在最终转化时,可能因不同需求转化为不同的类型,从而呈现出不同的值,有时候反倒让我们很惊讶。分析了这么多,我们可以改下代码,测试下刚才的源码解析出的一些结论,代码修改如下: ``` @RequestMapping(path = "/hi2", method = RequestMethod.GET) public String hi2(@RequestParam("name") String[] name){ return Arrays.toString(name); }; ``` 这里我们将接收类型改为 String 数组,然后我们重新测试,会发现结果为 \[xiaoming, hanmeimei\],这就更好理解和接受了。 ## **[第10课](https://time.geekbang.org/column/article/373942)** 在案例 3 中,我们以 Content-Type 为例,提到在 Controller 层中随意自定义常用头有时候会失效。那么这个结论是不是普适呢?即在使用其他内置容器或者在其他开发框架下,是不是也会存在一样的问题? 实际上,答案是否定的。这里我们不妨修改下案例 3 的 pom.xml。修改的目标是让其不要使用默认的内嵌 Tomcat 容器,而是 Jetty 容器。具体修改示例如下: ``` org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-tomcat org.springframework.boot spring-boot-starter-jetty ``` 经过上面的修改后,我们再次运行测试程序,我们会发现 Content-Type 确实可以设置成我们想要的样子,具体如下: ![](https://static001.geekbang.org/resource/image/9e/08/9ec8c83f80f2a3c620869f1f84d6c308.png) 同样是执行 addHeader(),但是因为置换了容器,所以调用的方法实际是 Jetty 的方法,具体参考 org.eclipse.jetty.server.Response#addHeader: ``` public void addHeader(String name, String value) { //省略其他非关键代码 if (HttpHeader.CONTENT_TYPE.is(name)) { setContentType(value); return; } //省略其他非关键代码 _fields.add(name, value); } ``` 在上述代码中,setContentType() 最终是完成了 Header 的添加。这点和 Tomcat 完全不同。具体可参考其实现: ``` public void setContentType(String contentType) { //省略其他非关键代码 if (HttpGenerator.__STRICT || _mimeType == null) //添加CONTENT_TYPE _fields.put(HttpHeader.CONTENT_TYPE, _contentType); else { _contentType = _mimeType.asString(); _fields.put(_mimeType.getContentTypeField()); } } } ``` 再次对照案例 3 给出的部分代码,在这里,直接贴出关键一段(具体参考 AbstractMessageConverterMethodProcessor#writeWithMessageConverters): ``` MediaType selectedMediaType = null; MediaType contentType = outputMessage.getHeaders().getContentType(); boolean isContentTypePreset = contentType != null && contentType.isConcrete(); if (isContentTypePreset) { selectedMediaType = contentType; } else { //根据请求 Accept 头和注解指定的返回类型(RequestMapping#produces)协商用何种 MediaType. } //省略其他代码:else ``` 从上述代码可以看出,最终选择的 MediaType 已经不需要协商了,这是因为在Jetty容器中,Header 里面添加进了contentType,所以可以拿出来直接使用。而之前介绍的Tomcat容器没有把contentType添加进Header里,所以在上述代码中,它不能走入isContentTypePreset 为 true 的分支。此时,它只能根据请求 Accept 头和注解指定的返回类型等信息协商用何种 MediaType。 追根溯源,主要在于不同的容器对于 addHeader() 的实现不同。这里我们不妨再深入探讨下。首先,回顾我们案例 3 代码中的方法定义: ``` import javax.servlet.http.HttpServletResponse; public String hi3(HttpServletResponse httpServletResponse) ``` 虽然都是接口 HttpServletResponse,但是在 Jetty 容器下,会被装配成 org.eclipse.jetty.server.Response,而在 Tomcat 容器下,会被装配成 org.apache.catalina.connector.Response。所以调用的方法才会发生不同。 如何理解这个现象?容器是通信层,而 Spring Boot 在这其中只是中转,所以在 Spring Boot 中,HTTP Servlet Response 来源于最原始的通信层提供的对象,这样也就合理了。 通过这个思考题,我们可以看出:对于很多技术的使用,一些结论并不是一成不变的。可能只是换下容器,结论就会失效。所以,只有洞悉其原理,才能从根本上避免各种各样的麻烦,而不仅仅是凭借一些结论去“刻舟求剑”。 ## **[第11课](https://time.geekbang.org/column/article/374654)** 通过案例 1 的学习,我们知道直接基于 Spring MVC 而非 Spring Boot 时,是需要我们手工添加 JSON 依赖,才能解析出 JSON 的请求或者编码 JSON 响应,那么为什么基于 Spring Boot 就不需要这样做了呢? 实际上,当我们使用 Spring Boot 时,我们都会添加相关依赖项: ``` org.springframework.boot spring-boot-starter-web ``` 而这个依赖项会间接把 Jackson 添加进去,依赖关系参考下图: ![](https://static001.geekbang.org/resource/image/62/5e/622d8593721b8614154dc7aa61af115e.png) 后续 Jackson 编解码器的添加,和普通 Spring MVC 关键逻辑相同:都是判断相关类是否存在。不过这里可以稍微总结下,判断相关类是否存在有两种风格: 1. 直接使用反射来判断 例如前文介绍的关键语句: > ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", null) 2. 使用 @ConditionalOnClass 参考 JacksonHttpMessageConvertersConfiguration 的实现: ``` package org.springframework.boot.autoconfigure.http; @Configuration(proxyBeanMethods = false) class JacksonHttpMessageConvertersConfiguration { @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ObjectMapper.class) @ConditionalOnBean(ObjectMapper.class) @ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, havingValue = "jackson", matchIfMissing = true) static class MappingJackson2HttpMessageConverterConfiguration { @Bean @ConditionalOnMissingBean(value = MappingJackson2HttpMessageConverter.class) //省略部分非关键代码 MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) { return new MappingJackson2HttpMessageConverter(objectMapper); } } ``` 以上即为判断某个类是否存在的两种方法。 ## **[第12课](https://time.geekbang.org/column/article/375554)** 在上面的学籍管理系统中,我们还存在一个接口,负责根据学生的学号删除他的信息,代码如下: ``` @RequestMapping(path = "students/{id}", method = RequestMethod.DELETE) public void deleteStudent(@PathVariable("id") @Range(min = 1,max = 10000) String id){ log.info("delete student: {}",id); //省略业务代码 }; ``` 这个学生的编号是从请求的Path中获取的,而且它做了范围约束,必须在1到10000之间。那么你能找出负责解出 ID 的解析器(HandlerMethodArgumentResolver)是哪一种吗?校验又是如何触发的? 按照案例1的案例解析思路,我们可以轻松地找到负责解析ID值的解析器是PathVariableMethodArgumentResolver,它的匹配要求参考如下代码: ``` @Override public boolean supportsParameter(MethodParameter parameter) { if (!parameter.hasParameterAnnotation(PathVariable.class)) { return false; } if (Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) { PathVariable pathVariable = parameter.getParameterAnnotation(PathVariable.class); return (pathVariable != null && StringUtils.hasText(pathVariable.value())); } //要返回true,必须标记@PathVariable注解 return true; } ``` 查看上述代码,当String类型的方法参数ID标记@PathVariable时,它就能符合上PathVariableMethodArgumentResolver的匹配条件。 翻阅这个解析类的实现,我们很快就可以定位到具体的解析方法,但是当我们顺藤摸瓜去找Validation时,却无蛛丝马迹,这点完全不同于案例1中的解析器RequestResponseBodyMethodProcessor。那么它的校验到底是怎么触发的?你可以把这个问题当做课后作业去思考下,这里仅仅给出一个提示,实际上,对于这种直接标记在方法参数上的校验是通过AOP拦截来做校验的。 ## **[第13课](https://time.geekbang.org/column/article/376115)** 在案例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("do some logic"); } } ``` 对于这样的情况,如果不了解Filter的实现逻辑,我们可能觉得,它最终会执行到Controller层的业务逻辑,最多是忽略掉排序在这个过滤器之后的一些过滤器而已。但是实际上,结果要严重得多。 以我们的改造案例为例,我们执行HTTP请求添加用户返回是成功的: > POST [http://localhost:8080/regStudent/fujian](http://localhost:8080/regStudent/fujian) >   > HTTP/1.1 200 > Content-Length: 0 > Date: Tue, 13 Apr 2021 11:37:43 GMT > Keep-Alive: timeout=60 > Connection: keep-alive 但是实际上,我们的Controller层压根没有执行。这里给你解释下原因,还是贴出之前解析过的过滤器执行关键代码(ApplicationFilterChain#internalDoFilter): ``` private void internalDoFilter(ServletRequest request, ServletResponse response){ if (pos < n) { // pos会递增 ApplicationFilterConfig filterConfig = filters[pos++]; try { Filter filter = filterConfig.getFilter(); // 省略非关键代码 // 执行filter filter.doFilter(request, response, this); // 省略非关键代码 } // 省略非关键代码 return; } // 执行真正实际业务 servlet.service(request, response); } // 省略非关键代码 } ``` 当我们的过滤器DemoFilter被执行,而它没有在其内部调用FilterChain#doFilter时,我们会执行到上述代码中的return语句。这不仅导致后续过滤器执行不到,也会导致能执行业务的servlet.service(request, response)执行不了。此时,我们的Controller层逻辑并未执行就不稀奇了。 相反,正是因为每个过滤器都显式调用了FilterChain#doFilter,才有机会让最后一个过滤器在调用FilterChain#doFilter时,能看到 pos = n 这种情况。而这种情况下,return就走不到了,能走到的是业务逻辑(servlet.service(request, response))。 ## **[第14课](https://time.geekbang.org/column/article/377167)** 这节课的两个案例,它们都是在Tomcat容器启动时发生的,但你了解Spring是如何整合Tomcat,使其在启动时注册这些过滤器吗? 当我们调用下述关键代码行启动Spring时: ``` SpringApplication.run(Application.class, args); ``` 会创建一个具体的 ApplicationContext 实现,以ServletWebServerApplicationContext为例,它会调用onRefresh()来与Tomcat或Jetty等容器集成: ``` @Override protected void onRefresh() { super.onRefresh(); try { createWebServer(); } catch (Throwable ex) { throw new ApplicationContextException("Unable to start web server", ex); } } ``` 查看上述代码中的createWebServer()实现: ``` private void createWebServer() { WebServer webServer = this.webServer; ServletContext servletContext = getServletContext(); if (webServer == null && servletContext == null) { ServletWebServerFactory factory = getWebServerFactory(); this.webServer = factory.getWebServer(getSelfInitializer()); } // 省略非关键代码 } ``` 第6行,执行factory.getWebServer()会启动Tomcat,其中这个方法调用传递了参数getSelfInitializer(),它返回的是一个特殊格式回调方法this::selfInitialize用来添加Filter等,它是当Tomcat启动后才调用的。 ``` private void selfInitialize(ServletContext servletContext) throws ServletException { prepareWebApplicationContext(servletContext); registerApplicationScope(servletContext); WebApplicationContextUtils.registerEnvironmentBeans(getBeanFactory(), servletContext); for (ServletContextInitializer beans : getServletContextInitializerBeans()) { beans.onStartup(servletContext); } } ``` 那说了这么多,你可能对这个过程还不够清楚,这里我额外贴出了两段调用栈帮助你理解。 1. 启动Spring Boot时,启动Tomcat: ![](https://static001.geekbang.org/resource/image/c6/38/c6943e5093cc8c68f88decd2df235938.png) 2. Tomcat启动后回调selfInitialize: ![](https://static001.geekbang.org/resource/image/80/50/80975a6eea602239e90e73db4316c550.png) 相信通过上述调用栈,你能更清晰地理解Tomcat启动和Filter添加的时机了。 ## **[第15课](https://time.geekbang.org/column/article/378170)** 通过案例 1 的学习,我们知道在 Spring Boot 开启 Spring Security 时,访问需要授权的 API 会自动跳转到如下登录页面,你知道这个页面是如何产生的么? ![](https://static001.geekbang.org/resource/image/b9/dd/b9808555a78fb2447d7abbb1d67b91dd.png) 实际上,在 Spring Boot 启用 Spring Security 后,匿名访问一个需要授权的 API 接口时,我们会发现这个接口授权会失败,从而进行 302 跳转,跳转的关键代码可参考 ExceptionTranslationFilter 调用的 LoginUrlAuthenticationEntryPoint#commence 方法: ``` public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { //省略非关键代码 redirectUrl = buildRedirectUrlToLoginPage(request, response, authException); //省略非关键代码 redirectStrategy.sendRedirect(request, response, redirectUrl); } ``` 具体的跳转情况可参考 Chrome 的开发工具: ![](https://static001.geekbang.org/resource/image/f6/32/f6676902da707c3976838eb9e74a9f32.png) 在跳转后,新的请求最终看到的效果图是由下面的代码生产的 HTML 页面,参考 DefaultLoginPageGeneratingFilter#generateLoginPageHtml: ``` private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) { String errorMsg = "Invalid credentials"; //省略部分非关键代码 StringBuilder sb = new StringBuilder(); sb.append("\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " Please sign in\n" + " \n" + " \n" + " \n" + " \n" + "
\n"); //省略部分非关键代码 sb.append("
\n"); sb.append(""); return sb.toString(); } ``` 上即为登录页面的呈现过程,可以看出基本都是由各种 Filter 来完成的。 ## **第16课** 这节课的两个案例,在第一次发送请求的时候,会遍历对应的资源处理器和异常处理器,并注册到 DispatcherServlet 对应的类成员变量中,你知道它是如何被触发的吗? 实现了 FrameworkServlet 的 onRefresh() 接口,这个接口会在WebApplicationContext初始化时被回调: ``` public class DispatcherServlet extends FrameworkServlet { @Override protected void onRefresh(ApplicationContext context) { initStrategies(context); } /** * Initialize the strategy objects that this servlet uses. *

May be overridden in subclasses in order to initialize further strategy objects. */ protected void initStrategies(ApplicationContext context) { initMultipartResolver(context); initLocaleResolver(context); initThemeResolver(context); initHandlerMappings(context); initHandlerAdapters(context); initHandlerExceptionResolvers(context); initRequestToViewNameTranslator(context); initViewResolvers(context); initFlashMapManager(context); } } ``` 以上就是这次答疑的全部内容,我们下一章节再见!