8

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

 3 years ago
source link: http://developer.51cto.com/art/202102/644609.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

fQVJrqA.jpg!mobile

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

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

Uv6raqQ.jpg!mobile

这个小伙伴想在 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<String, ViewResolver> 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<String, Object> 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 的继承类:

ruimEfY.jpg!mobile

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

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

public View resolveViewName(String viewName, Locale locale) throws Exception { 
 RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); 
 List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest()); 
 if (requestedMediaTypes != null) { 
  List<View> 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<View> getCandidateViews(String viewName, Locale locale, List<MediaType> requestedMediaTypes) 
  throws Exception { 
 List<View> 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<String> 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 进行常规配置即可,不需要额外配置。所谓的常规配置就是首先引入所需依赖:

<dependency> 
    <groupId>org.apache.tomcat.embed</groupId> 
    <artifactId>tomcat-embed-jasper</artifactId> 
    <scope>provided</scope> 
</dependency> 
<dependency> 
    <groupId>javax.servlet</groupId> 
    <artifactId>jstl</artifactId> 
</dependency> 

然后配置一下 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,看了前面四种,第五种情况应该就不用我多说了吧~

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK