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

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

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

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

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

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

初始化方法

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

private void initViewResolvers(ApplicationContext context) { 
 this.viewResolvers = null; 
 if (this.detectAllViewResolvers) { 
  // Find all ViewResolvers in the ApplicationContext, including ancestor contexts. 
  Map matchingBeans = 
    BeanFactoryUtils.beansOfTypeIncludingAncestors(context, ViewResolver.class, true, false); 
  if (!matchingBeans.isEmpty()) { 
   this.viewResolvers = new ArrayList(matchingBeans.values()); 
   // We keep ViewResolvers in sorted order. 
   AnnotationAwareOrderComparator.sort(this.viewResolvers); 
  } 
 } 
 else { 
  try { 
   ViewResolver vr = context.getBean(VIEW_RESOLVER_BEAN_NAME, ViewResolver.class); 
   this.viewResolvers = Collections.singletonList(vr); 
  } 
  catch (NoSuchBeanDefinitionException ex) { 
   // Ignore, we'll add a default ViewResolver later. 
  } 
 } 
 // Ensure we have at least one ViewResolver, by registering 
 // a default ViewResolver if no other resolvers are found. 
 if (this.viewResolvers == null) { 
  this.viewResolvers = getDefaultStrategies(context, ViewResolver.class); 
 } 
} 

这段代码的逻辑很清楚:

  • 首先将 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 方法中包含如下一段代码:

View view; 
String viewName = mv.getViewName(); 
if (viewName != null) { 
 // We need to resolve the view name. 
 view = resolveViewName(viewName, mv.getModelInternal(), locale, request); 
 if (view == null) { 
  throw new ServletException("Could not resolve view with name '" + mv.getViewName() + 
    "' in servlet with name '" + getServletName() + "'"); 
 } 
} 
else { 
 // No need to lookup: the ModelAndView object contains the actual View object. 
 view = mv.getView(); 
 if (view == null) { 
  throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a " + 
    "View object in servlet with name '" + getServletName() + "'"); 
 } 
} 

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

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

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

protected View resolveViewName(String viewName, @Nullable Map model, 
  Locale locale, HttpServletRequest request) throws Exception { 
 if (this.viewResolvers != null) { 
  for (ViewResolver viewResolver : this.viewResolvers) { 
   View view = viewResolver.resolveViewName(viewName, locale); 
   if (view != null) { 
    return view; 
   } 
  } 
 } 
 return null; 
} 

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

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

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

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

public View resolveViewName(String viewName, Locale locale) throws Exception { 
 RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); 
 List requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest()); 
 if (requestedMediaTypes != null) { 
  List candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes); 
  View bestView = getBestView(candidateViews, requestedMediaTypes, attrs); 
  if (bestView != null) { 
   return bestView; 
  } 
 } 
 if (this.useNotAcceptableStatusCode) { 
  return NOT_ACCEPTABLE_VIEW; 
 } 
 else { 
  return null; 
 } 
} 

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

  • 首先是获取到当前的请求对象,可以直接从 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:

private List getCandidateViews(String viewName, Locale locale, List requestedMediaTypes) 
  throws Exception { 
 List candidateViews = new ArrayList(); 
 if (this.viewResolvers != null) { 
  for (ViewResolver viewResolver : this.viewResolvers) { 
   View view = viewResolver.resolveViewName(viewName, locale); 
   if (view != null) { 
    candidateViews.add(view); 
   } 
   for (MediaType requestedMediaType : requestedMediaTypes) { 
    List extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType); 
    for (String extension : extensions) { 
     String viewNameWithExtension = viewName + '.' + extension; 
     view = viewResolver.resolveViewName(viewNameWithExtension, locale); 
     if (view != null) { 
      candidateViews.add(view); 
     } 
    } 
   } 
  } 
 } 
 if (!CollectionUtils.isEmpty(this.defaultViews)) { 
  candidateViews.addAll(this.defaultViews); 
 } 
 return candidateViews; 
} 

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

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

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

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

protected View loadView(String viewName, Locale locale) throws Exception { 
 AbstractUrlBasedView view = buildView(viewName); 
 View result = applyLifecycleMethods(viewName, view); 
 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 进行常规配置即可,不需要额外配置。所谓的常规配置就是首先引入所需依赖:

 
    org.apache.tomcat.embed 
    tomcat-embed-jasper 
    provided 
 
 
    javax.servlet 
    jstl 
 

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

@Configuration 
public class WebConfig implements WebMvcConfigurer { 
    @Override 
    public void configureViewResolvers(ViewResolverRegistry registry) { 
        registry.jsp("/", ".jsp"); 
    } 
} 

这就可以了。

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

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

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

第四种情况:

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

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

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

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

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

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

@Configuration 
public class WebConfig implements WebMvcConfigurer { 
    @Override 
    public void configureViewResolvers(ViewResolverRegistry registry) { 
        registry.jsp("/", ".jsp").viewClass(HandleResourceViewExists.class); 
        registry.order(1); 
    } 
} 

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

第五种情况:

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

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