SpringBoot錯誤處理流程深入詳解

一、錯誤處理

默認情況下,Spring Boot提供/error處理所有錯誤的映射

對於機器客戶端(例如PostMan),它將生成JSON響應,其中包含錯誤,HTTP狀態和異常消息的詳細信息(如果設置瞭攔截器,需要在請求頭中塞入Cookie相關參數)

對於瀏覽器客戶端,響應一個“ whitelabel”錯誤視圖,以HTML格式呈現相同的數據

另外,templates下面error文件夾中的4xx,5xx頁面會被自動解析

二、底層相關組件

那麼Spring Boot是怎麼實現上述的錯誤頁相關功能的呢?

我們又要來找一下相關源碼進行分析瞭

首先我們先瞭解一個概念:@Bean配置的類的默認id是方法的名稱,但是我們可以通過value或者name給這個bean取別名,兩者不可同時使用

我們進入ErrorMvcAutoConfiguration,看這個類名應該是和錯誤處理的自動配置有關,我們看下這個類做瞭什麼

向容器中註冊類型為DefaultErrorAttributes,id為errorAttributes的bean(管理錯誤信息,如果要自定義錯誤頁面打印的字段,就自定義它),這個類實現瞭ErrorAttributes, HandlerExceptionResolver(異常處理解析器接口), Ordered三個接口

@Bean
@ConditionalOnMissingBean(
    value = {ErrorAttributes.class},
    search = SearchStrategy.CURRENT
)
public DefaultErrorAttributes errorAttributes() {
    return new DefaultErrorAttributes();
}

點進去後發現,這個類是和我們響應頁面中的message、error等字段有關

向容器中註冊一個id為basicErrorController的控制器bean(管理錯誤相應邏輯,不想返回json或者錯誤視圖,就自定義它)

@Bean
@ConditionalOnMissingBean(
    value = {ErrorController.class},
    search = SearchStrategy.CURRENT
)
public BasicErrorController basicErrorController(ErrorAttributes errorAttributes, ObjectProvider<ErrorViewResolver> errorViewResolvers) {
    return new BasicErrorController(errorAttributes, this.serverProperties.getError(), (List)errorViewResolvers.orderedStream().collect(Collectors.toList()));
}

這個控制器就和前面我們返回json或者錯誤視圖有關

聲明類型為DefaultErrorViewResolver,id為conventionErrorViewResolver的bean(管理錯誤視圖跳轉路徑,如果要改變跳轉路徑,就自定義它)

@Configuration(
   proxyBeanMethods = false
)
@EnableConfigurationProperties({WebProperties.class, WebMvcProperties.class})
static class DefaultErrorViewResolverConfiguration {
   private final ApplicationContext applicationContext;
   private final Resources resources;
   DefaultErrorViewResolverConfiguration(ApplicationContext applicationContext, WebProperties webProperties) {
       this.applicationContext = applicationContext;
       this.resources = webProperties.getResources();
   }
   @Bean
   @ConditionalOnBean({DispatcherServlet.class})
   @ConditionalOnMissingBean({ErrorViewResolver.class})
   DefaultErrorViewResolver conventionErrorViewResolver() {
       return new DefaultErrorViewResolver(this.applicationContext, this.resources);
   }
}

這個類中,解釋瞭為什麼前面會根據不同的狀態碼轉向不同的錯誤頁

聲明一個靜態內部類WhitelabelErrorViewConfiguration,它與錯誤視圖配置相關,這個類中聲明瞭一個id為error的視圖對象提供給basicErrorController中使用,還定義瞭視圖解析器BeanNameViewResolver ,它會根據返回的視圖名作為組件的id去容器中找View對象

@Configuration(
   proxyBeanMethods = false
)
@ConditionalOnProperty(
   prefix = "server.error.whitelabel",
   name = {"enabled"},
   matchIfMissing = true
)
@Conditional({ErrorMvcAutoConfiguration.ErrorTemplateMissingCondition.class})
protected static class WhitelabelErrorViewConfiguration {
   private final ErrorMvcAutoConfiguration.StaticView defaultErrorView = new ErrorMvcAutoConfiguration.StaticView();
   protected WhitelabelErrorViewConfiguration() {
   }
   @Bean(
       name = {"error"}
   )
   @ConditionalOnMissingBean(
       name = {"error"}
   )
   public View defaultErrorView() {
       return this.defaultErrorView;
   }
   @Bean
   @ConditionalOnMissingBean
   public BeanNameViewResolver beanNameViewResolver() {
       BeanNameViewResolver resolver = new BeanNameViewResolver();
       resolver.setOrder(2147483637);
       return resolver;
   }
}

另外還聲明瞭一個靜態內部類StaticView,這裡面涉及錯誤視圖的渲染等相關操作

private static class StaticView implements View {
   private static final MediaType TEXT_HTML_UTF8;
   private static final Log logger;
   private StaticView() {
   }
   public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
       if (response.isCommitted()) {
           String message = this.getMessage(model);
           logger.error(message);
       } else {
           response.setContentType(TEXT_HTML_UTF8.toString());
           StringBuilder builder = new StringBuilder();
           Object timestamp = model.get("timestamp");
           Object message = model.get("message");
           Object trace = model.get("trace");
           if (response.getContentType() == null) {
               response.setContentType(this.getContentType());
           }
           ...

三、異常處理流程

為瞭瞭解Spring Boot的異常處理流程,我們寫一個demo進行debug

首先寫一個會發生算術運算異常的接口/test_error

/**
 * 測試報錯信息
 * @return 跳轉錯誤頁面
 */
@GetMapping(value = "/test_error")
public String testError() {
    int a = 1/0;
    return String.valueOf(a);
}

然後放置一個錯誤頁面5xx.html於templates下的error文件夾中

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
  <meta name="description" content="">
  <meta name="author" content="ThemeBucket">
  <link rel="shortcut icon" href="#" rel="external nofollow"  rel="external nofollow"  type="image/png">
  <title>500 Page</title>
  <link href="css/style.css" rel="external nofollow"  rel="stylesheet">
  <link href="css/style-responsive.css" rel="external nofollow"  rel="stylesheet">
  <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
  <!--[if lt IE 9]>
  <script src="js/html5shiv.js"></script>
  <script src="js/respond.min.js"></script>
  <![endif]-->
</head>
<body class="error-page">
<section>
    <div class="container ">
        <section class="error-wrapper text-center">
            <h1><img alt="" src="images/500-error.png"></h1>
            <h2>OOOPS!!!</h2>
            <h3 th:text="${message}">Something went wrong.</h3>
            <p class="nrml-txt" th:text="${trace}">Why not try refreshing you page? Or you can <a href="#" rel="external nofollow"  rel="external nofollow" >contact our support</a> if the problem persists.</p>
            <a class="back-btn" href="index.html" rel="external nofollow"  th:text="${status}"> Back To Home</a>
        </section>
    </div>
</section>
<!-- Placed js at the end of the document so the pages load faster -->
<script src="js/jquery-1.10.2.min.js"></script>
<script src="js/jquery-migrate-1.2.1.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/modernizr.min.js"></script>
<!--common scripts for all pages-->
<!--<script src="js/scripts.js"></script>-->
</body>
</html>

然後我們開啟debug模式,發送請求

首先,我們的斷點還是來到DispatcherServlet類下的doDispatch()方法

經過mv = ha.handle(processedRequest, response, mappedHandler.getHandler());調用目標方法之後,他會返回相關錯誤信息,並將其塞入dispatchException這個對象

然後調用this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);處理調度結果

然後他會在processDispatchResult()中經過判斷是否存在異常,異常不為空,調用processHandlerException()方法,這裡它會遍歷系統中所有的異常處理解析器,哪個解析器返回結果不為null,就結束循環

在調用DefaultErrorAttributes時,它會將錯誤中的信息放入request請求域中(我們後面模板引擎頁面解析會用到)

遍歷完所有解析器,我們發現他們都不能返回一個不為空的ModelAndView對象,於是它會繼續拋出異常

當系統發現沒有任何人能處理這個異常時,底層就會發送 /error 請求,它就會被我們上面介紹的BasicErrorController下的errorHtml()方法處理

這個方法會通過ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);去遍歷系統中所有的錯誤視圖解析器,如果調用解析器的resolveErrorView()方法返回結果不為空就結束循環

系統中隻默認註冊瞭一個錯誤視圖解析器,也就是我們上面介紹的DefaultErrorViewResolver,跟隨debug斷點我們得知,這個解析器會把error+響應狀態碼作為錯誤頁的地址,最終返回給我們的視圖地址為error/5xx.html

四、定制錯誤處理邏輯

1、自定義錯誤頁面

error下的4xx.html和5xx.html,根據我們上面瞭解的DefaultErrorViewResolver類可以,它的resolveErrorView()方法在進行錯誤頁解析時,如果有精確的錯誤狀態碼頁面就匹配精確,沒有就找 4xx.html,如果都沒有就轉到系統默認的錯誤頁

2、使用註解或者默認的異常處理

@ControllerAdvice+@ExceptionHandler處理全局異常,我們結合一個demo來瞭解一下用法

首先我們創建一個類用來處理全局異常

package com.decade.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
@Slf4j
public class MyExceptionHandler {
   // 指定該方法處理某些指定異常,@ExceptionHandler的value可以是數組,這裡我們指定該方法處理數學運算異常和空指針異常
   @ExceptionHandler(value = {ArithmeticException.class, NullPointerException.class})
   public String handleArithmeticException(Exception exception) {
       log.error("異常信息為:{}", exception);
       // 打印完錯誤信息後,返回登錄頁
       return "login";
   }
}

我們還是使用上面的會發生算術運算異常的接口/test_error進行測試

請求接口後發現,頁面跳轉到登錄頁瞭

為什麼沒有再走到5xx.html呢?

因為@ControllerAdvice+@ExceptionHandler的底層是ExceptionHandlerExceptionResolver來處理的

這樣在進入DispatcherServlet類下的processHandlerException()方法時,就會調用ExceptionHandlerExceptionResolver這個異常處理解析器,從而跳轉到我們自己創建的異常處理類進行異常處理,然後返回不為null的ModelAndView對象給它,終止遍歷,不會再發送/error請求

@ResponseStatus+自定義異常

首先我們自定義一個異常類

package com.decade.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
// code對應錯誤碼,reason對應message
@ResponseStatus(code = HttpStatus.METHOD_NOT_ALLOWED, reason = "自定義異常")
public class CustomException extends RuntimeException {
   public CustomException() {
   }
   public CustomException(String message) {
       super(message);
   }
}

然後寫一個接口去拋出自定義異常

/**
* 測試報錯信息
* @return 跳轉錯誤頁面
*/
@GetMapping(value = "/test_responseStatus")
public String testResponseStatus(@RequestParam("param") String param) {
   if ("test_responseStatus".equals(param)) {
       throw new CustomException();
   }
   return "main";
}

最後我們調用接口,可以得到,跳轉到瞭4xx.html,但是狀態碼和message都和我們自己定義的匹配

那麼原理是什麼呢?我們還是從DispatcherServlet類下的processHandlerException()方法開始看

當我們拋出自定義異常時,由於前面@ControllerAdvice+@ExceptionHandler修飾的類沒有指定處理這個異常,所以循環走到下一個異常處理解析器ResponseStatusExceptionResolver

我們分析一下這裡的代碼

@Nullable
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
    try {
        if (ex instanceof ResponseStatusException) {
            return this.resolveResponseStatusException((ResponseStatusException)ex, request, response, handler);
        }
		// 由於我們自定義異常類使用瞭@ResponseStatus註解修飾,所以我們這裡獲取到的status信息不為空
        ResponseStatus status = (ResponseStatus)AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class);
        if (status != null) {
            return this.resolveResponseStatus(status, request, response, handler, ex);
        }
		...
protected ModelAndView resolveResponseStatus(ResponseStatus responseStatus, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) throws Exception {
    // 獲取@ResponseStatus註解的code和reason作為狀態碼和message
	int statusCode = responseStatus.code().value();
    String reason = responseStatus.reason();
    return this.applyStatusAndReason(statusCode, reason, response);
}
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response) throws IOException {
    if (!StringUtils.hasLength(reason)) {
        response.sendError(statusCode);
    } else {
        String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;
		// 發送/error請求,入參為@ResponseStatus註解的code和reason
        response.sendError(statusCode, resolvedReason);
    }
	// 返回一個modelAndView
    return new ModelAndView();
}

經過debug我們知道,ResponseStatusExceptionResolver這個異常處理解析器返回瞭一個空的ModelAndView對象給我們,而且還通過response.sendError(statusCode, resolvedReason);發送瞭/error請求

這樣就又走到瞭上面的第三節處理/error請求的流程中,從而帶著我們@ResponseStatus註解的code和reason跳轉到瞭4xx.html頁面,這樣就能解釋為什麼4xx.html頁面中的狀態碼和message都是我們自定義的瞭

如果沒有使用上述2種方法處理指定異常或處理我們自己自定義的異常,那麼系統就會按照Spring底層的異常進行處理,如 請求方法不支持異常等,都是使用DefaultHandlerExceptionResolver這個異常處理解析器進行處理的

我們分析這個類的doResolveException()方法得知,它最後也會發送/error請求,從而轉到4xx.html或者5xx.html頁面

3、自定義異常處理解析器

使用@Component註解,並實現HandlerExceptionResolver接口來自定義一個異常處理解析器

package com.decade.exception;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
// 將優先級提到第一位,Order越小,優先級越高,所以我們這裡設置int的最小值
@Order(Integer.MIN_VALUE)
@Component
public class CustomExceptionHandler implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            response.sendError(500, "自己定義的異常");
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new ModelAndView();
    }
}

當我們把優先級提到最高時,前面的那些異常處理解析器都會失效,這時我們的自定義異常處理解析器可以作為默認的全局異常處理規則

值得註意的是,當代碼走到response.sendError時,就會觸發/error請求,當你的異常沒有人能處理時,也會走tomcat底層觸發response.sendError,發送/error請求

到此這篇關於SpringBoot錯誤處理流程深入詳解的文章就介紹到這瞭,更多相關SpringBoot錯誤處理內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: