在 Spring Boot 项目中,有的时候我们想返回一段 JSON,结果却忘了写 @ResponseBody
注解,像下面这样:
@Controller
public class HelloController {
@GetMapping("/01")
public void hello() {
System.out.println("01");
}
}
这个时候当项目跑起来,肯定会报错,具体报什么错,则要看用的什么视图解析器,如果用了 Freemarker,你可能会看到如下错误:
这个错误是说陷入到循环调用中了。
如果用了 Thymeleaf,你可能会看到如下错误:
这个是说一个名叫 01 的视图不存在。
我只是少加了一个 @ResponseBody
注解而已,为什么用不同的视图解析器会报不同的错误?并且这些错误实在看不出和 @ResponseBody
注解有什么关联。
松哥今天就通过源码分析,来和大家把这个问题讲清楚。
1.方法入口
前面松哥刚刚和大家分享了 DispatcherServlet 的源码,并且和大家细致分析了 doDispatch 方法的执行步骤,还没看的小伙伴可以先看看:
- SpringMVC 源码分析之 DispatcherServlet
在这篇文章中,有一个小小细节,就是在 doDispatch 方法中,有如下一段代码:
代码语言:javascript复制applyDefaultViewName(processedRequest, mv);
当这段代码执行的时候,接口方法已经通过反射调用完成了,并且将返回值封装成了一个 ModelAndView 对象(如果接口方法用到了 @ResponseBody
注解,则此时拿到的 ModelAndView 对象为 null),但是这个时候的 ModelAndView 对象还没有渲染,此时会调用 applyDefaultViewName 方法去判断返回的 ModelAndView 对象中有没有 view,如果没有,则给出一个默认的视图名。
这行代码就是切入点,接下来我们就来分析一下 applyDefaultViewName 方法。
2.applyDefaultViewName
代码语言:javascript复制private void applyDefaultViewName(HttpServletRequest request, @Nullable ModelAndView mv) throws Exception {
if (mv != null && !mv.hasView()) {
String defaultViewName = getDefaultViewName(request);
if (defaultViewName != null) {
mv.setViewName(defaultViewName);
}
}
}
可以看到,这里的判断逻辑很简单,首先检查 mv 是否为 null(如果用户添加了 @ResponseBody
注解,mv 就为 null),然后去判断 mv 中是否包含视图,如果不包含视图,则调用 getDefaultViewName 方法去获取默认的视图名,并将获取到的默认视图名交给 mv。
3.getDefaultViewName
代码语言:javascript复制@Nullable
protected String getDefaultViewName(HttpServletRequest request) throws Exception {
return (this.viewNameTranslator != null ? this.viewNameTranslator.getViewName(request) : null);
}
这里涉及到一个新的组件 viewNameTranslator,如果 viewNameTranslator 不为 null,则调用其 getViewName 方法获取默认的视图名。
viewNameTranslator 其实就是 RequestToViewNameTranslator,我们一起来看下:
代码语言:javascript复制public interface RequestToViewNameTranslator {
@Nullable
String getViewName(HttpServletRequest request) throws Exception;
}
这个接口很简单,里边就一个方法 getViewName 方法来返回视图名称。在 SpringMVC 中,RequestToViewNameTranslator 接口只有一个默认的实现类 DefaultRequestToViewNameTranslator,我们来看下实现类中的 getViewName 方法:
代码语言:javascript复制@Override
public String getViewName(HttpServletRequest request) {
String path = ServletRequestPathUtils.getCachedPathValue(request);
return (this.prefix transformPath(path) this.suffix);
}
@Nullable
protected String transformPath(String lookupPath) {
String path = lookupPath;
if (this.stripLeadingSlash && path.startsWith(SLASH)) {
path = path.substring(1);
}
if (this.stripTrailingSlash && path.endsWith(SLASH)) {
path = path.substring(0, path.length() - 1);
}
if (this.stripExtension) {
path = StringUtils.stripFilenameExtension(path);
}
if (!SLASH.equals(this.separator)) {
path = StringUtils.replace(path, SLASH, this.separator);
}
return path;
}
在 getViewName 方法中,首先提取出来当前请求路径,如果请求地址是 http://localhost:8080/01
,那么这里提取出来的路径就是 /01
,然后通过 transformPath 方法对路径进行处理,再分别加上前后缀后返回,默认的前后缀都是空字符串(如有需要,也可以自行配置)。
transformPath 则主要干了如下几件事:
- 去掉路径开始的
/
。 - 去掉路径结尾的
/
。 - 如果请求路径有扩展名,则去掉扩展名,例如请求路径是
/01.txt
,经过这一步处理后,就变成了/01
。 - 如果 separator 与 SLASH 不同,则替换原来的分隔符(默认是相同的)。
好了,经过这一波处理后,正常情况下,我们就拿到了一个新的视图名,这个新的视图名就是你的请求路径。
例如请求路径是 http://localhost:8080/01
,那么获取到的默认视图名就是 01
。
现在大家就知道了,在没有写 @ResponseBody
的情况下,SpringMVC 会自动提取出一个默认的视图名,并且根据这个视图名去查找视图。
4.问题分析
要搞清楚这个问题,需要大家对视图解析器有一定了解,如果还不了解,可以先看看松哥之前的文章:
- SpringMVC 九大组件之 ViewResolver 深入分析
- SpringMVC 中如何同时存在多个视图解析器
看完视图解析器的分析之后,接下来的内容就很好理解了。
4.1 Freemarker
先来看使用了 Freemarker 后为什么报循环调用的错。
根据前面两篇文章的分析,现在我们在 Spring Boot 中默认使用的视图解析器是 ContentNegotiatingViewResolver,在这个视图解析器中会首先选出所有候选的 View,由于我们的代码中并不存在一个名为 01 的 Freemarker 视图(如果刚好存在一个名为 01 的 Freemarker 视图就不会报错了,就直接将该视图展示出来了),而 FreeMarkerViewResolver 的父类 UrlBasedViewResolver 中的 loadView 方法在加载视图的时候,会去检查视图是否存在,结果发现视图吧不存在,导致最终返回 null。所以当 01 这个视图不存在时,最终负责处理该视图的并不是 FreeMarkerViewResolver,而是否则兜底的 InternalResourceViewResolver,该视图解析器最终构建出来的视图就是 InternalResourceView。
InternalResourceView 在最终渲染之前,会有一个预处理,代码如下:
代码语言:javascript复制protected String prepareForRendering(HttpServletRequest request, HttpServletResponse response)
throws Exception {
String path = getUrl();
Assert.state(path != null, "'url' not set");
if (this.preventDispatchLoop) {
String uri = request.getRequestURI();
if (path.startsWith("/") ? uri.equals(path) : uri.equals(StringUtils.applyRelativePath(uri, path))) {
throw new ServletException("Circular view path [" path "]: would dispatch back "
"to the current handler URL [" uri "] again. Check your ViewResolver setup! "
"(Hint: This may be the result of an unspecified view, due to default view name generation.)");
}
}
return path;
}
这个地方的 getUrl 参数是在 buildView 方法中设置的(具体参见:SpringMVC 九大组件之 ViewResolver 深入分析),它返回的视图的完整路径名,也就是 prefix viewName suffix
,如果这个路径和当前请求路径一致,就抛出异常,抛出的异常就是我们一开始截图中看到的异常(其实异常中也说了,这个问题可能是由于自动生成 viewName 导致的)。
这就是为什么当我们使用 Freemarker 依赖时报循环请求的异常。
4.2 Thymeleaf
再来看 Thymeleaf,使用 Thymeleaf 时报的异常是模版不存在。
首先我们找到异常抛出的位置是在 TemplateManager#resolveTemplate 方法中:
代码语言:javascript复制private static TemplateResolution resolveTemplate(
final IEngineConfiguration configuration,
final String ownerTemplate,
final String template,
final Map<String, Object> templateResolutionAttributes,
final boolean failIfNotExists) {
for (final ITemplateResolver templateResolver : configuration.getTemplateResolvers()) {
final TemplateResolution templateResolution =
templateResolver.resolveTemplate(configuration, ownerTemplate, template, templateResolutionAttributes);
if (templateResolution != null) {
return templateResolution;
}
}
if (!failIfNotExists) {
return null;
}
throw new TemplateInputException(
"Error resolving template [" template "], "
"template might not exist or might not be accessible by "
"any of the configured Template Resolvers");
}
可以看到,这个方法在执行的过程中如果没能提前返回,最终就会抛出异常,抛出的异常也就是我们在控制台所看到的异常。执行到这一步的原因是前面获取到的 templateResolution 为 null,并且 failIfNotExists 参数为 true,failIfNotExists 参数在调用的时候固定传入,这个没啥好说的,问题的核心在于获取到的 templateResolution 是否为 null。
templateResolution 则是在 AbstractTemplateResolver#resolveTemplate 方法中获取到的,如下:
代码语言:javascript复制public final TemplateResolution resolveTemplate(
final IEngineConfiguration configuration,
final String ownerTemplate, final String template,
final Map<String, Object> templateResolutionAttributes) {
if (!computeResolvable(configuration, ownerTemplate, template, templateResolutionAttributes)) {
return null;
}
final ITemplateResource templateResource = computeTemplateResource(configuration, ownerTemplate, template, templateResolutionAttributes);
if (templateResource == null) {
return null;
}
if (this.checkExistence && !templateResource.exists()) { // will only check if flag set to true
return null;
}
return new TemplateResolution(
templateResource,
this.checkExistence,
computeTemplateMode(configuration, ownerTemplate, template, templateResolutionAttributes),
this.useDecoupledLogic,
computeValidity(configuration, ownerTemplate, template, templateResolutionAttributes));
}
可以看到,在拿到 templateResource 之后,会调用 templateResource.exists()
方法判断资源是否存在,也就是相应的模版文件是否存在,如果不存在就会返回 null,进而导致上一个方法抛出异常。
5.小结
好啦,今天主要和小伙伴们分享了一下 SpringMVC 中默认视图名的问题,不知道大家有没有 GET 到呢~