在维护老项目的时候,我们总会遇到一些奇葩的需求,而解决这些奇葩的问题可能是我们开发的常态。这不,最近有个小伙伴问了这样一个问题:这位小伙伴想在SpringBoot中同时使用多个视图解析器。一般来说,我们平时设计一个项目的时候,肯定不会这么弄的,要么前后端分离不需要viewresolver,要么不管前后端都需要viewresolver,但是即使需要,也只会使用一个视图解析器,不会将多个视图解析器混在一起。不过既然小伙伴们提出了这个问题,那就看看这个要求能不能实现吧!先说结论:从技术上讲,这当然是可以实现的,而且实现的方法也不难。但是要彻底理解这个问题,这就涉及到SpringMVC的工作原理了。今天宋哥就来和大家一起梳理一下这个问题。初始化方法在SpringMVC中,我们可以配置多个视图解析器,最终会在DispatcherServlet#initViewResolvers方法中加载,如下:,includingancestorcontexts.MapmatchingBeans=BeanFactoryUtils.beansOfTypeIncludingAncestors(context,ViewResolver.class,true,false);如果(!matchingBeans.isEmpty()){this.viewResolvers=newArrayList<>(matchingBeans.values());//WekeepViewResolversinsertedorder.AnnotationAwareOrderComparator.sort(this.viewResolvers);}}else{try{ViewResolvervr=context.getBean(VIEW_RESOLVER_BEAN_NAME,ViewResolver.class);this.viewResolvers=Collections.singletonList(vr);}catch(NoSuchBeanDefinitionException){//忽略,稍后我们会添加defaultViewResolver。}}//确保我们有至少一个ViewResolver,通过注册//defaultViewResolver如果没有找到其他resolvers.if(this.viewResolvers==null){this.viewResolvers=getDefaultStrategies(context,ViewResolver.class);}}这段代码的逻辑很清晰:首先清空viewResolvers变量,里面会存放所有viewresolver,然后根据detectAllViewResolversResolver的变量值决定是否加载所有view,这个变量默认为true,表示加载所有视图解析器。加载所有视图解析器就是在Spring容器中找到所有的ViewResolver实例,然后根据Order优先级对这些ViewResolver实例进行排序。如果detectAllViewResolvers的变量值为false,则表示只加载名为viewResolver的视图解析器。经过前面的步骤,如果viewResolvers仍然为null,则说明用户根本没有配置视图解析器。这个时候调用getDefaultStrategies方法加载一个默认的视图解析器,保证我们的系统中至少有一个视图解析器。一般来说,在一个SSM项目中,如果我们没有在SpringMVC的配置文件中配置任何ViewResolver,那么就会进入第三步。initViewResolvers方法的主要目的是初始化和排序视图解析器。从这里我们也可以大致看出SpringMVC是支持多个视图解析器同时存在的。原理分析以上就是视图解析器的初始化过程。接下来,让我们看看视图解析器是如何工作的。小伙伴们知道,一个request进入DispatcherServlet后,执行方法流程是service->processRequest->doService->doDispatch->processDispatchResult->render->resolveViewName->...进入render方法就差不多走上正轨了,我们的页面渲染将在这个方法中完成。render方法包??含如下一段代码:(view==null){thrownewServletException("Couldnotresolveviewwithname'"+mv.getViewName()+"'inservletwithname'"+getServletName()+"'");}}else{//Noneedtolookup:theModelAndViewobjectcontainstheactualViewobject.view=mv.getView();if(view==null){thrownewServletException("ModelAndView["+mv+"]neithercontainsaviewnamenora"+"Viewobjectinservletwithname'"+getServletName()+"'");}}得到name之后可以看到查看到这里,接下来调用resolveViewName方法获取具体视图。在resolveViewName方法中,会根据视图名称和已有的视图解析器找到对应的视图。所以这里有一个问题。如果有多个现有的视图解析器,应该使用哪一个?我们来看看resolveViewName方法中的执行逻辑。protectedViewresolveViewName(StringviewName,@NullableMapmodel,Localelocale,HttpServletRequestrequest)throwsException{if(this.viewResolvers!=null){for(ViewResolverviewResolver:this.viewResolvers){Viewview=viewResolver.resolveViewName(viewName,locale);如果(view!=null){returnview;}}}returnnull;}可以看到,这里是遍历所有的ViewResolvers,调用它的resolveViewName方法找到对应的View,找到后返回。ViewResolver就是我们常说的视图解析器。我们使用JSP、Thymeleaf、Freemarker等,都有相应的视图解析器。从下图中,我们可以看到ViewResolver的继承类:但是在SpringBoot中,我们不会直接使用这些视图解析器,而是使用一个名为ContentNegotiatingViewResolver的视图解析器,它是Spring3.0引入的视图解析器。它不负责具体的视图解析,而是根据当前请求的MIME类型,从上下文中选择一个合适的视图解析器,并将请求工作委托给它。所以这里我们先看一下ContentNegotiatingViewResolver#resolveViewName方法:!=null){ListcandidateViews=getCandidateViews(viewName,locale,requestedMediaTypes);ViewbestView=getBestView(candidateViews,requestedMediaTypes,attrs);if(bestView!=null){returnbestView;}}if(this.useNotAcceptableStatusCode){returnNOT_ACCEPTABLE_VIEW;}else{returnnull;}}这里的代码逻辑也比较简单:首先获取当前请求对象,可以直接从RequestContextHolder中获取。然后从当前请求对象中提取MediaType。如果MediaType不为null,则根据MediaType,找到合适的viewresolver,返回解析后的View。如果MediaType为null,则有两种情况。如果useNotAcceptableStatusCode为真,则返回NOT_ACCEPTABLE_VIEW视图。这个视图其实是一个406响应,说明客户端错误,服务端无法提供Accept-Charset和Accept-Language头指定的信息。值匹配的响应;如果useNotAcceptableStatusCode为假,则返回null。现在问题的核心其实变成了getCandidateViews方法和getBestView方法。看名字就知道,前者是获取所有候选View,后者是从这些候选View中选出最好的View。让我们一一看看。先看getCandidateViews:privateListgetCandidateViews(StringviewName,Localelocale,ListrequestedMediaTypes)throwsException{ListcandidateViews=newArrayList<>();if(this.viewResolvers!=null){for(ViewResolverviewResolver:this.viewResolvers){Viewview=viewResolver.resolveViewName(viewName,locale);if(view!=null){candidateViews.add(view);}for(MediaTyperequestedMediaType:requestedMediaTypes){Listextensions=this.contentNegotiationManager.resolveFileExtensions(requestedMediaType);for(Stringextension:extensions){StringviewNameWithExtension=viewName+'.'+extension;view=viewResolver.resolveViewName(viewNameWithExtension,locale);if(view!=null){candidateViews.add(view);}}}}}if(!CollectionUtils.isEmpty(this.defaultViews)){candidateViews.addAll(this.defaultViews);}returncandidateViews;}获取所有候选View分为两步:在每个ViewResolver中调用resolveViewName方法加载对应的View对象。根据MediaType提取扩展,然后根据扩展加载View对象。在实际应用中,我们很少配置这一步,所以基本上一步加载不了View对象,主要还是靠第一步。加载View对象的第一步其实就是根据你的viewName,结合ViewResolver中配置的prefix、suffix、templateLocation等属性,找到对应的View。方法执行过程为resolveViewName->createView->loadView。具体的实现方法我就不一一贴出来了。唯一需要提及的重要事项是最终的loadView方法。我们来看看这个方法:protectedViewloadView(StringviewName,Localelocale)throwsException{AbstractUrlBasedViewview=buildView(viewName);Viewresult=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数组进行匹配。第一个匹配的是最好的视图。在这个过程中,它不会检查视图是否真的存在,所以有可能会选择一个根本不存在的视图,最终会导致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中的view不存在,导致在getBestView方法中,直接按顺序匹配到ThymeleafView,最终导致运行出错。在SpringBoot中,如果我们引入Freemarker和Thyemeleaf的启动器,默认情况下,Freemarker和Thymeleaf的优先级是一样的,Ordered.LOWEST_PRECEDENCE-5,但是由于Freemarker总是先加载,而且排序的时候由于两者的优先级是一样的所以位置保持不变,所以在具体的代码实践中,FreeMarkerViewResolver总是在ThymeleafViewResolver的前面,FreeMarkerView会自动检查view是否存在,所以这样排序刚刚好。在具体的代码实践中,如果我们在项目中同时引入Freemarker和Thymeleaf,我们可以同时使用这两个视图解析器,不需要任何配置。我想在这里抱怨。网上很多人说Freemarker默认优先级高于Thymeleaf。不知道是谁抄袭了谁。反正都是错的,一定要严谨!第三种情况:Freemarker+Jsp组合,如果项目中同时使用这两个viewresolver的话,只需要jsp的常规配置,不需要额外配置。所谓常规配置,就是先引入需要的依赖:org.apache.tomcat.embedtomcat-embed-jasperprovidedjavax.servletjstl然后配置jsp视图的前缀和后缀:@ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{@OverridepublicvoidconfigureViewResolvers(ViewResolverRegistryregistry){registry("/",".jsp");}}就是这样。为什么这个组合这么简单?原因如下:在Spring的设计中,InternalResourceView其实是最底层的,所以它不会检查视图是否真的存在,它的优先级是最低的。由于InternalResourceView的优先级是最低的,在Freemarker之后,Freemarker会自动检查view是否存在,所以这个组合我们不需要额外配置。第四种情况:Thymeleaf+Jsp组合。这种组合有点麻烦,因为Thymeleaf和InternalResourceView都不会检查视图是否存在,而且Thymeleaf的优先级高于Jsp,所以Thymeleaf会“吞掉”对Jsp视图的请求。为了使这两个视图解析器同时存在,一个视图解析器必须具有检查视图是否存在的能力。Jsp在这方面的配置比较容易,所以我们选择对InternalResourceView做一些自定义。具体方法如下,先定义继承自InternalResourceView的类,重写checkResource方法://判断页面Returnfile.exists();}}InternalResourceView默认的checkResource方法总是返回true,现在我们稍微修改一下,让它判断视图文件是否存在,存在则返回true,否则返回false。配置完成后,重新配置新的HandleResourceViewExists,修改优先级,使其高于ThymeleafViewResolver,如下:.class);registry.order(1);}}在此之后,两个视图解析器可以同时存在。第五个案例:Freemarker+Thymeleaf+Jsp,看完前面四个,第五个案例我就不用多说了~好了,这个问题从原理到应用都给大家整理好了,我感兴趣的小伙伴们赶紧试试吧~本文转载自微信公众号“江南的一场小雨”,可以通过以下二维码关注。转载本文请联系江南一点鱼公众号。