Spring 闯关指南:SpringMVC

阅读本文,你可以了解到 Spring MVC 整体的架构,以及 web 编程中一些常见问题的解决方案。例如, Spring MVC 是如何在框架中解决静态资源问题、编码问题、序列化问题以及跨域问题,还有 Spring MVC 是如何将容器中管理的 bean 用作 web 容器中的 Filter 的。

在 Spring 官方文档这样定义 Spring MVC :“基于 Servlet API 构建的原始 Web 框架”。所以,我们在使用 MVC 框架时首先需要了解它能够支持的 Servlet 容器以及 Java EE 版本范围。

整体架构

我们知道 mvc 框架需要在 servlet 容器中使用才有效,而 MVC 框架又与 Spring 框架无缝集成。那么,我们首先需要考虑的问题就是如何在 servlet 容器中创建 Spring 中最重要的 IOC 容器。

就拿常见的 tomcat 来说,我们可控制的入口一般在 web.xml 中定义的 servlet 和 listener。同样地,我们可以考虑通过这样的方式来创建容器。

容器层次结构

Spring 的确是通过 servlet 和 listener 来创建容器。但问题是在这两个拓展点对应的实体类 ContextLoaderListenerDispatcherServlet 中都存在创建容器的过程,所以这两者又有什么关系呢?

其实,这与容器的层次结构有关。先来分析以下几种情况会发生什么。第一种,如果工程同时配置了 ContextLoaderListenerDispatcherServlet ,前者所创建的容器将作为后者所创建容器的父容器。第二,如果只配置前者,项目仍然能够正常启动。但我们知道,mvc 的诸多特性需要 DispatcherServlet 支持。第三,如果单单配置 DispatcherServlet ,项目功能正常。当然,这一切的前提建立在你正确的为 Spring 容器提供了配置文件。

也就是说,我们的 bean 将交给不同层次的 Spring 容器管理。你可以根据需要将基础 bean 放入 ContextLoaderListener 这个 Root WebApplicationContext,然后配置多个 DispatcherServlet 实例,通过父子容器的关系来共享基础的 bean。Spring 官方文档中有一个经典的图表示了这个关系:
SpringMVC容器层次结构-官网提供

关于父子容器的加载以及刷新过程,你可以独立看待它们。它们是一个完整的过程,只不过在子容器加载 bean 时,会先去父容器看看有没有,有的话就不用加载。但这有个前提,子容器不能包含当前 bean 对应的 BeanDefinition,才会去父容器加载。也就是说子容器不能扫描到父容器已经扫描过的 bean,否则会重复装载,这点需要注意 。所以,前文提到能正常工作,这源于一种“巧合”,而我们最应该担心的就是这种“巧合”带来的不确定性。

对于有特殊地管理部分 bean 的需求,父子容器将很有用处。还有,我们还可以通过 ContextLoaderListener 提供的拓展方法来为这个 Root 容器再配置一个父容器。

在日常中,大多两个这样地容器就够了,我们也很少再做这些细分。但不可否认地是,框架设计者考虑之细,令人佩服!

之前,我遇到过同时为 ContextLoaderListenerDispatcherServlet 分配相同配置文件的情况。其实,从上文官方提供的结构图来看,这是一种错误的使用示例。不同的容器应该管理不同层次的 bean。但这样的做法因为加载顺序的缘故,仍然能够得到正确的结果。发生这种情况是因为不了解框架的缘故,官方文档其实有提到这个问题,不过大多被忽略了。所以,尽管没有强制要求,我们仍然需要遵循正确的划分,保证不同容器加载不同配置文件。一方面代表着我们认真思考过这个问题,考虑到这样做的影响。另一方面,这样的层次划分会更加容易理解,避免出错。

在平常,我们有着对业务代码进行分层的概念,但却很少区分业务与框架间的分层,因为本身也不需要,框架向来对业务代码侵入少。但这里的容器层次结构,可以看作框架想要管理的 bean 和业务逻辑 bean 间的分层。

启动时

上面介绍完容器层次的概念,这里分析下 ContextLoaderListenerDispatcherServlet 在启动时所做的工作。

ContextLoaderListener 本身作为监听器,会有 web 应用服务器负责调用执行。所做的工作是创建 Root WebApplicationContext,并将其存储在全局的 ServletContext 中,这也是为什么 DispatcherServlet 能够获取到这个 Root 容器,并将其用作自己创建容器的父容器的原因。

DispatcherServlet 作为 servlet,会由 web 应用调用其初始化方法。其主要工作首先是设置 init parameters 的值到当前实例对象的属性中,像如下 web.xml 配置,DispatcherServletcontextConfigLocation 属性会被成功附值:

  <servlet>
    <servlet-name>app</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>classpath*:app-context.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>app</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>

这部分工作完成后,接下来开始创建 IOC 容器,然后执行初始化策略。重点便是这些初始化策略,这将是支撑 MVC 正常运转的核心,后续也将基于这些策略展开。

protected void initStrategies(ApplicationContext context) {
	initMultipartResolver(context);
	initLocaleResolver(context);
	initThemeResolver(context);
	initHandlerMappings(context);
	initHandlerAdapters(context);
	initHandlerExceptionResolvers(context);
	initRequestToViewNameTranslator(context);
	initViewResolvers(context);
	initFlashMapManager(context);
}
  • initMultipartResolver: 用于处理文件上传服务。
  • initLocaleResolver: 处理国际化问题,通过解析请求的 Locale 和设置响应的 Locale 来处理。
  • initThemeResolver: 用于定义一个主题。
  • initHandlerMappings: 用于定义用户设置的请求映射关系,主要是将用户的请求映射成一个个 Handler 实例。
  • initHandlerAdapters: 用于根据 Handler 的类型定义不同的处理规则。
  • initHandlerExceptionResolvers: 当 Handler 出错时,会通过这个来统一处理。
  • initRequestToViewNameTranslator: 将指定的 viewName 根据定义的 RequestToViewNameTranslator 替换成想要的格式。
  • initViewResolvers: 用于将 View 解析成页面。
  • initFlashMapManager: 初始化检索和保存 FlashMap 实例 的类。FlashMap 为一个请求提供了一种方法来存储用于另一个地方的属性。例如,当 URL 重定向时。

上面的初始化过程,如果需要对应实体类的对象,首先会考虑从 ApplicationContext 中获取。如果没有的话,则会使用默认的策略。所以,如果我们需要拓展,那么,将我们自定义的相应类交由 Spring 管理即可。

运行时

在启动时准备好了策略对象,那么在运行时,就将正式使用。我们已在 web.xml 正确配置 DispathcerServlet,并配置了 URL 映射。那么,当请求过来时,在经历了层层调用后,最终会执行到 DispatcherServletdoDispatch 方法来执行具体的 “转发”。所以,在分析 Spring web 问题时,一般会断点于此。
该方法的主要执行过程如下:

  • 针对当前请求,创建异步管理器:
    用于管理异步请求处理的核心类,主要用于作为 SPI ,通常不由应用程序类直接使用。
  • 处理文件上传请求:当有文件上传时,会将当前的 HttpServletRequest 包装为 MultipartHttpServletRequest ,并将上传的内容封装成 MultipartFile
  • 获取 url 对应的 Handler,并返回 HandlerExecutionChain:这一步和后面的步骤可以说是核心步骤。这里主要在已经注册的 HandlerMapping 中找到能够处理当前请求并返回 HandlerExecutionChain。如果找不到的话,就会在响应中写入 404,并结束后续流程。
  • 获取 handler 对应的 HandlerAdapter:根据当前请求找到的 handler 选取 handler adapter。
  • 处理 last-modified 头:这个需要 HandlerAdapter 支持。
  • handler 预处理;其实是执行链调用拦截器的 preHandle 方法, 如果该方法返回了 false,会接着触发拦截器的 afterCompletion 方法,并结束整个流程,包括不再调用后续拦截器以及后面的流程。
  • handler 执行:其实由于需要处理各种情况的复杂性,导致 handler 并不是由统一接口定义,所以在这里 handler 并不知道如何执行自身,但通过 handler 找到的 HandlerAdapter 知道如何执行它,并返回 ModelAndView
  • 异步管理器处理:如果有异步处理开始,则结束流程,直接返回。可似乎我们还没有看见哪里做了异步处理,不要着急,后文慢慢叙述。
  • handler 后处理: 执行链调用拦截器的 postHandle 方法。
  • 处理 Dispatche 结果:这一步其实是在拼装响应结果。处理 ModelAndView,如果有异常的话,也会处理异常。处理 ModelAndView 需要用到前文 localeResolverviewResolvers。处理异常则需要用到 handlerExceptionResolvers
  • handler 完成后处理:触发拦截器 HandlerInterceptorafterCompletion 方法。
  • 异步管理器处理::如果有异步处理开始,则会触发拦截器 AsyncHandlerInterceptorafterConcurrentHandlingStarted 方法。由于这一步在 finally 块中,所以前文提到的异步管理器处理流程结束并不会影响到这里。
  • 最后就是释放文件请求的资源了,由 multipartResolver 来完成这个工作。

发现了嘛?整个流程,各个类的职责非常清楚,如果你想理解某个功能如何实现,找到相应类查看即可。这种设计方法非常值得我们学习借鉴。

不过上面还有个异步管理器没有解析清楚。其实,这与 Servlet3 请求的异步处理有关。我们常用的 controller 方法不一定需要返回一个业务上的值,而是可以直接返回一个 Callable 对象,并交由 Spring MVC 管理的线程来执行这个对象。在这过程中,Servlet 的主线程可以退出并释放资源。而当 Callable 对象的方法返回时,将再次被分配到 Servlet 容器中恢复处理流程。

静态资源问题

首先,要了解 tomcat 是如何处理静态资源的。主要是由 DefaultServlet 处理,它提供静态资源和目录列表。该 Servlet 配置在 $CATALINA_BASE/conf/web.xml。该配置文件会应用于 tomcat 下的所有应用,并和应用本身指定的 web.xml 合并。除了 DefaultServlet 之外,配置文件中还配置了 JspServlet ,用于处理 JSP 资源。

所以,如果我们的 DispathcerServlet 在配置 url-pattern/* 时,将会覆盖的 DefaultServletJspServlet 配置。但如果配置为 /,则只会覆盖 DefaultServlet,jsp 的请求仍然会由 JspServlet 负责。

完成覆盖后,资源请求将由 DispatcherServlet 处理。完整的处理过程上文已经分析了,变化点主要是 HandlerHandlerAdapter。静态资源的 handler 为 ResourceHttpRequestHandlerHandlerAdapter 实现类为 HttpRequestHandlerAdapter

当然,我们自定义静态资源需要提供一些 url 配置,可以通过继承 WebMvcConfigurationSupport 类,覆盖 addResourceHandlers(ResourceHandlerRegistry registry) 方法来完成。并且,最终创建 ResourceHttpRequestHandler 实例的 HandlerMapping bean 也将由该配置类负责创建。

大概可以这样理解 handler、handlerMapping、handlerAdapter 之间的关系。handlerMapping 根据 request 获取到 handler,然后再根据 handler 找到支持它的 handlerAdapter。最终由 handlerAdapter 来负责执行 handler 。

编码问题

有人的地方就有江湖,有字符的地方就有编码问题。Spring MVC 中解决这个问题是通过配置 filter 实现,具体的实现类是 CharacterEncodingFilter。不过这个过滤器的作用只是设置编码,不存在任何的转换过程。

还有关于 CharacterEncodingFilter 继承的父类 OncePerRequestFilter ,从字面意思看,它确保一次请求只通过一次 Filter,而不需要重复执行。如此设计,是因为 web 容器并不是像我们所期望的那样,只过滤一次,这因 Servlet 版本而异。而该类能够兼容这些不同,保证只过滤一次。

序列化问题

序列化通过配置 HttpMessageConverter 实现,该类被 RequestMappingHandlerAdapterExceptionHandlerExceptionResolver 使用。所以,序列化框架需要支持 Spring 时,会提供实现了该接口的序列化类,例如 fastjson 的 FastJsonHttpMessageConverter,我们通过继承 WebMvcConfigurationSupport 类,覆盖 addDefaultHttpMessageConverters 方法即可。

Spring 总是在满足自身功能的同时,也在主动向第三方框架靠近,同时也保证给第三方框架主动靠近的可能。这类专业问题,Spring 一般不会再去重复造轮子。坦白说,与其重复造轮子,不如将这些时间花在框架的设计以及新的思维创新上,这所带来的收益可比解决这类特定问题好太多。

跨域问题

常用的解决跨域问题是在 controller 类上添加 CrossOrigin 注解,或者是通过配置的方式(继承 WebMvcConfigurationSupport 类)。这两种方式本质上都将生成 CorsConfiguration ,用于请求到来时的跨域分析判断。跨域注解的配置生成时机和 handler 的处理时机一样,发生在 RequestMappingHandlerMapping 的初始化阶段。

不过需要注意的是 handler 指的是类级别的 Controller 注解或 RequestMapping 注解修饰的类。所以,在使用 feign 框架时需要注意到这个问题。

其实,解决这类问题有个共性,那就是提取配置文件,然后处理。前者一般发生在启动时,后者一般发生在运行时。并且处理一般会需要找到这些配置文件,很少存在动态去匹配的,这与性能有关,像 AOP 的代理一样。所以,数据结构 Map 将会派上大的用场。所以分析框架中的各类问题,可以从数据的流动来看,确切地找到数据输入、处理和输出过程。

如何跨容器使用对象

这里描述的是如何将 Spring 容器管理的 bean 用作 web 容器的 filter。Spring 提供了一个 DelegatingFilterProxy 类来实现该功能,我们可以借鉴这个思路来实现其它需要在 Spring 容器 和 web 容器 中使用相同对象的需求。

首先,DelegatingFilterProxy 类需要配置在 web.xml ,本质上它仍然是一个 filter,所以,DelegatingFilterProxy 将实现 filter 接口。其次,需要在 web 容器中使用 spring 管理的 bean,那么,需要指定委托的 bean 的名称。最后,只要为该类找到 WebApplicationContext, 便可找到委托的 bean 了。这很容易做到,一个全局的 WebApplicationContextUtils 工具类即可。

写在最后

文章针对问题并未深入分析。这并不是限于篇幅的原因,而是有了另一种期望,一种想要透过表象寻求本质的追求。

我们的记忆有限,大脑不像硬盘,也不像天才内心的思维宝殿,能够产生惊人的记忆力。透过表象寻求本质,能够减少需要记忆的东西和增强逻辑思维。用程序员的话来讲,应该就是 时间换空间 了。

这是 Spring 闯关指南系列 的第五篇文章,如果你觉得我的文章还不错,并对后续文章感兴趣的话,可以通过扫描下方二维码关注我的公众号。谢谢!
我与风来

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值