3

Spring Boot 中这个默认视图名有点意思,看懂直呼内行内行!

 3 years ago
source link: http://www.javaboy.org/2021/0407/springmvc-default-viewname.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Spring Boot 中这个默认视图名有点意思,看懂直呼内行内行!

[TOC]

在 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,进而导致上一个方法抛出异常。

好啦,今天主要和小伙伴们分享了一下 SpringMVC 中默认视图名的问题,不知道大家有没有 GET 到呢~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK