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

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

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

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

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

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

初始化方法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 方法中包含如下一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 方法中的执行逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
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 的继承类:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 方法,我们来看下这个方法:

1
2
3
4
5
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 进行常规配置即可,不需要额外配置。所谓的常规配置就是首先引入所需依赖:

1
2
3
4
5
6
7
8
9
<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 视图的前缀后缀啥的:

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

这就可以了。

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

  1. 在 Spring 设计中,InternalResourceView 其实就是兜底的,所以它不会检查视图是否真的存在,它的优先级也是最低的。
  2. 由于 InternalResourceView 的优先级最低,排在 Freemarker 后面,而 Freemarker 会自动检查视图是否存在,所以对于这个组合我们不需要额外配置。

第四种情况:

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

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

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

1
2
3
4
5
6
7
8
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,如下:

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

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

第五种情况:

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

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

对了,这个问题松哥也录制了相关的视频,感兴趣的小伙伴可以在公众号后台回复 vhr 查看视频详情。

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×