Spring Boot 中這個默認視圖名有點意思,看懂直呼內行內行!

松哥原創的 Spring Boot 視頻教程已經殺青,感興趣的小夥伴戳這裏-->Spring Boot+Vue+微人事視頻教程


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

本文分享自微信公衆號 - 江南一點雨(a_javaboy)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章