这得多老的项目才会有这么奇葩的需求

开发 项目管理
维护老项目的时候,我们总会遇到一些奇奇怪怪的需求,解决这些奇葩问题可能才是我们开发的常态。

 [[380543]]

维护老项目的时候,我们总会遇到一些奇奇怪怪的需求,解决这些奇葩问题可能才是我们开发的常态。

这不,最近就有小伙伴问了这样一个问题:

 

这个小伙伴想在 Spring Boot 中同时使用多个视图解析器,一般来说我们正常设计一个项目时,肯定不会搞成这样,要么前后端分离不需要视图解析器,要么前后端不分需要视图解析器,但是即使需要一般也只会使用一种视图解析器,而不会多种视图解析器混在一起使用。

不过现在既然小伙伴提出了这个问题,我们就来看看这个需求能不能做!先说结论:技术上来说这个当然是可以实现的,而且实现方式不难。

不过要把这个问题理解透彻,这就涉及到到 SpringMVC 的工作原理了,今天松哥就来和大家把这个问题稍微梳理下。

初始化方法

在 SpringMVC 中我们可以配置多个视图解析器,这些视图解析器最终会在 DispatcherServlet#initViewResolvers 方法中完成加载,如下:

  1. private void initViewResolvers(ApplicationContext context) { 
  2.  this.viewResolvers = null
  3.  if (this.detectAllViewResolvers) { 
  4.   // Find all ViewResolvers in the ApplicationContext, including ancestor contexts. 
  5.   Map<String, ViewResolver> matchingBeans = 
  6.     BeanFactoryUtils.beansOfTypeIncludingAncestors(context, ViewResolver.class, truefalse); 
  7.   if (!matchingBeans.isEmpty()) { 
  8.    this.viewResolvers = new ArrayList<>(matchingBeans.values()); 
  9.    // We keep ViewResolvers in sorted order
  10.    AnnotationAwareOrderComparator.sort(this.viewResolvers); 
  11.   } 
  12.  } 
  13.  else { 
  14.   try { 
  15.    ViewResolver vr = context.getBean(VIEW_RESOLVER_BEAN_NAME, ViewResolver.class); 
  16.    this.viewResolvers = Collections.singletonList(vr); 
  17.   } 
  18.   catch (NoSuchBeanDefinitionException ex) { 
  19.    // Ignore, we'll add a default ViewResolver later. 
  20.   } 
  21.  } 
  22.  // Ensure we have at least one ViewResolver, by registering 
  23.  // a default ViewResolver if no other resolvers are found. 
  24.  if (this.viewResolvers == null) { 
  25.   this.viewResolvers = getDefaultStrategies(context, ViewResolver.class); 
  26.  } 

这段代码的逻辑很清楚:

  • 首先将 viewResolvers 变量置空,这个变量将存储所有的视图解析器。
  • 接下来根据 detectAllViewResolvers 的变量值来决定是否要加载所有的视图解析器,该变量默认为 true,表示加载所有的视图解析器,加载所有的视图解析器就是去 Spring 容器中查找到所有的 ViewResolver 实例,然后给这些 ViewResolver 实例按照 Order 优先级进行排序。如果 detectAllViewResolvers 的变量值为 false,表示只加载名为 viewResolver 的视图解析器。
  • 经过前面的步骤,如果 viewResolvers 还是为 null,表示用户压根就没有配置视图解析器,此时调用 getDefaultStrategies 方法加载一个默认的视图解析器,以确保我们的系统中至少有一个视图解析器。

一般来说,在一个 SSM 项目中,如果我们在 SpringMVC 的配置文件中,没有做任何关于视图解析器的配置,那么就会走入第三步。

initViewResolvers 方法的主要目的就是初始化视图解析器,并对视图解析器进行排序。从这里我们也可以大概看出来 SpringMVC 中是支持多个视图解析器同时存在的。

原理分析

上面是视图解析器的初始化过程。

接下来我们来看看视图解析器具体是如何发挥作用的。

小伙伴们知道,一个请求进入 DispatcherServlet 之后,执行的方法流程依次是 service->processRequest->doService->doDispatch->processDispatchResult->render->resolveViewName->...

进入 render 方法就差不多进入正题了,我们的页面渲染将在这个方法中完成。render 方法中包含如下一段代码:

  1. View view
  2. String viewName = mv.getViewName(); 
  3. if (viewName != null) { 
  4.  // We need to resolve the view name
  5.  view = resolveViewName(viewName, mv.getModelInternal(), locale, request); 
  6.  if (view == null) { 
  7.   throw new ServletException("Could not resolve view with name '" + mv.getViewName() + 
  8.     "' in servlet with name '" + getServletName() + "'"); 
  9.  } 
  10. else { 
  11.  // No need to lookup: the ModelAndView object contains the actual View object. 
  12.  view = mv.getView(); 
  13.  if (view == null) { 
  14.   throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a " + 
  15.     "View object in servlet with name '" + getServletName() + "'"); 
  16.  } 

可以看到,这里获取到视图的名字之后,接下来调用 resolveViewName 方法去获取一个具体的视图。在 resolveViewName 方法中,将根据视图名称以及现有的视图解析器找到对应的视图。

那么这里就存在一个问题,现有的视图解析器如果有多个,究竟该以哪个为准呢?

我们来看下 resolveViewName 方法中的执行逻辑。

  1. protected View resolveViewName(String viewName, @Nullable Map<String, Object> model, 
  2.   Locale locale, HttpServletRequest request) throws Exception { 
  3.  if (this.viewResolvers != null) { 
  4.   for (ViewResolver viewResolver : this.viewResolvers) { 
  5.    View view = viewResolver.resolveViewName(viewName, locale); 
  6.    if (view != null) { 
  7.     return view
  8.    } 
  9.   } 
  10.  } 
  11.  return null

可以看到,这里就是遍历所有的 ViewResolver,调用其 resolveViewName 方法去找到对应的 View,找到后就返回了。

ViewResolver 就是我们常说的视图解析器,我们用 JSP、Thymeleaf、Freemarker 等,都有对应的视图解析器,从下面一张图中就可以看出 ViewResolver 的继承类:

 

不过在 Spring Boot 中,我们并不会直接使用这些视图解析器,而是使用一个名为 ContentNegotiatingViewResolver 的视图解析器,这个是 Spring3.0 中引入的的视图解析器,它不负责具体的视图解析,而是根据当前请求的 MIME 类型,从上下文中选择一个合适的视图解析器,并将请求工作委托给它。

所以这里我们就先来看看 ContentNegotiatingViewResolver#resolveViewName 方法:

  1. public View resolveViewName(String viewName, Locale locale) throws Exception { 
  2.  RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); 
  3.  List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest()); 
  4.  if (requestedMediaTypes != null) { 
  5.   List<View> candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes); 
  6.   View bestView = getBestView(candidateViews, requestedMediaTypes, attrs); 
  7.   if (bestView != null) { 
  8.    return bestView; 
  9.   } 
  10.  } 
  11.  if (this.useNotAcceptableStatusCode) { 
  12.   return NOT_ACCEPTABLE_VIEW; 
  13.  } 
  14.  else { 
  15.   return null
  16.  } 

这里的代码逻辑也比较简单:

  • 首先是获取到当前的请求对象,可以直接从 RequestContextHolder 中获取。然后从当前请求对象中提取出 MediaType。
  • 如果 MediaType 不为 null,则根据 MediaType,找到合适的视图解析器,并将解析出来的 View 返回。
  • 如果 MediaType 为 null,则为两种情况,如果 useNotAcceptableStatusCode 为 true,则返回 NOT_ACCEPTABLE_VIEW 视图,这个视图其实是一个 406 响应,表示客户端错误,服务器端无法提供与 Accept-Charset 以及 Accept-Language 消息头指定的值相匹配的响应;如果 useNotAcceptableStatusCode 为 false,则返回 null。

现在问题的核心其实就变成 getCandidateViews 方法和 getBestView 方法了,看名字就知道,前者是获取所有的候选 View,后者则是从这些候选 View 中选择一个最佳的 View,我们一个一个来看。

先来看 getCandidateViews:

  1. private List<View> getCandidateViews(String viewName, Locale locale, List<MediaType> requestedMediaTypes) 
  2.   throws Exception { 
  3.  List<View> candidateViews = new ArrayList<>(); 
  4.  if (this.viewResolvers != null) { 
  5.   for (ViewResolver viewResolver : this.viewResolvers) { 
  6.    View view = viewResolver.resolveViewName(viewName, locale); 
  7.    if (view != null) { 
  8.     candidateViews.add(view); 
  9.    } 
  10.    for (MediaType requestedMediaType : requestedMediaTypes) { 
  11.     List<String> extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType); 
  12.     for (String extension : extensions) { 
  13.      String viewNameWithExtension = viewName + '.' + extension; 
  14.      view = viewResolver.resolveViewName(viewNameWithExtension, locale); 
  15.      if (view != null) { 
  16.       candidateViews.add(view); 
  17.      } 
  18.     } 
  19.    } 
  20.   } 
  21.  } 
  22.  if (!CollectionUtils.isEmpty(this.defaultViews)) { 
  23.   candidateViews.addAll(this.defaultViews); 
  24.  } 
  25.  return candidateViews; 

获取所有的候选 View 分为两个步骤:

  1. 调用各个 ViewResolver 中的 resolveViewName 方法去加载出对应的 View 对象。
  2. 根据 MediaType 提取出扩展名,再根据扩展名去加载 View 对象,在实际应用中,这一步我们都很少去配置,所以一步基本上是加载不出来 View 对象的,主要靠第一步。

第一步去加载 View 对象,其实就是根据你的 viewName,再结合 ViewResolver 中配置的 prefix、suffix、templateLocation 等属性,找到对应的 View,方法执行流程依次是 resolveViewName->createView->loadView。

具体执行的方法我就不一一贴出来了,唯一需要说的一个重点就是最后的 loadView 方法,我们来看下这个方法:

  1. protected View loadView(String viewName, Locale locale) throws Exception { 
  2.  AbstractUrlBasedView view = buildView(viewName); 
  3.  View result = applyLifecycleMethods(viewName, view); 
  4.  return (view.checkResource(locale) ? result : null); 

在这个方法中,View 加载出来后,会调用其 checkResource 方法判断 View 是否存在,如果存在就返回 View,不存在就返回 null。

这是一个非常关键的步骤,但是我们常用的视图对此的处理却不尽相同:

  • FreeMarkerView:会老老实实检查。
  • ThymeleafView:没有检查这个环节(Thymeleaf 的整个 View 体系不同于 FreeMarkerView 和 JstlView)。
  • JstlView:检查结果总是返回 true。

至此,我们就找到了所有的候选 View,但是大家需要注意,这个候选 View 不一定存在,在有 Thymeleaf 的情况下,返回的候选 View 不一定可用,在 JstlView 中,候选 View 也不一定真的存在。

接下来调用 getBestView 方法,从所有的候选 View 中找到最佳的 View。getBestView 方法的逻辑比较简单,就是查找看所有 View 的 MediaType,然后和请求的 MediaType 数组进行匹配,第一个匹配上的就是最佳 View,这个过程它不会检查视图是否真的存在,所以就有可能选出来一个压根没有的视图,最终导致 404。

这就是整个 View 的加载过程。

具体应用

如果是单个视图,这套加载流程没什么问题,但是如果是多个视图解析器同时存在,就可能会有问题。

松哥一个一个来说明。

第一种情况:

FreeMarkerView、ThymeleafView 以及 JstlView 在项目中只存在任意一个,这种情况没任何问题,这也是小伙伴们日常常见的使用场景。

第二种情况:

FreeMarkerView+ThymeleafView 组合。如果项目中同时存在这两种视图解析器,由于 FreeMarkerView 会老老实实检查视图是否存在,而 ThymeleafView 不会检查,所以需要确保 FreeMarkerViewResolver 的优先级高于 ThymeleafViewResolver 的优先级。这样就能够确保视图加载的时候先去加载 FreeMarkerView(FreeMarkerView 如果不存在,则不会列为候选 View),再去加载 ThymeleafView,这样无论是 FreeMarkerView 还是 ThymeleafView,都能够正常加载到(回顾前面所讲 getBestView 方法逻辑)。假如 ThymeleafViewResolver 的优先级高于 FreeMarkerViewResolver,那么就会出现如下情况:用户请求一个 Freemarker 视图,结果在 getCandidateViews 方法中返回了两个视图,依次是 ThymeleafView 和 FreeMarkerView,但是实际上 ThymeleafView 中的视图是不存在的,结果在 getBestView 方法中,按顺序直接匹配到 ThymeleafView,最终导致运行出错。

在 Spring Boot 中,如果我们引入了 Freemarker 和 Thyemeleaf 的 starter,默认情况下,Freemarker 和 Thymeleaf 的优先级相同,都是 Ordered.LOWEST_PRECEDENCE - 5,但是由于 Freemarker 总是被优先加载,而排序时由于两者优先级相同所以位置不变,所以在具体代码实践中,FreeMarkerViewResolver 总是排在 ThymeleafViewResolver 前面,FreeMarkerView 会自动检查视图是否存在,所以这样的排序刚刚恰到好处。在具体代码实践中,如果我们在项目中同时引入了 Freemarker 和 Thymeleaf,可以不用做任何配置直接同时使用这两种视图解析器。

这里要吐槽一下,网上看多人说默认情况下 Freemarker 优先级高于 Thymeleaf,不知道谁抄谁的,反正都说错了,还是要严谨呀!

第三种情况:

Freemarker+Jsp 组合,如果项目中同时使用了这两种视图解析器,则只需要对 jsp 进行常规配置即可,不需要额外配置。所谓的常规配置就是首先引入所需依赖:

  1. <dependency> 
  2.     <groupId>org.apache.tomcat.embed</groupId> 
  3.     <artifactId>tomcat-embed-jasper</artifactId> 
  4.     <scope>provided</scope> 
  5. </dependency> 
  6. <dependency> 
  7.     <groupId>javax.servlet</groupId> 
  8.     <artifactId>jstl</artifactId> 
  9. </dependency> 

然后配置一下 jsp 视图的前缀后缀啥的:

  1. @Configuration 
  2. public class WebConfig implements WebMvcConfigurer { 
  3.     @Override 
  4.     public void configureViewResolvers(ViewResolverRegistry registry) { 
  5.         registry.jsp("/"".jsp"); 
  6.     } 

这就可以了。

为什么这个组合这么简单呢?原因如下:

在 Spring 设计中,InternalResourceView 其实就是兜底的,所以它不会检查视图是否真的存在,它的优先级也是最低的。

由于 InternalResourceView 的优先级最低,排在 Freemarker 后面,而 Freemarker 会自动检查视图是否存在,所以对于这个组合我们不需要额外配置。

第四种情况:

Thymeleaf+Jsp 组合。这个组合稍微有点麻烦,因为 Thymeleaf 和 InternalResourceView 都不会去检查视图是否存在,而 Thymeleaf 的优先级高于 Jsp,所以 Thymeleaf 会“吞掉” Jsp 视图的请求。

想要这两个视图解析器同时存在,必须要有一个视图解析器具备检查视图是否存在的能力。Jsp 在这块的配置相对容易一些,所以我们选择对 InternalResourceView 做一些定制。

具体办法如下,首先定义类继承自 InternalResourceView 并重写 checkResource 方法:

  1. public class HandleResourceViewExists extends InternalResourceView { 
  2.     @Override 
  3.     public boolean checkResource(Locale locale) { 
  4.         File file = new File(this.getServletContext().getRealPath("/") + getUrl()); 
  5.         //判断页面是否存在 
  6.         return file.exists(); 
  7.     } 

InternalResourceView 默认的 checkResource 方法总是返回 true,现在我们稍微修改一下,让它去判断一下视图文件是否存在,如果存在,返回 true,否则返回 false。

配置完成后,将新的 HandleResourceViewExists 重新配置,同时修改优先级,使之优先级大于 ThymeleafViewResolver,如下:

  1. @Configuration 
  2. public class WebConfig implements WebMvcConfigurer { 
  3.     @Override 
  4.     public void configureViewResolvers(ViewResolverRegistry registry) { 
  5.         registry.jsp("/"".jsp").viewClass(HandleResourceViewExists.class); 
  6.         registry.order(1); 
  7.     } 

如此之后,这两个视图解析器就可以同时存在了。

第五种情况:

Freemarker+Thymeleaf+Jsp,看了前面四种,第五种情况应该就不用我多说了吧~

好啦,这个问题从原理到应用,都给大伙捋了一遍了,感兴趣的小伙伴赶紧试试哦~

本文转载自微信公众号「江南一点雨」,可以通过以下二维码关注。转载本文请联系江南一点雨公众号。

 

责任编辑:武晓燕 来源: 江南一点雨
相关推荐

2013-01-15 09:41:45

编程语言

2013-01-24 09:44:44

数据库

2022-07-26 23:43:29

编程语言开发Java

2017-12-21 19:38:50

润乾中间表

2015-05-18 15:08:08

多种程序设计语言程序设计语言

2021-08-31 09:35:01

TCPIP漏洞

2018-04-18 10:16:23

数字化转型

2017-02-24 13:20:13

搜索引擎数据结构架构

2018-07-16 09:12:00

程序员奇葩开发

2022-06-01 12:04:02

项目Webpack

2023-07-26 00:32:33

注解抽象spring

2020-06-01 08:04:18

三目运算符代码

2017-07-04 14:01:40

机房机柜

2017-07-12 08:20:32

闪存用途企业

2024-03-18 09:24:12

RocketMQ消息模型分布式

2009-09-10 09:54:24

虚拟化LinuxLinux操作系统

2015-11-02 11:46:36

bug开发程序员

2020-10-15 13:19:24

为什么会存在乱码

2021-09-17 16:31:30

GPUCPUTPU

2020-11-02 08:35:59

内存数据库Redis
点赞
收藏

51CTO技术栈公众号