[TOC]
在 Spring Boot 项目中,有的时候我们想返回一段 JSON,结果却忘了写 @ResponseBody
注解,像下面这样:
1 | @Controller |
这个时候当项目跑起来,肯定会报错,具体报什么错,则要看用的什么视图解析器,如果用了 Freemarker,你可能会看到如下错误:
这个错误是说陷入到循环调用中了。
如果用了 Thymeleaf,你可能会看到如下错误:
这个是说一个名叫 01 的视图不存在。
我只是少加了一个 @ResponseBody
注解而已,为什么用不同的视图解析器会报不同的错误?并且这些错误实在看不出和 @ResponseBody
注解有什么关联。
松哥今天就通过源码分析,来和大家把这个问题讲清楚。
1.方法入口
前面松哥刚刚和大家分享了 DispatcherServlet 的源码,并且和大家细致分析了 doDispatch 方法的执行步骤,还没看的小伙伴可以先看看:
在这篇文章中,有一个小小细节,就是在 doDispatch 方法中,有如下一段代码:
1 | applyDefaultViewName(processedRequest, mv); |
当这段代码执行的时候,接口方法已经通过反射调用完成了,并且将返回值封装成了一个 ModelAndView 对象(如果接口方法用到了 @ResponseBody
注解,则此时拿到的 ModelAndView 对象为 null),但是这个时候的 ModelAndView 对象还没有渲染,此时会调用 applyDefaultViewName 方法去判断返回的 ModelAndView 对象中有没有 view,如果没有,则给出一个默认的视图名。
这行代码就是切入点,接下来我们就来分析一下 applyDefaultViewName 方法。
2.applyDefaultViewName
1 | private void applyDefaultViewName(HttpServletRequest request, @Nullable ModelAndView mv) throws Exception { |
可以看到,这里的判断逻辑很简单,首先检查 mv 是否为 null(如果用户添加了 @ResponseBody
注解,mv 就为 null),然后去判断 mv 中是否包含视图,如果不包含视图,则调用 getDefaultViewName 方法去获取默认的视图名,并将获取到的默认视图名交给 mv。
3.getDefaultViewName
1 | @Nullable |
这里涉及到一个新的组件 viewNameTranslator,如果 viewNameTranslator 不为 null,则调用其 getViewName 方法获取默认的视图名。
viewNameTranslator 其实就是 RequestToViewNameTranslator,我们一起来看下:
1 | public interface RequestToViewNameTranslator { |
这个接口很简单,里边就一个方法 getViewName 方法来返回视图名称。在 SpringMVC 中,RequestToViewNameTranslator 接口只有一个默认的实现类 DefaultRequestToViewNameTranslator,我们来看下实现类中的 getViewName 方法:
1 | @Override |
在 getViewName 方法中,首先提取出来当前请求路径,如果请求地址是 http://localhost:8080/01
,那么这里提取出来的路径就是 /01
,然后通过 transformPath 方法对路径进行处理,再分别加上前后缀后返回,默认的前后缀都是空字符串(如有需要,也可以自行配置)。
transformPath 则主要干了如下几件事:
- 去掉路径开始的
/
。 - 去掉路径结尾的
/
。 - 如果请求路径有扩展名,则去掉扩展名,例如请求路径是
/01.txt
,经过这一步处理后,就变成了/01
。 - 如果 separator 与 SLASH 不同,则替换原来的分隔符(默认是相同的)。
好了,经过这一波处理后,正常情况下,我们就拿到了一个新的视图名,这个新的视图名就是你的请求路径。
例如请求路径是 http://localhost:8080/01
,那么获取到的默认视图名就是 01
。
现在大家就知道了,在没有写 @ResponseBody
的情况下,SpringMVC 会自动提取出一个默认的视图名,并且根据这个视图名去查找视图。
4.问题分析
要搞清楚这个问题,需要大家对视图解析器有一定了解,如果还不了解,可以先看看松哥之前的文章:
看完视图解析器的分析之后,接下来的内容就很好理解了。
4.1 Freemarker
先来看使用了 Freemarker 后为什么报循环调用的错。
根据前面两篇文章的分析,现在我们在 Spring Boot 中默认使用的视图解析器是 ContentNegotiatingViewResolver,在这个视图解析器中会首先选出所有候选的 View,由于我们的代码中并不存在一个名为 01 的 Freemarker 视图(如果刚好存在一个名为 01 的 Freemarker 视图就不会报错了,就直接将该视图展示出来了),而 FreeMarkerViewResolver 的父类 UrlBasedViewResolver 中的 loadView 方法在加载视图的时候,会去检查视图是否存在,结果发现视图吧不存在,导致最终返回 null。所以当 01 这个视图不存在时,最终负责处理该视图的并不是 FreeMarkerViewResolver,而是否则兜底的 InternalResourceViewResolver,该视图解析器最终构建出来的视图就是 InternalResourceView。
InternalResourceView 在最终渲染之前,会有一个预处理,代码如下:
1 | protected String prepareForRendering(HttpServletRequest request, HttpServletResponse response) |
这个地方的 getUrl 参数是在 buildView 方法中设置的(具体参见:SpringMVC 九大组件之 ViewResolver 深入分析),它返回的视图的完整路径名,也就是 prefix + viewName + suffix
,如果这个路径和当前请求路径一致,就抛出异常,抛出的异常就是我们一开始截图中看到的异常(其实异常中也说了,这个问题可能是由于自动生成 viewName 导致的)。
这就是为什么当我们使用 Freemarker 依赖时报循环请求的异常。
4.2 Thymeleaf
再来看 Thymeleaf,使用 Thymeleaf 时报的异常是模版不存在。
首先我们找到异常抛出的位置是在 TemplateManager#resolveTemplate 方法中:
1 | private static TemplateResolution resolveTemplate( |
可以看到,这个方法在执行的过程中如果没能提前返回,最终就会抛出异常,抛出的异常也就是我们在控制台所看到的异常。执行到这一步的原因是前面获取到的 templateResolution 为 null,并且 failIfNotExists 参数为 true,failIfNotExists 参数在调用的时候固定传入,这个没啥好说的,问题的核心在于获取到的 templateResolution 是否为 null。
templateResolution 则是在 AbstractTemplateResolver#resolveTemplate 方法中获取到的,如下:
1 | public final TemplateResolution resolveTemplate( |
可以看到,在拿到 templateResource 之后,会调用 templateResource.exists()
方法判断资源是否存在,也就是相应的模版文件是否存在,如果不存在就会返回 null,进而导致上一个方法抛出异常。
5.小结
好啦,今天主要和小伙伴们分享了一下 SpringMVC 中默认视图名的问题,不知道大家有没有 GET 到呢~