Spring Boot乾貨系列:(十三)Spring Boot全域性異常處理整理

嘟嘟MD發表於2018-05-17

原本地址:Spring Boot乾貨系列:(十三)Spring Boot全域性異常處理整理
部落格地址:tengj.top/

前言

今天來一起學習一下Spring Boot中的異常處理,在日常web開發中發生了異常,往往是需要通過一個統一的異常處理來保證客戶端能夠收到友好的提示。

正文

本篇要點如下

  • 介紹Spring Boot預設的異常處理機制
  • 如何自定義錯誤頁面
  • 通過@ControllerAdvice註解來處理異常

介紹Spring Boot預設的異常處理機制

預設情況下,Spring Boot為兩種情況提供了不同的響應方式。

一種是瀏覽器客戶端請求一個不存在的頁面或服務端處理髮生異常時,一般情況下瀏覽器預設傳送的請求頭中Accept: text/html,所以Spring Boot預設會響應一個html文件內容,稱作“Whitelabel Error Page”。

image.png

另一種是使用Postman等除錯工具傳送請求一個不存在的url或服務端處理髮生異常時,Spring Boot會返回類似如下的Json格式字串資訊

{
    "timestamp": "2018-05-12T06:11:45.209+0000",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/index.html"
} 
複製程式碼

原理也很簡單,Spring Boot 預設提供了程式出錯的結果對映路徑/error。這個/error請求會在BasicErrorController中處理,其內部是通過判斷請求頭中的Accept的內容是否為text/html來區分請求是來自客戶端瀏覽器(瀏覽器通常預設自動傳送請求頭內容Accept:text/html)還是客戶端介面的呼叫,以此來決定返回頁面檢視還是 JSON 訊息內容。 相關BasicErrorController中程式碼如下:

image.png

如何自定義錯誤頁面

好了,瞭解完Spring Boot預設的錯誤機制後,我們來點有意思的,瀏覽器端訪問的話,任何錯誤Spring Boot返回的都是一個Whitelabel Error Page的錯誤頁面,這個很不友好,所以我們可以自定義下錯誤頁面。

1、先從最簡單的開始,直接在/resources/templates下面建立error.html就可以覆蓋預設的Whitelabel Error Page的錯誤頁面,我專案用的是thymeleaf模板,對應的error.html程式碼如下:

image.png

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
動態error錯誤頁面
<p th:text="${error}"></p>
<p th:text="${status}"></p>
<p th:text="${message}"></p>
</body>
</html>
複製程式碼

這樣執行的時候,請求一個不存在的頁面或服務端處理髮生異常時,展示的自定義錯誤介面如下:

image.png

2、此外,如果你想更精細一點,根據不同的狀態碼返回不同的檢視頁面,也就是對應的404,500等頁面,這裡分兩種,錯誤頁面可以是靜態HTML(即,新增到任何靜態資原始檔夾下),也可以使用模板構建,檔案的名稱應該是確切的狀態碼。

  • 如果只是靜態HTML頁面,不帶錯誤資訊的,在resources/public/下面建立error目錄,在error目錄下面建立對應的狀態碼html即可 ,例如,要將404對映到靜態HTML檔案,您的資料夾結構如下所示:
    image.png

靜態404.html簡單頁面如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    靜態404錯誤頁面
</body>
</html>
複製程式碼

這樣訪問一個錯誤路徑的時候,就會顯示靜態404錯誤頁面錯誤頁面

image.png

注:這時候如果存在上面第一種介紹的error.html頁面,則狀態碼錯誤頁面將覆蓋error.html,具體狀態碼錯誤頁面優先順序比較高。

  • 如果是動態模板頁面,可以帶上錯誤資訊,在resources/templates/下面建立error目錄,在error目錄下面命名即可:
    image.png

這裡我們模擬下500錯誤,控制層程式碼,模擬一個除0的錯誤:

@Controller 
public class BaseErrorController extends  AbstractController{ 
private Logger logger = LoggerFactory.getLogger(this.getClass()); 

    @RequestMapping(value="/ex") 
    @ResponseBody 
    public String error(){ 
        int i=5/0; 
        return "ex"; 
    } 
} 
複製程式碼

500.html程式碼:

<!DOCTYPE html> 
<html xmlns:th="http://www.thymeleaf.org"> 
<head> 
<meta charset="UTF-8"> 
<title>Title</title> 
</head> 
<body> 
    動態500錯誤頁面 
    <p th:text="${error}"></p> 
    <p th:text="${status}"></p> 
    <p th:text="${message}"></p> 
</body> 
</html> 
複製程式碼

這時訪問 http://localhost:8080/spring/ex 即可看到如下錯誤,說明確實對映到了500.html

image.png

注:如果同時存在靜態頁面500.html和動態模板的500.html,則後者覆蓋前者。即templates/error/這個的優先順序比resources/public/error高。

整體概括上面幾種情況,如下:

  • error.html會覆蓋預設的 whitelabel Error Page 錯誤提示
  • 靜態錯誤頁面優先順序別比error.html高
  • 動態模板錯誤頁面優先順序比靜態錯誤頁面高

3、上面介紹的只是最簡單的覆蓋錯誤頁面的方式來自定義,如果對於某些錯誤你可能想特殊對待,則可以這樣

@Configuration 
public class ContainerConfig { 
    @Bean 
    public EmbeddedServletContainerCustomizer containerCustomizer(){ 
        return new EmbeddedServletContainerCustomizer(){ 
           @Override 
           public void customize(ConfigurableEmbeddedServletContainer container) { 
               container.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500")); 
           } 
        }; 
   } 
} 
複製程式碼

上面這段程式碼中HttpStatus.INTERNAL_SERVER_ERROR就是對應500錯誤碼,也就是說程式如果發生500錯誤,就會將請求轉發到/error/500這個對映來,那我們只要實現一個方法是對應這個/error/500對映即可捕獲這個異常做出處理

@RequestMapping("/error/500") 
@ResponseBody 
public String showServerError() { 
    return "server error"; 
} 
複製程式碼

這樣,我們再請求前面提到的異常請求 http://localhost:8080/spring/ex 的時候,就會被我們這個方法捕獲了。

image.png

這裡我們就只對500做了特殊處理,並且返還的是字串,如果想要返回檢視,去掉 @ResponseBody註解,並返回對應的檢視頁面。如果想要對其他狀態碼自定義對映,在customize方法中新增即可。

上面這種方法雖然我們重寫了/500對映,但是有一個問題就是無法獲取錯誤資訊,想獲取錯誤資訊的話,我們可以繼承BasicErrorController或者乾脆自己實現ErrorController介面,除了用來響應/error這個錯誤頁面請求,可以提供更多型別的錯誤格式等(BasicErrorController在上面介紹SpringBoot預設異常機制的時候有提到)

這裡博主選擇直接繼承BasicErrorController,然後把上面 /error/500對映方法新增進來即可

@Controller
public class MyBasicErrorController extends BasicErrorController {

    public MyBasicErrorController() {
        super(new DefaultErrorAttributes(), new ErrorProperties());
    }

    /**
    * 定義500的ModelAndView
    * @param request
    * @param response
    * @return
    */

    @RequestMapping(produces = "text/html",value = "/500")
    public ModelAndView errorHtml500(HttpServletRequest request,HttpServletResponse response) {
        response.setStatus(getStatus(request).value());
        Map<String, Object> model = getErrorAttributes(request,isIncludeStackTrace(request, MediaType.TEXT_HTML));
        model.put("msg","自定義錯誤資訊");
        return new ModelAndView("error/500", model);
    }

    /**
    * 定義500的錯誤JSON資訊
    * @param request
    * @return
    */

    @RequestMapping(value = "/500")
    @ResponseBody

    public ResponseEntity<Map<String, Object>> error500(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request,isIncludeStackTrace(request, MediaType.TEXT_HTML));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<Map<String, Object>>(body, status);
    }
}
複製程式碼

程式碼也很簡單,只是實現了自定義的500錯誤的對映解析,分別對瀏覽器請求以及json請求做了回應。

BasicErrorController預設對應的@RequestMapping是/error,固我們方法裡面對應的@RequestMapping(produces = "text/html",value = "/500")實際上完整的對映請求是/error/500,這就跟上面 customize 方法自定義的對映路徑對上了。

errorHtml500 方法中,我返回的是模板頁面,對應/templates/error/500.html,這裡順便自定義了一個msg資訊,在500.html也輸出這個資訊<p th:text="${msg}"></p>,如果輸出結果有這個資訊,則表示我們配置正確了。

再次訪問請求http://localhost:8080/spring/ex ,結果如下

image.png

## 通過@ControllerAdvice註解來處理異常

Spring Boot提供的ErrorController是一種全域性性的容錯機制。此外,你還可以用@ControllerAdvice註解和@ExceptionHandler註解實現對指定異常的特殊處理。

這裡介紹兩種情況:

  • 區域性異常處理  @Controller + @ExceptionHandler
  • 全域性異常處理  @ControllerAdvice + @ExceptionHandler

區域性異常處理 @Controller + @ExceptionHandler

區域性異常主要用到的是@ExceptionHandler註解,此註解註解到類的方法上,當此註解裡定義的異常丟擲時,此方法會被執行。如果@ExceptionHandler所在的類是@Controller,則此方法只作用在此類。如果@ExceptionHandler所在的類帶有@ControllerAdvice註解,則此方法會作用在全域性。

該註解用於標註處理方法處理那些特定的異常。被該註解標註的方法可以有以下任意順序的引數型別:

  • Throwable、Exception 等異常物件;

  • ServletRequest、HttpServletRequest、ServletResponse、HttpServletResponse;

  • HttpSession 等會話物件;

  • org.springframework.web.context.request.WebRequest;

  • java.util.Locale;

  • java.io.InputStream、java.io.Reader;

  • java.io.OutputStream、java.io.Writer;

  • org.springframework.ui.Model;

並且被該註解標註的方法可以有以下的返回值型別可選:

  • ModelAndView;

  • org.springframework.ui.Model;

  • java.util.Map;

  • org.springframework.web.servlet.View;

  • @ResponseBody 註解標註的任意物件;

  • HttpEntity or ResponseEntity;

  • void;

以上羅列的不完全,更加詳細的資訊可參考:[Spring ExceptionHandler](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/ExceptionHandler.html)。

舉個簡單例子,這裡我們對除0異常用@ExceptionHandler來捕捉。

@Controller
public class BaseErrorController extends  AbstractController{ 
    private Logger logger = LoggerFactory.getLogger(this.getClass()); 

    @RequestMapping(value="/ex") 
    @ResponseBody 
    public String error(){ 
        int i=5/0; 
        return "ex"; 
  } 

    //區域性異常處理 
    @ExceptionHandler(Exception.class) 
    @ResponseBody 
    public String exHandler(Exception e){ 
      // 判斷髮生異常的型別是除0異常則做出響應 
      if(e instanceof ArithmeticException){ 
          return "發生了除0異常"; 
      } 
      // 未知的異常做出響應 
      return "發生了未知異常"; 
    }
} 
複製程式碼

image.png

全域性異常處理 @ControllerAdvice + @ExceptionHandler

在spring 3.2中,新增了@ControllerAdvice 註解,可以用於定義@ExceptionHandler、@InitBinder、@ModelAttribute,並應用到所有@RequestMapping中。

簡單的說,進入Controller層的錯誤才會由@ControllerAdvice處理,攔截器丟擲的錯誤以及訪問錯誤地址的情況@ControllerAdvice處理不了,由SpringBoot預設的異常處理機制處理。

我們實際開發中,如果是要實現RESTful API,那麼預設的JSON錯誤資訊就不是我們想要的,這時候就需要統一一下JSON格式,所以需要封裝一下。

/**
* 返回資料
*/
public class AjaxObject extends HashMap<String, Object> {
    private static final long serialVersionUID = 1L;
 
    public AjaxObject() {
        put("code", 0);
    }
    
    public static AjaxObject error() {
        return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知異常,請聯絡管理員");
    }
    
    public static AjaxObject error(String msg) {
        return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
    }
    
    public static AjaxObject error(int code, String msg) {
        AjaxObject r = new AjaxObject();
        r.put("code", code);
        r.put("msg", msg);
        return r;
    }

    public static AjaxObject ok(String msg) {
        AjaxObject r = new AjaxObject();
        r.put("msg", msg);
        return r;
    }
    
    public static AjaxObject ok(Map<String, Object> map) {
        AjaxObject r = new AjaxObject();
        r.putAll(map);
        return r;
    }
    
    public static AjaxObject ok() {
        return new AjaxObject();
    }

    public AjaxObject put(String key, Object value) {
        super.put(key, value);
        return this;
    }
    
    public AjaxObject data(Object value) {
        super.put("data", value);
        return this;
    }

    public static AjaxObject apiError(String msg) {
        return error(1, msg);
    }
}
複製程式碼

上面這個AjaxObject就是我平時用的,如果是正確情況返回的就是:

{
    code:0,
    msg:“獲取列表成功”,
    data:{ 
        queryList :[]
    }
}
複製程式碼

正確預設code返回0,data裡面可以是集合,也可以是物件,如果是異常情況,返回的json則是:

{
    code:500,
    msg:“未知異常,請聯絡管理員”
}
複製程式碼

然後建立一個自定義的異常類:

public class BusinessException extends RuntimeException implements Serializable {

    private static final long serialVersionUID = 1L;
    private String msg;
    private int code = 500;
    
    public BusinessException(String msg) {
        super(msg);
        this.msg = msg;
    }
    
    public BusinessException(String msg, Throwable e) {
        super(msg, e);
        this.msg = msg;
    }
    
    public BusinessException(int code,String msg) {
        super(msg);
        this.msg = msg;
        this.code = code;
    }
    
    public BusinessException(String msg, int code, Throwable e) {
        super(msg, e);
        this.msg = msg;
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }
}
複製程式碼

注:spring 對於 RuntimeException 異常才會進行事務回滾

Controler中新增一個json對映,用來處理這個異常

@Controller
public class BaseErrorController{
    @RequestMapping("/json")
    public void json(ModelMap modelMap) {
        System.out.println(modelMap.get("author"));
        int i=5/0;
    }
}
複製程式碼

最後建立這個全域性異常處理類:

/**
 * 異常處理器
 */
@RestControllerAdvice
public class BusinessExceptionHandler {
	private Logger logger = LoggerFactory.getLogger(getClass());



	/**
	 * 應用到所有@RequestMapping註解方法,在其執行之前初始化資料繫結器
	 * @param binder
	 */
	@InitBinder
	public void initBinder(WebDataBinder binder) {
		System.out.println("請求有引數才進來");
	}

	/**
	 * 把值繫結到Model中,使全域性@RequestMapping可以獲取到該值
	 * @param model
	 */
	@ModelAttribute
	public void addAttributes(Model model) {
		model.addAttribute("author", "嘟嘟MD");
	}

	@ExceptionHandler(Exception.class)
	public Object handleException(Exception e,HttpServletRequest req){
		AjaxObject r = new AjaxObject();
		//業務異常
		if(e instanceof BusinessException){
			r.put("code", ((BusinessException) e).getCode());
			r.put("msg", ((BusinessException) e).getMsg());
		}else{//系統異常
			r.put("code","500");
			r.put("msg","未知異常,請聯絡管理員");
		}

		//使用HttpServletRequest中的header檢測請求是否為ajax, 如果是ajax則返回json, 如果為非ajax則返回view(即ModelAndView)
		String contentTypeHeader = req.getHeader("Content-Type");
		String acceptHeader = req.getHeader("Accept");
		String xRequestedWith = req.getHeader("X-Requested-With");
		if ((contentTypeHeader != null && contentTypeHeader.contains("application/json"))
				|| (acceptHeader != null && acceptHeader.contains("application/json"))
				|| "XMLHttpRequest".equalsIgnoreCase(xRequestedWith)) {
			return r;
		} else {
			ModelAndView modelAndView = new ModelAndView();
			modelAndView.addObject("msg", e.getMessage());
			modelAndView.addObject("url", req.getRequestURL());
			modelAndView.addObject("stackTrace", e.getStackTrace());
			modelAndView.setViewName("error");
			return modelAndView;
		}
	}
}
複製程式碼

@ExceptionHandler 攔截了異常,我們可以通過該註解實現自定義異常處理。其中,@ExceptionHandler 配置的 value 指定需要攔截的異常型別,上面我配置了攔截Exception, 再根據不同異常型別返回不同的相應,最後新增判斷,如果是Ajax請求,則返回json,如果是非ajax則返回view,這裡是返回到error.html頁面。

為了展示錯誤的時候更友好,我封裝了下error.html,不僅展示了錯誤,還新增了跳轉百度谷歌以及StackOverFlow的按鈕,如下:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org" layout:decorator="layout">
<head>
    <title>Spring Boot管理後臺</title>
    <script type="text/javascript">
    </script>
</head>
<body>
<div layout:fragment="content" th:remove="tag">
    <div  id="navbar">
        <h1>系統異常統一處理</h1>
        <h3 th:text="'錯誤資訊:'+${msg}"></h3>
        <h3 th:text="'請求地址:'+${url}"></h3>

        <h2>Debug</h2>
        <a th:href="@{'https://www.google.com/webhp?hl=zh-CN#safe=strict&hl=zh-CN&q='+${msg}}"
           class="btn btn-primary btn-lg" target="_blank" id="Google">Google</a>
        <a th:href="@{'https://www.baidu.com/s?wd='+${msg}}" class="btn btn-info btn-lg"  target="_blank" id="Baidu">Baidu</a>
        <a th:href="@{'http://stackoverflow.com/search?q='+${msg}}"
           class="btn btn-default btn-lg"  target="_blank" id="StackOverFlow">StackOverFlow</a>
        <h2>異常堆疊跟蹤日誌StackTrace</h2>
        <div th:each="line:${stackTrace}">
            <div th:text="${line}"></div>
        </div>
    </div>
</div>
<div layout:fragment="js" th:remove="tag">
</div>
</body>
</html>
複製程式碼

訪問http://localhost:8080/json的時候,因為是瀏覽器發起的,返回的是error介面:

image.png

如果是ajax請求,返回的就是錯誤:

{ "msg":"未知異常,請聯絡管理員", "code":500 }
複製程式碼

這裡我給帶@ModelAttribute註解的方法通過Model設定了author值,在json對映方法中通過 ModelMwap 獲取到改值。

認真的你可能發現,全域性異常類我用的是@RestControllerAdvice,而不是@ControllerAdvice,因為這裡返回的主要是json格式,這樣可以少寫一個@ResponseBody。

總結

到此,SpringBoot中對異常的使用也差不多全了,本專案中處理異常的順序會是這樣,當傳送一個請求:

  • 攔截器那邊先判斷是否登入,沒有則返回登入頁。
  • 在進入Controller之前,譬如請求一個不存在的地址,返回404錯誤介面。
  • 在執行@RequestMapping時,發現的各種錯誤(譬如資料庫報錯、請求引數格式錯誤/缺失/值非法等)統一由@ControllerAdvice處理,根據是否Ajax返回json或者view。

想要檢視更多Spring Boot乾貨教程,可前往:[Spring Boot乾貨系列總綱](http://tengj.top/2017/04/24/springboot0/)

# 原始碼下載

( ̄︶ ̄)↗[[相關示例完整程式碼](https://github.com/tengj/SpringBootDemo/tree/master)]

- chapter13==》Spring Boot乾貨系列:(十三)Spring Boot全域性異常處理整理

一直覺得自己寫的不是技術,而是情懷,一篇篇文章是自己這一路走來的痕跡。靠專業技能的成功是最具可複製性的,希望我的這條路能讓你少走彎路,希望我能幫你抹去知識的蒙塵,希望我能幫你理清知識的脈絡,希望未來技術之巔上有你也有我。

Spring Boot乾貨系列:(十三)Spring Boot全域性異常處理整理

相關文章