在 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 方法的执行步骤,还没看的小伙伴可以先看看:

在这篇文章中,有一个小小细节,就是在 doDispatch 方法中,有如下一段代码:

applyDefaultViewName(processedRequest, mv);

当这段代码执行的时候,接口方法已经通过反射调用完成了,并且将返回值封装成了一个 ModelAndView 对象(如果接口方法用到了 @ResponseBody 注解,则此时拿到的 ModelAndView 对象为 null),但是这个时候的 ModelAndView 对象还没有渲染,此时会调用 applyDefaultViewName 方法去判断返回的 ModelAndView 对象中有没有 view,如果没有,则给出一个默认的视图名。

这行代码就是切入点,接下来我们就来分析一下 applyDefaultViewName 方法。

# 2.applyDefaultViewName

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

@Nullable
protected String getDefaultViewName(HttpServletRequest request) throws Exception {
	return (this.viewNameTranslator != null ? this.viewNameTranslator.getViewName(request) : null);
}

这里涉及到一个新的组件 viewNameTranslator,如果 viewNameTranslator 不为 null,则调用其 getViewName 方法获取默认的视图名。

viewNameTranslator 其实就是 RequestToViewNameTranslator,我们一起来看下:

public interface RequestToViewNameTranslator {
	@Nullable
	String getViewName(HttpServletRequest request) throws Exception;
}

这个接口很简单,里边就一个方法 getViewName 方法来返回视图名称。在 SpringMVC 中,RequestToViewNameTranslator 接口只有一个默认的实现类 DefaultRequestToViewNameTranslator,我们来看下实现类中的 getViewName 方法:

@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 则主要干了如下几件事:

  1. 去掉路径开始的 /
  2. 去掉路径结尾的 /
  3. 如果请求路径有扩展名,则去掉扩展名,例如请求路径是 /01.txt,经过这一步处理后,就变成了 /01
  4. 如果 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 在最终渲染之前,会有一个预处理,代码如下:

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 方法中:

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 方法中获取到的,如下:

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 到呢~