松哥原創的 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 則主要乾了如下幾件事:
-
去掉路徑開始的 /
。 -
去掉路徑結尾的 /
。 -
如果請求路徑有擴展名,則去掉擴展名,例如請求路徑是 /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 在最終渲染之前,會有一個預處理,代碼如下:
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源創計劃”,歡迎正在閱讀的你也加入,一起分享。