diff --git "a/zh-cn/geek courses/spring/23 \347\255\224\347\226\221\347\216\260\345\234\272\357\274\232Spring \350\241\245\345\205\205\347\257\207\346\200\235\350\200\203\351\242\230\345\220\210\351\233\206.md" "b/zh-cn/geek courses/spring/23 \347\255\224\347\226\221\347\216\260\345\234\272\357\274\232Spring \350\241\245\345\205\205\347\257\207\346\200\235\350\200\203\351\242\230\345\220\210\351\233\206.md" new file mode 100644 index 00000000..dcc6633d --- /dev/null +++ "b/zh-cn/geek courses/spring/23 \347\255\224\347\226\221\347\216\260\345\234\272\357\274\232Spring \350\241\245\345\205\205\347\257\207\346\200\235\350\200\203\351\242\230\345\220\210\351\233\206.md" @@ -0,0 +1,151 @@ +你好,我是傅健。 + +欢迎来到第三次答疑现场,恭喜你,到了这,终点已近在咫尺。到今天为止,我们已经解决了 50 个线上问题,是不是很有成就感了?但要想把学习所得真正为你所用还要努力练习呀,这就像理论与实践之间永远有道鸿沟需要我们去跨越一样。那么接下来,话不多说,我们就开始逐一解答第三章的课后思考题了,有任何想法欢迎到留言区补充。 + +## **[第18课](https://time.geekbang.org/column/article/380565)** + +在案例 1 中使用 Spring Data Redis 时,我们提到了 StringRedisTemplate 和 RedisTemplate。那么它们是如何被创建起来的呢? + +实际上,当我们依赖 spring-boot-starter 时,我们就间接依赖了 spring-boot -autoconfigure。 + +![](23%20%E7%AD%94%E7%96%91%E7%8E%B0%E5%9C%BA%EF%BC%9ASpring%20%E8%A1%A5%E5%85%85%E7%AF%87%E6%80%9D%E8%80%83%E9%A2%98%E5%90%88%E9%9B%86/2f0d2b7f22254ef7b191ece3c6545084.jpg) + +在这个 JAR 中,存在下面这样的一个类,即 RedisAutoConfiguration。 + +```java +@Configuration(proxyBeanMethods = false) @ConditionalOnClass(RedisOperations.class) @EnableConfigurationProperties(RedisProperties.class) @Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class }) public class RedisAutoConfiguration { @Bean @ConditionalOnMissingBean(name = "redisTemplate") @ConditionalOnSingleCandidate(RedisConnectionFactory.class) public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); return template; } @Bean @ConditionalOnMissingBean @ConditionalOnSingleCandidate(RedisConnectionFactory.class) public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; } } +``` + +从上述代码可以看出,当存在RedisOperations这个类时,就会创建 StringRedisTemplate 和 RedisTemplate 这两个 Bean。顺便说句,这个 RedisOperations 是位于 Spring Data Redis 这个 JAR 中。 + +再回到开头,RedisAutoConfiguration 是如何被发现的呢?实际上,它被配置在 + +spring-boot-autoconfigure 的 META-INF/spring.factories 中,示例如下: + +```kotlin +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration,\ org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\ org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,\ org.springframework.boot.autoconfigure.data.r2dbc.R2dbcRepositoriesAutoConfiguration,\ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\ +``` + +那么它是如何被加载进去的呢?我们的应用启动程序标记了@SpringBootApplication,这个注解继承了下面这个注解: + +```less +//省略其他非关键代码 @Import(AutoConfigurationImportSelector.class) public @interface EnableAutoConfiguration { //省略其他非关键代码 } +``` + +当它使用了 AutoConfigurationImportSelector 这个类,这个类就会导入在META-INF/spring.factories定义的 RedisAutoConfiguration。那么 import 动作是什么时候执行的呢?实际上是在启动应用程序时触发的,调用堆栈信息如下: + +![](23%20%E7%AD%94%E7%96%91%E7%8E%B0%E5%9C%BA%EF%BC%9ASpring%20%E8%A1%A5%E5%85%85%E7%AF%87%E6%80%9D%E8%80%83%E9%A2%98%E5%90%88%E9%9B%86/8148a15e5a3d4342b364fffd1b9e5e45.jpg) + +结合上面的堆栈和相关源码,我们不妨可以总结下 RedisTemplate 被创建的过程。 + +当 Spring 启动时,会通过 ConfigurationClassPostProcessor 尝试处理所有标记@Configuration 的类,具体到每个配置类的处理是通过 ConfigurationClassParser 来完成的。 + +在这个完成过程中,它会使用 ConfigurationClassParser.DeferredImportSelectorHandler 来完成对 Import 的处理。AutoConfigurationImportSelector 就是其中一种Import,它被 @EnableAutoConfiguration 这个注解间接引用。它会加载”META-INF/spring.factories”中定义的 RedisAutoConfiguration,此时我们就会发现 StringRedisTemplate 和 RedisTemplate 这两个 Bean 了。 + +## **[第19课](https://time.geekbang.org/column/article/381193)** + +RuntimeException 是 Exception 的子类,如果用 rollbackFor=Exception.class,那对 RuntimeException 也会生效。如果我们需要对 Exception 执行回滚操作,但对于 RuntimeException 不执行回滚操作,应该怎么做呢? + +我们可以同时为 @Transactional 指定rollbackFor 和noRollbackFor 属性,具体代码示例如下: + +```php +@Transactional(rollbackFor = Exception.class, noRollbackFor = RuntimeException.class) public void doSaveStudent(Student student) throws Exception { studentMapper.saveStudent(student); if (student.getRealname().equals("小明")) { throw new RuntimeException("该用户已存在"); } } +``` + +## **[第20课](https://time.geekbang.org/column/article/382150)** + +结合案例2,请你思考这样一个问题:在这个案例中,我们在 CardService类方法上声明了这样的事务传播属性,@Transactional(propagation = Propagation.REQUIRES\_NEW),如果使用 Spring 的默认声明行不行,为什么? + +答案是不行。我们前面说过,Spring 默认的事务传播类型是 REQUIRED,在有外部事务的情况下,内部事务则会加入原有的事务。如果我们声明成 REQUIRED,当我们要操作 card 数据的时候,持有的依然还会是原来的 DataSource。 + +## **[第21课](https://time.geekbang.org/column/article/382710)** + +当我们比较案例 1 和案例 2,你会发现不管使用的是查询(Query)参数还是表单(Form)参数,我们的接口定义并没有什么变化,风格如下: + +```typescript +@RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(@RequestParam("para1") String para1){ return "helloworld:" + para1; }; } +``` + +那是不是 @RequestParam 本身就能处理这两种数据呢? + +不考虑实现原理,如果我们仔细看下 @RequestParam 的 API 文档,你就会发现@RequestParam 不仅能处理表单参数,也能处理查询参数。API 文档如下: + +> In Spring MVC, “request parameters” map to query parameters, form data, and parts in multipart requests. This is because the Servlet API combines query parameters and form data into a single map called “parameters”, and that includes automatic parsing of the request body. + +稍微深入一点的话,我们还可以从源码上看看具体实现。 + +不管是使用 Query 参数还是用 Form 参数来访问,对于案例程序而言,解析的关键逻辑都是类似的,都是通过下面的调用栈完成参数的解析: + +![](23%20%E7%AD%94%E7%96%91%E7%8E%B0%E5%9C%BA%EF%BC%9ASpring%20%E8%A1%A5%E5%85%85%E7%AF%87%E6%80%9D%E8%80%83%E9%A2%98%E5%90%88%E9%9B%86/8713edfa6bf14ee9989d05de1383716f.jpg) + +这里可以看出,负责解析的都是 RequestParamMethodArgumentResolver,解析最后的调用也都是一样的方法。在 org.apache.catalina.connector.Request#parseParameters 这个方法中,对于 From 的解析是这样的: + +```go +if (!("application/x-www-form-urlencoded".equals(contentType))) { success = true; return; } //走到这里,说明是 Form: "application/x-www-form-urlencoded" int len = getContentLength(); if (len > 0) { int maxPostSize = connector.getMaxPostSize(); if ((maxPostSize >= 0) && (len > maxPostSize)) { //省略非关键代码 } byte[] formData = null; if (len < CACHED_POST_LEN) { if (postData == null) { postData = new byte[CACHED_POST_LEN]; } formData = postData; } else { formData = new byte[len]; } try { if (readPostBody(formData, len) != len) { parameters.setParseFailedReason(FailReason.REQUEST_BODY_INCOMPLETE); return; } } catch (IOException e) { //省略非关键代码 } //把 Form 数据添加到 parameter 里面去 parameters.processParameters(formData, 0, len); +``` + +Form 的数据最终存储在 Parameters#paramHashValues 中。 + +而对于查询参数的处理,同样是在 org.apache.catalina.connector.Request#parseParameters 中,不过处理它的代码行在 Form 前面一些,关键调用代码行如下: + +```scss +parameters.handleQueryParameters(); +``` + +最终它也是通过 org.apache.tomcat.util.http.Parameters#processParameters 来完成数据的添加。自然,它存储的位置也是 Parameters#paramHashValues 中。 + +综上可知,虽然使用的是一个固定的注解 @RequestParam,但是它能处理表单和查询参数,因为它们都会存储在同一个位置:Parameters#paramHashValues。 + +## **[第22课](https://time.geekbang.org/column/article/383756)** + +在案例 1 中,我们解释了为什么测试程序加载不到 spring.xml 文件,根源在于当使用下面的语句加载文件时,它们是采用不同的 Resource 形式来加载的: + +```kotlin +@ImportResource(locations = {"spring.xml"}) +``` + +具体而言,应用程序加载使用的是 ClassPathResource,测试加载使用的是 ServletContextResource,那么这是怎么造成的呢? + +实际上,以何种类型的Resource加载是由 DefaultResourceLoader#getResource 来决定的: + +```typescript +@Override public Resource getResource(String location) { //省略非关键代码 if (location.startsWith("/")) { return getResourceByPath(location); } else if (location.startsWith(CLASSPATH_URL_PREFIX)) { return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader()); } else { try { // Try to parse the location as a URL... URL url = new URL(location); return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url)); } catch (MalformedURLException ex) { // No URL -> resolve as resource path. return getResourceByPath(location); } } } +``` + +结合上述代码,你可以看出,当使用下面语句时: + +```kotlin +@ImportResource(locations = {"classpath:spring.xml"}) +``` + +走入的分支是: + +```scss +//CLASSPATH_URL_PREFIX:classpath else if (location.startsWith(CLASSPATH_URL_PREFIX)) { return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader()); } +``` + +即创建的是 ClassPathResource。 + +而当使用下面语句时: + +```kotlin +@ImportResource(locations = {"spring.xml"}) +``` + +走入的分支是: + +```java +try { // 按 URL 加载 URL url = new URL(location); return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url)); } catch (MalformedURLException ex) { // 按路径加载 return getResourceByPath(location); } +``` + +先尝试按 URL 加载,很明显这里会失败,因为字符串spring.xml并非一个 URL。随后使用 getResourceByPath()来加载,它会执行到下面的 WebApplicationContextResourceLoader#getResourceByPath(): + +```java +private static class WebApplicationContextResourceLoader extends ClassLoaderFilesResourcePatternResolver.ApplicationContextResourceLoader { private final WebApplicationContext applicationContext; //省略非关键代码 protected Resource getResourceByPath(String path) { return (Resource)(this.applicationContext.getServletContext() != null ? new ServletContextResource(this.applicationContext.getServletContext(), path) : super.getResourceByPath(path)); } } +``` + +可以看出,这个时候其实已经和 ApplicationContext 息息相关了。在我们的案例中,最终返回的是 ServletContextResource。 + +相信看到这里,你就能明白为什么一个小小的改动会导致生成的Resource不同了。无非还是因为你定义了不同的格式,不同的格式创建的资源不同,加载逻辑也不同。至于后续是如何加载的,你可以回看全文。 + +以上就是这次答疑的全部内容,我们下节课再见! \ 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 37ab8fa1..43fc8a04 100644 --- a/zh-cn/geek courses/spring/README.md +++ b/zh-cn/geek courses/spring/README.md @@ -16,6 +16,8 @@ [08 答疑现场:Spring Core 篇思考题合集]() +[导读 5分钟轻松了解一个HTTP请求的处理过程]() + [09 Spring Web URL 解析常见错误]() [10 Spring Web Header 解析常见错误]() @@ -42,4 +44,10 @@ [21 Spring Rest Template 常见错误]() -[22 Spring Test 常见错误]() \ No newline at end of file +[22 Spring Test 常见错误]() + +[23 答疑现场:Spring 补充篇思考题合集]() + +[知识回顾 系统梳理Spring编程错误根源]() + +[结束语 问题总比解决办法多]() \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/\345\257\274\350\257\273 5\345\210\206\351\222\237\350\275\273\346\235\276\344\272\206\350\247\243\344\270\200\344\270\252HTTP\350\257\267\346\261\202\347\232\204\345\244\204\347\220\206\350\277\207\347\250\213.md" "b/zh-cn/geek courses/spring/\345\257\274\350\257\273 5\345\210\206\351\222\237\350\275\273\346\235\276\344\272\206\350\247\243\344\270\200\344\270\252HTTP\350\257\267\346\261\202\347\232\204\345\244\204\347\220\206\350\277\207\347\250\213.md" new file mode 100644 index 00000000..2e18c0f8 --- /dev/null +++ "b/zh-cn/geek courses/spring/\345\257\274\350\257\273 5\345\210\206\351\222\237\350\275\273\346\235\276\344\272\206\350\247\243\344\270\200\344\270\252HTTP\350\257\267\346\261\202\347\232\204\345\244\204\347\220\206\350\277\207\347\250\213.md" @@ -0,0 +1,147 @@ +你好,我是傅健。 + +上一章节我们学习了自动注入、AOP 等 Spring 核心知识运用上的常见错误案例。然而,我们**使用 Spring 大多还是为了开发一个 Web 应用程序**,所以从这节课开始,我们将学习Spring Web 的常见错误案例。 + +在这之前,我想有必要先给你简单介绍一下 Spring Web 最核心的流程,这可以让我们后面的学习进展更加顺利一些。 + +那什么是 Spring Web 最核心的流程呢?无非就是一个 HTTP 请求的处理过程。这里我以 Spring Boot 的使用为例,以尽量简单的方式带你梳理下。 + +首先,回顾下我们是怎么添加一个 HTTP 接口的,示例如下: + +```typescript +@RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(){ return "helloworld"; }; } +``` + +这是我们最喜闻乐见的一个程序,但是对于很多程序员而言,其实完全不知道为什么这样就工作起来了。毕竟,不知道原理,它也能工作起来。 + +但是,假设你是一个严谨且有追求的人,你大概率是有好奇心去了解它的。而且相信我,这个问题面试也可能会问到。我们一起来看看它背后的故事。 + +其实仔细看这段程序,你会发现一些**关键的“元素”**: + +1. 请求的 Path: hi +2. 请求的方法:Get +3. 对应方法的执行:hi() + +那么,假设让你自己去实现 HTTP 的请求处理,你可能会写出这样一段伪代码: + +```typescript +public class HttpRequestHandler{ Map mapper = new HashMap<>(); 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 到具体执行方法的映射; +2. 当一个请求来临时,根据请求的关键信息来获取对应的需要执行的方法; +3. 根据方法定义解析出调用方法的参数值,然后通过反射调用方法,获取返回结果。 + +除此之外,你还需要一个东西,就是利用底层通信层来解析出你的 HTTP 请求。只有解析出请求了,才能知道 path/method 等信息,才有后续的执行,否则也是“巧妇难为无米之炊”了。 + +所以综合来看,你大体上需要这些过程才能完成一个请求的解析和处理。那么接下来我们就按照处理顺序分别看下 Spring Boot 是如何实现的,对应的一些关键实现又长什么样。 + +首先,解析 HTTP 请求。对于 Spring 而言,它本身并不提供通信层的支持,它是依赖于Tomcat、Jetty等容器来完成通信层的支持,例如当我们引入Spring Boot时,我们就间接依赖了Tomcat。依赖关系图如下: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/87f83fe678694b96afb78d62b461ba25.jpg) + +另外,正是这种自由组合的关系,让我们可以做到直接置换容器而不影响功能。例如我们可以通过下面的配置从默认的Tomcat切换到Jetty: + +```xml + org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-tomcat - org.springframework.boot spring-boot-starter-jetty +``` + +依赖了Tomcat后,Spring Boot在启动的时候,就会把Tomcat启动起来做好接收连接的准备。 + +关于Tomcat如何被启动,你可以通过下面的调用栈来大致了解下它的过程: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/4b8b3a9eec9046149c4f6c8481a99de6.jpg) + +说白了,就是调用下述代码行就会启动Tomcat: + +```cpp +SpringApplication.run(Application.class, args); +``` + +那为什么使用的是Tomcat?你可以看下面这个类,或许就明白了: + +```less +//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 serverCustomizers) { //省略非关键代码 return factory; } } //省略其他容器配置 } +``` + +前面我们默认依赖了Tomcat内嵌容器的JAR,所以下面的条件会成立,进而就依赖上了Tomcat: + +```python +@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class }) +``` + +有了Tomcat后,当一个HTTP请求访问时,会触发Tomcat底层提供的NIO通信来完成数据的接收,这点我们可以从下面的代码(org.apache.tomcat.util.net.NioEndpoint.Poller#run)中看出来: + +```java +@Override public void run() { while (true) { //省略其他非关键代码 //轮询注册的兴趣事件 if (wakeupCounter.getAndSet(-1) > 0) { keyCount = selector.selectNow(); } else { keyCount = selector.select(selectorTimeout); //省略其他非关键代码 Iterator iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null; while (iterator != null && iterator.hasNext()) { SelectionKey sk = iterator.next(); NioSocketWrapper socketWrapper = (NioSocketWrapper) //处理事件 processKey(sk, socketWrapper); //省略其他非关键代码 } //省略其他非关键代码 } } +``` + +上述代码会完成请求事件的监听和处理,最终在processKey中把请求事件丢入线程池去处理。请求事件的接收具体调用栈如下: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/88c01a6e7cfb404a8ab742b37559406c.jpg) + +线程池对这个请求的处理的调用栈如下: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/27da4f2b056b4c709656e95e0d37c27e.jpg) + +在上述调用中,最终会进入Spring Boot的处理核心,即DispatcherServlet(上述调用栈没有继续截取完整调用,所以未显示)。可以说,DispatcherServlet是用来处理HTTP请求的中央调度入口程序,为每一个 Web 请求映射一个请求的处理执行体(API controller/method)。 + +我们可以看下它的核心是什么?它本质上就是一种Servlet,所以它是由下面的Servlet核心方法触发: + +> javax.servlet.http.HttpServlet#service(javax.servlet.ServletRequest, javax.servlet.ServletResponse) + +最终它执行到的是下面的doService(),这个方法完成了请求的分发和处理: + +```java +@Override protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception { doDispatch(request, response); } +``` + +我们可以看下它是如何分发和执行的: + +```java +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查找来得复杂,但是无非还是一个根据请求寻找候选执行方法的过程,这里我们可以通过一个调试视图感受下这种对应关系: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/107d4853543d466599f484fc26a98d4e.jpg) + +这里的关键映射Map,其实就是上述调试视图中的RequestMappingHandlerMapping。 + +**2\. 执行,反射执行寻找到的执行方法** + +这点可以参考下面的调试视图来验证这个结论,参考代码org.springframework.web.method.support.InvocableHandlerMethod#doInvoke: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/70da4564e8b44f1682472b6d8a1a304c.jpg) + +最终我们是通过反射来调用执行方法的。 + +通过上面的梳理,你应该基本了解了一个HTTP请求是如何执行的。但是你可能会产生这样一个疑惑:Handler的映射是如何构建出来的呢? + +说白了,核心关键就是RequestMappingHandlerMapping这个Bean的构建过程。 + +它的构建完成后,会调用afterPropertiesSet来做一些额外的事,这里我们可以先看下它的调用栈: + +![](%E5%AF%BC%E8%AF%BB%205%E5%88%86%E9%92%9F%E8%BD%BB%E6%9D%BE%E4%BA%86%E8%A7%A3%E4%B8%80%E4%B8%AAHTTP%E8%AF%B7%E6%B1%82%E7%9A%84%E5%A4%84%E7%90%86%E8%BF%87%E7%A8%8B/ec78729486f54bdea3735437ad3f59ea.jpg) + +其中关键的操作是AbstractHandlerMethodMapping#processCandidateBean方法: + +```typescript +protected void processCandidateBean(String beanName) { //省略非关键代码 if (beanType != null && isHandler(beanType)) { detectHandlerMethods(beanName); } } +``` + +isHandler(beanType)的实现参考以下关键代码: + +```kotlin +@Override protected boolean isHandler(Class 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中各种类型的错误案例才是大有裨益的! \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/\347\237\245\350\257\206\345\233\236\351\241\276 \347\263\273\347\273\237\346\242\263\347\220\206Spring\347\274\226\347\250\213\351\224\231\350\257\257\346\240\271\346\272\220.md" "b/zh-cn/geek courses/spring/\347\237\245\350\257\206\345\233\236\351\241\276 \347\263\273\347\273\237\346\242\263\347\220\206Spring\347\274\226\347\250\213\351\224\231\350\257\257\346\240\271\346\272\220.md" new file mode 100644 index 00000000..eea124f3 --- /dev/null +++ "b/zh-cn/geek courses/spring/\347\237\245\350\257\206\345\233\236\351\241\276 \347\263\273\347\273\237\346\242\263\347\220\206Spring\347\274\226\347\250\213\351\224\231\350\257\257\346\240\271\346\272\220.md" @@ -0,0 +1,151 @@ +你好,我是傅健。 + +前面,我们介绍了50个各式各样的问题,在正式结束课程之前,我觉得有必要带着你去梳理下或者说复盘下问题出现的原因。错误的表现千万种,但是如果追根溯源的话,其实根源不会太多。 + +当然可能有的同学会把所有的问题都简单粗暴地归结为“学艺不精”,但是除了这个明显的原因外,我想你还是应该深入思考下,最起码,假设是Spring本身就很容易让人犯的错误,你至少是有意识的。那么接下来,我们就来梳理下关于Spring使用中常见的一些错误根源。 + +## 隐式规则的存在 + +要想使用好 Spring,你就一**定要了解它的一些潜规则**,例如默认扫描Bean的范围、自动装配构造器等等。如果我们不了解这些规则,大多情况下虽然也能工作,但是稍微变化,则可能完全失效,例如在[第1课](https://time.geekbang.org/column/article/364761)的案例1中,我们使用 Spring Boot 来快速构建了一个简易的 Web 版 HelloWorld: + +![](%E7%9F%A5%E8%AF%86%E5%9B%9E%E9%A1%BE%20%E7%B3%BB%E7%BB%9F%E6%A2%B3%E7%90%86Spring%E7%BC%96%E7%A8%8B%E9%94%99%E8%AF%AF%E6%A0%B9%E6%BA%90/e110ee0ed8fa4cf3845c754dddfdbb0d.jpg) + +其中,负责启动程序的 Application 类定义如下: + +```typescript +package com.spring.puzzle.class1.example1.application //省略 import @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } +``` + +提供接口的 HelloWorldController 代码如下: + +```kotlin +package com.spring.puzzle.class1.example1.application //省略 import @RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(){ return "helloworld"; }; } +``` + +但是,假设有一天,当我们需要添加多个类似的 Controller,同时又希望用更清晰的包层次结构来管理时,我们可能会去单独建立一个独立于 application 包之外的 Controller 包,并调整类的位置。调整后结构示意如下: + +![](%E7%9F%A5%E8%AF%86%E5%9B%9E%E9%A1%BE%20%E7%B3%BB%E7%BB%9F%E6%A2%B3%E7%90%86Spring%E7%BC%96%E7%A8%8B%E9%94%99%E8%AF%AF%E6%A0%B9%E6%BA%90/7ea97f198edb4002ae5e7d69500d42db.jpg) + +这样就会工作不起来了,追根溯源,你可能忽略了Sping Boot中@SpringBootApplication是有一个默认的扫描包范围的。这就是一个隐私规则。如果你原本不知道,那么犯错概率还是很高的。类似的案例这里不再赘述。 + +## 默认配置不合理 + +除了上述原因以外,还有一个很重要的因素在于,Spring默认的配置不见得是合理的。 + +你可以思考这样一个问题,如果让我们写一个框架,我们最大的追求肯定是让用户“快速上手”,这样才好推广。所以我们肯定不会去写一堆配置,而是采用默认值的方式。但是这里面你提出的默认值一定是用户需要的么?未必。这时候,你可能会妥协地满足80%的用户使用场景。所以在使用时,你一定要考虑自己是不是那多余的20%。 + +一起复习这样一个的案例,在[第18课](https://time.geekbang.org/column/article/380565)的案例2中,当我们什么都不去配置,而是直接使用 Spring Data Cassandra 来操作时,我们实际依赖了 Cassandra driver 内部的配置文件,具体目录如下: + +> .m2\\repository\\com\\datastax\\oss\\java-driver-core\\4.6.1\\java-driver-core-4.6.1.jar!\\reference.conf + +我们可以看下它存在很多默认的配置,其中一项很重要的配置是 Consistency,在 driver 中默认为 LOCAL\_ONE,具体如下: + +```php +basic.request { # The consistency level. # # Required: yes # Modifiable at runtime: yes, the new value will be used for requests issued after the change. # Overridable in a profile: yes consistency = LOCAL_ONE //省略其他非关键配置 } +``` + +当你第一次学习和应用 Cassandra 时,你一定会先只装一台机器玩玩。此时,设置为 LOCAL\_ONE 其实是最合适的,也正因为只有一台机器,你的读写都只能命中一台。这样的话,读写是完全没有问题的。 + +但是产线上的 Cassandra 大多都是多数据中心多节点的,备份数大于1。所以读写都用 LOCAL\_ONE 就会出现问题。所以这样说,你就理解了我要表达的意思了吧?Spring采用了一堆默认配置有其原因,但不见得适合你的情况。 + +## 追求奇技淫巧 + +Spring给我们提供了很多易用的可能,然后有时候,你用着用着会觉得,Spring怎么用都能工作起来,特别是你在网上看到了一些更简洁高效的写法之后,你会觉得惊喜,原来这样也可以。但是Spring真的是无所不能地随意使用么? + +这里让我们快速回顾下[第9课](https://time.geekbang.org/column/article/373215)的案例2,我们常常使用@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,提示匹配不上。 + +这个案例的原因,我就不复述了,我只是想说,通过这个案例,你要明白Spring虽然强大,看起来怎么都能玩转,但是实际并非一定如此。 + +## 理所当然地使用 + +在使用Spring框架时,有时候,我们会不假思索地随意下结论。例如,我们在处理HTTP Header遇到需要处理多个Header时,我们第一反映是使用一个HashMap来接收,但是会满足所有情况么?让我们快速回顾下[第10课](https://time.geekbang.org/column/article/373942)的案例1。 + +在 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,不能直接使用Map而应该使用MultiValueMap。** + +借着这个案例,可以思考下你为什么会出错?因为你肯定知道要用一个Map来接收,也相信一定可以,但是你可能疏忽了你用的Map是Spring给你返回的Map。所以有时候,一些“理所当然”的结论其实是错误的。一定要大胆假设、小心求证,才能规避很多问题。 + +## 无关的依赖变动 + +Spring依赖了大量的其他组件来协同完成功能,但是完成同一个功能的组件却可能存在多种工具,例如Spring完成JSON操作,既可以依赖Gson,也可以依赖Jackson。更可怕的是Spring往往是动态依赖的,即优先看看优选的工具是否存在,存在则用,不存在才看其他依赖的工具类型是否存在。这样的逻辑会导致项目的依赖不同时,依赖的工具也不同,从而引发一些微妙的行为“变化”。 + +我们可以快速复习下[第11课](https://time.geekbang.org/column/article/374654)的案例2,首先看下面这段代码: + +```less +@RestController public class HelloController { @PostMapping("/hi2") public Student hi2(@RequestBody Student student) { return student; } } +``` + +这段代码接收了一个 Student 对象,然后原样返回。我们使用下面的测试请求进行测试: + +> POST [http://localhost:8080/springmvc3\_war/app/hi2](http://localhost:8080/springmvc3_war/app/hi2)\- Content-Type: application/json- {- “name”: “xiaoming”- } + +经过测试,我们会得到以下结果: + +> {- “name”: “xiaoming”- } + +但是随着项目的推进,在代码并未改变时,我们可能会返回以下结果: + +> \- {- “name”: “xiaoming”,- “age”: null- } + +即当 age 取不到值,开始并没有序列化它作为响应 Body 的一部分,后来又序列化成 null 作为 Body 返回了。 + +如果我们发现上述问题,那么很有可能是上述描述的依赖变动造成的。具体而言,在后续的代码开发中,我们直接依赖或者间接依赖了新的 JSON 解析器,例如下面这种方式就依赖了Jackson: + +```xml + com.fasterxml.jackson.core jackson-databind 2.9.6 +``` + +诸如此类问题,一般不会出现严重的问题,但是你一定要意识到,当你的代码不变时,你的依赖变了,行为则可能“异常”了。 + +## 通用错误 + +实际上,除了上面的一些原因外,还有不少错误是所有类似Spring框架都要面对的问题。例如,处理一个HTTP请求,Path Variable 含有特殊字符/时,一般都会有问题,大多需要额外的处理。我们可以复习下[第9课](https://time.geekbang.org/column/article/373215)的案例1。 + +在解析一个 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”?然而稍微敏锐点的程序员都会判定这个访问是会报错的。 + +这个案例其实你换别的HTTP服务框架也可能需要处理,这种问题就是一些通用的问题,并不是因为你使用Spring才出现的。 + +通过思考上述错误根源,其实你应该相信了,除了学艺不精之外,还有一部分原因在于我们的“武断”和Spring的好用。也正因为它的好用,让我们很少去思考它的内部运作机制,当我们大刀阔斧地到处使用Spring时,可能不小心就踩坑了。所以当你使用Spring时,不妨大胆假设、小心求证,多看看别人犯的错误,多总结总结最佳实践。这样才能一劳永逸,更加熟练和自信地使用Spring! \ No newline at end of file diff --git "a/zh-cn/geek courses/spring/\347\273\223\346\235\237\350\257\255 \351\227\256\351\242\230\346\200\273\346\257\224\350\247\243\345\206\263\345\212\236\346\263\225\345\244\232.md" "b/zh-cn/geek courses/spring/\347\273\223\346\235\237\350\257\255 \351\227\256\351\242\230\346\200\273\346\257\224\350\247\243\345\206\263\345\212\236\346\263\225\345\244\232.md" new file mode 100644 index 00000000..2845239f --- /dev/null +++ "b/zh-cn/geek courses/spring/\347\273\223\346\235\237\350\257\255 \351\227\256\351\242\230\346\200\273\346\257\224\350\247\243\345\206\263\345\212\236\346\263\225\345\244\232.md" @@ -0,0 +1,53 @@ +你好,我是傅健,这是专栏的最后一讲。行百里路,我们终于来到了这里,很感谢你的支持和坚持,让你没有在枯燥的源码学习中半路放弃。 + +本专栏虽然只有20多讲,但是覆盖的知识面还是比较广泛的,不仅有Spring最基础的应用,也涉及Spring Data等层次高一点的应用。通过学习,你会发现,遇到的问题肯定远远超过我列举的50多个。而通过解决一个又一个的问题,你还会发现**Spring原理的重要性**。 + +谈及学习Spring的感受,可以说,当你初学Spring时,你会觉得它是一个好用的“黑盒”,而当你稍微深入应用Spring时,你又会觉得它像一团“迷雾”,看起来很美,但却很容易让人迷失,不知所措。那么通过系统的学习之后,虽然我们还是无法解决所有的问题,但是已经算有了件衬手的兵器,通关进阶咫尺之遥。通过剖析各种问题所涉及的源码,你能**从量到质**有一个转化式的提升! + +但话说回来,有个问题我不知道你有没有思考过,我们为什么会遇到那么多的问题?是基本功不扎实吗?还是实践经验不够? + +举个例子,在日常开发中,我们经常会发现,新手能很快地完成一个任务,而且完成的功能貌似也能运行良好。但是随着时间的推移,很多问题会慢慢暴露出来。 + +我记得之前我的某位新手同事会随手在产品代码中使用”System.out.println”去输出日志。当我发现这个问题时,这个程序已经上线一个月了,并且很稳定。我也仔细查看过系统,这样输出的Console日志最终也会被设置的归档系统按天归档到其他挂载的NFS磁盘上,貌似也不需要太担心它会一直变大。但是有一天,本地磁盘还是报警容量不足了,苦逼的是,我使用了很多的Linux命令都无法确定哪个文件很大,明明看到的所有文件都是很小的,磁盘怎么会满呢? + +最后,还是比较幸运的,我用lsof命令去查询了“删除”的文件,果然占用空间很大,根源就在于同事当初的”System.out.println”导致了一个文件不断变大,而因为存在归档(删除),所以我们很难立马找到它。 + +仔细复盘这个问题,你会发现,新手有个特点,对于每行代码背后的原理是什么,怎么写最合适,并没有太深入的研究。遇到问题时,也习惯于通过各种搜索引擎去解决,在尝试一个方案失败后,往往会继续迭加使用其他的方案,最终勉强可以工作了,但等再次出现问题时你会发现,他的代码是真心复杂啊,很难避免更多问题的产生。 + +**见微知著,基本功非一日之功,实践也非一蹴而就。**我的建议就是:当你去书写代码时,多问自己几句,我使用的代码书写方式是正确的么?我的解决方案是正规的套路么?我解决问题的方式会不会有其他的负面影响?等等。 + +从另外一个角度看,如果你去翻阅StackOverflow上的各式各样的问题,你会发现很多问题都描述的“天花乱坠”,很复杂,但是问题的根源往往是极其简单的。你还可以尝试去除问题复杂的无关的部分,把它简化成一个“迷你”版本,这样更容易定位“根源”,这才是最重要的。 + +你看,这不就是我们这门课程的设计思路吗?用一些简化的案例,让你了解问题的核心,从而避免踩坑。这是一种很好的学习方式! + +所以,我们也不妨总结下,面对无穷无尽的问题,我们到底该如何应对? + +我认为,**问题总比解决办法多**。换句话说,问题是可归类的,所以导致问题出现的根源是有限的,那么对应的解决办法自然也是可总结的。 + +当你遇到一个问题时,你可以先思考这样3点: + +1. 这个问题是不是别人也还没有解决过? +2. 是不是问题本身就很难? +3. 是不是自己当前的知识储备不够? + +千万不要一上来就给自己贴一个“能力不够”的标签,因为等你工作个10来年以后,或许曾经那些让你泪奔的问题,你仍然解决不了。因为一些问题就是很难呀,我们无法在指定的时间内解决也是很正常的一件事,说白了,你要**先对自己有信心**。 + +其次,我们可以尝试一些方式方法,毕竟老板掏钱养员工可不是让我们来培养自信的,对吧! + +1. **将问题化繁为简** + +很多问题很难搞定,在于有很多无关的东西掩盖了真相。但是你要相信,不管表象多复杂,出错的根源可能都不会太复杂。就像前苏联的一次火箭发射失败,也只是因为一个小数点错误而已。所以,当你碰到一个棘手的问题时,你一定要不断地把这个问题做“简化”,直到简化成一个最简单的问题模型,你就能很容易地定位到问题根源了。 + +1. **耐心积累知识** + +当你已经定位到问题的大概范围,但仍然解决不了时,往往是因为欠缺相关的知识和技能。就像让你去写一个Eclipse/IDEA插件,但是你都没有使用过它们,你觉得可能么?这个时候,你一定要学会查缺补漏,把自己的知识网络构建起来。当然,这样做可能无法立竿见影,但长期来看对你的个人发展是大有裨益的。 + +1. **寻求帮助** + +如果靠自己实在解决不了,寻求他人帮助也是可以的。但我建议你先努力尝试一下再去求助,学习就像打游戏,每解决一个问题都像升级一次,他人帮助就像开了外挂,治标不治本。另外就是求助也是有技巧的,切忌扬汤止沸,去寻求一个定位和解决问题的思路是更好的方式。 + +最后的最后,非常感谢各位同学的信任,这门课程我为你提供了很多的问题场景以及解决问题的思路,希望能帮助你在技术之路上越走越远!在学习的过程中,如果你有什么意见或者建议,也欢迎通过下方的**结课问卷**告知我,我会正视大家的声音。 + +我是傅健,我们江湖再见! + +[![](%E7%BB%93%E6%9D%9F%E8%AF%AD%20%E9%97%AE%E9%A2%98%E6%80%BB%E6%AF%94%E8%A7%A3%E5%86%B3%E5%8A%9E%E6%B3%95%E5%A4%9A/e08c145566e0453980b293d127a23b29.jpg)](https://jinshuju.net/f/KKizl7) \ No newline at end of file