维护老项目的时候,我们总会遇到一些奇奇怪怪的需求,解决这些奇葩问题可能才是我们开发的常态。
这不,最近就有小伙伴问了这样一个问题:
这个小伙伴想在 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 的继承类:
不过在 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 分为两个步骤:
- 调用各个 ViewResolver 中的 resolveViewName 方法去加载出对应的 View 对象。
- 根据 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,看了前面四种,第五种情况应该就不用我多说了吧~
好啦,这个问题从原理到应用,都给大伙捋了一遍了,感兴趣的小伙伴赶紧试试哦~
本文转载自微信公众号「江南一点雨」,可以通过以下二维码关注。转载本文请联系江南一点雨公众号。