一文帶你掌握Spring Web例外處理方式

2022-08-10 18:03:33

一、前言

大家好,我是 去哪裡吃魚 ,也叫小張。

最近從單位離職了,離開了五年多來朝朝夕夕皆燈火輝煌的某網,激情也好悲涼也罷,觥籌場上屢屢物是人非,調轉過事業部以為能換種情緒,豈料和下了週五的班的前同事兼好朋友,匆匆趕往藏身巷弄的小菜館裡時,又次次被迫想起,那破曉時分大廈頭頂有點吝嗇的陽光。

阿坤:但凡拿我們當自己人,就不會這樣...
我 :也許人家想好好表現呢
阿坤:算了,不說了,走著走著天要亮了,回去睡吧
我 :臥槽,真的是,行了不說了,趁著下面還沒亮,趕緊回去睡吧
阿坤:下午見

小張目前蝸居賦閒,順便養一下左肩。

想念七七。

扯遠了,不說了,今天來給大家說一下 Spring Web 模組(基於 Servlet)中的異常(以下簡稱 Spring 異常)處理機制,看完文章,你應該會懂得在 web 開發過程中,怎麼處理程式出現的異常。

本文基於 springboot 2.5.1 , 對應 spring framework 版本為 5.3.8

二、本文的異常種類劃分

  1. "你妹啊,誰在 service 裡面拋了個自定義異常給我 controller !" ———— 業務程式碼引起的異常
  2. "你這報文簽名不對,引數也不對,攔截器都沒過" ———— 攔截器異常
  3. "這是什麼錯誤啊,status=500,啥也沒有顯示啊" ———— errorPath 異常

三、Spring Web 模組的請求核心流程解析

上述錯誤,都是使用者在使用瀏覽器或者 APP 等存取後臺時候出現的異常,因此我們有必要去了解一下 Spring Web 模組對使用者請求的核心處理流程,只有當熟悉了請求處理流程,我們處理起異常來,才會得心應手。

3.1 那個 Servlet

每一個基於 Servlet 的 web 框架,都會有自己的 Servlet 實現,在 Spring Web 中,它叫 DispatcherServlet ,你所有的請求都會經過它來處理。

而在 Spring 的設計中,DispatcherServlet 中處理請求的那個方法,叫 doDispatch()

3.2 那個 doDispatch()

話不多說,先來看我精簡過的方法

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
        // 。。。小張替你省略部分程式碼。。。
        try {
            ModelAndView mv = null;
            Exception dispatchException = null;

            try {
                // 。。。小張替你省略部分程式碼。。。

                // 根據 request 獲取對應的 Handler
                mappedHandler = getHandler(processedRequest);
                if (mappedHandler == null) {
                    noHandlerFound(processedRequest, response);
                    return;
                }

                // 根據 Handler 型別找到合適的 Handler 介面卡,DispatcherServilet 通過介面卡間接呼叫 Handler ,
                HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

                // 。。。小張替你省略部分程式碼。。。

                // 重頭戲來了
                // 步驟1. 下面這一行會遍歷攔截器,執行所有攔截器的 preHandle() 方法
                if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                    return;
                }

                // 步驟2. 當所有攔截器都校驗通過的時,下面這一行執行目標 controller 對應的業務方法
                mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

                // 。。。小張替你省略部分程式碼。。。

                // 步驟3. 當目標 controller 的業務方法執行完畢之後,下面這一行執行所有攔截器的 postHandler() 方法
                mappedHandler.applyPostHandle(processedRequest, response, mv);

                // 步驟4. 下面有兩個異常,捕獲了 攔截器 preHandler 階段和 controller 的業務方法執行階段丟擲的異常
            } catch (Exception ex) {
                dispatchException = ex;
            } catch (Throwable err) {
                dispatchException = new NestedServletException("Handler dispatch failed", err);
            }
            // 步驟5. 如果步驟4有異常,就會在這裡處理
            processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);

            // 步驟6. 下面兩個異常呼叫所有攔截器的 fterCompletion方法
        } catch (Exception ex) {
            triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
        } catch (Throwable err) {
            triggerAfterCompletion(processedRequest, response, mappedHandler,new NestedServletException("Handler processing failed", err));
        }finally {
            // 。。。小張替你省略部分程式碼。。。
        }
    }

上面程式碼中,最裡面的那個 try ... catch 已經把常用情況下的 攔截器、controller 的異常捕獲到了,例外處理邏輯在 步驟5 裡面:

private void processDispatchResult(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    @Nullable HandlerExecutionChain mappedHandler, 
                                    @Nullable ModelAndView mv, 
                                    @Nullable Exception exception) throws Exception { 
    // 。。。小張替你省略部分程式碼。。。

    if (exception != null) {
        if (exception instanceof ModelAndViewDefiningException) {
           // 。。。小張替你省略部分程式碼。。。
        } else {
            Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
            // 呼叫 DispatcherServlet 例外處理流程
            mv = processHandlerException(request, response, handler, exception);
            errorView = (mv != null);
        }
    }
    // 。。。小張替你省略部分程式碼。。。
}

ModelAndView processHandlerException(HttpServletRequest request,
                                    HttpServletResponse response,
                                    @Nullable Object handler,
                                    Exception ex) throws Exception {
    ModelAndView exMv = null;
    if (this.handlerExceptionResolvers != null) {
        // 遍歷 DispatcherServlet 裡載入好的例外處理器
        for (HandlerExceptionResolver handlerExceptionResolver : this.handlerExceptionResolvers) {
            // 交給例外處理器處理
            exMv = handlerExceptionResolver.resolveException(request, response, handler, ex);
            if (exMv != null) {
                // 找到合適的例外處理器就中斷
                break;
            }
        }
    }
    // 。。。小張替你省略部分程式碼。。。
}

異常最終會交給 DispatcherServlet 裡的 this.handlerExceptionResolvers 集合來處理,而這個東西也是我們自己規劃的例外處理器最終匯聚的地方,它的型別是 HandlerExceptionResolver 介面

3.3 那個 HandlerExceptionResolver

這是一個介面,只有例外處理的方法簽名

public interface HandlerExceptionResolver {

    @Nullable
    ModelAndView resolveException(
            HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);

}

注意,返回值 ModelAndView 不為空,證明該例外處理器處理了異常,spring 不會再讓剩下的例外處理器處理該異常

四、例外處理手段

章節二的劃分對應的處理手段有下面這幾種,我們一一舉例

4.1 controller 中的業務程式碼引起的例外處理方式

4.1.1 簡單點,使用 @ControllerAdvice 註解和 @ExceptionHandler 註解

// 步驟1. 使用註解修飾例外處理類
@ControllerAdvice
public class ErrorHandlerDemo {

    // 步驟2. 搭配使用註解,處理指定異常
    @ExceptionHandler(CacheException.class)
    @ResponseBody
    public String cacheException(HandlerMethod handlerMethod, Exception e) {
        return defaultErrorHandler(handlerMethod, e);
    }

    // 步驟2. 搭配使用註解,處理指定異常
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public String defaultErrorHandler(HandlerMethod handlerMethod, Exception e) {
        return revertMessage(e);
    }

    private String revertMessage(Exception e) {
        String msg = "系統異常";
        if (e instanceof CacheException) {
            msg = e.getMessage();
        }
        return msg;
    }
}

對應例外處理的方法返回值型別,類比 @RequestMapping 方法的返回值型別,比如,也可以是 ModelAndView 型別

原理剖析

A. @ControllerAdvice + @ExceptionHandler 註解修飾的類的解析

首先,被 @ControllerAdvice 註解修飾的類,會被 Spring 包裝成 ControllerAdviceBean ,這個東西把修飾的類的 Class<?> 儲存成 beanType ,並且是 ExceptionHandlerMethodResolver 的建構函式入參,唯一的建構函式唯一的入參。

ExceptionHandlerMethodResolver 又是個什麼東西? 例外處理器方法解析器?什麼玩意兒!先看它都幹了什麼吧

public class ExceptionHandlerMethodResolver {

public static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> AnnotatedElementUtils.hasAnnotation(method, ExceptionHandler.class);

    // 引數 handlerType 就是上面說提到的 beanType,就是上面範例程式碼中的 ErrorHandlerDemo 類
    public ExceptionHandlerMethodResolver(Class<?> handlerType) {

        // 這個 EXCEPTION_HANDLER_METHODS 是個函數式介面
        // MethodIntrospector.selectMethods() 方法用來查詢 @ControllerAdvice 類裡面被 @ExceptionHandler 註解修飾的方法並快取起來
        for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) {
            for (Class<? extends Throwable> exceptionType : detectExceptionMappings(method)) {

                // 以 異常型別:例外處理方法 格式快取起來
                addExceptionMapping(exceptionType, method);
            }
        }
    }

}

細心讀下注釋,可以發現 ExceptionHandlerMethodResolver 已經把我們自定義的例外處理類和例外處理方法都已經收集、準備完畢了。

有人說了,我搞了多個 @ControllerAdvice 修飾的類啊,你敢不敢都給解析了?

敢!接下來就告訴你 @ControllerAdvice 修飾的類在哪裡解析的!

B. @ControllerAdvice + @ExceptionHandler 註解修飾的類的載入

有個類叫 ExceptionHandlerExceptionResolver (什麼玩意兒?例外處理器異常解析器?),別懵,不翻譯它,看它是個啥

public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExceptionResolver
        implements ApplicationContextAware, InitializingBean {
    @Nullable
    private ApplicationContext applicationContext;

    // 沒錯,就是這裡,快取了 ErrorHandlerDemo 和它內部的例外處理方法對應的 ExceptionHandlerMethodResolver
    private final Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache = new LinkedHashMap<>();

    // 。。。小張替你省略部分程式碼。。。

    // 這裡是 InitializingBean 的實現方法
    @Override
    public void afterPropertiesSet() {
        // 這就是載入 ControllerAdviceBean 的地方
        initExceptionHandlerAdviceCache();

        // 。。。小張替你省略部分程式碼。。。
    }

    private void initExceptionHandlerAdviceCache() {
        if (getApplicationContext() == null) {
            return;
        }

        // 載入重點來了,通過上 Spring 上下文獲取被 @ControllerAdvice 修飾的 ErrorHandlerDemo
        List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());

        for (ControllerAdviceBean adviceBean : adviceBeans) {
            Class<?> beanType = adviceBean.getBeanType();
            if (beanType == null) {
                throw new IllegalStateException("Unresolvable type for ControllerAdviceBean: " + adviceBean);
            }

            // 建立與 ErrorHandlerDemo 中的例外處理方法對應的 ExceptionHandlerMethodResolver,就是上面A小節的解析部分
            ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(beanType);

            if (resolver.hasExceptionMappings()) {
                // ControllerAdviceBean 與 ExceptionHandlerMethodResolver 一一對應,快取起來
                this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
            }

            // 。。。小張替你省略部分程式碼。。。
        }

        // 。。。小張替你省略部分程式碼。。。
    }
}

那麼到這裡就明白了, ExceptionHandlerExceptionResolver 裡面快取了 ControllerAdivceBean 和它對應的具體的例外處理方法包裝(即 ExceptionHandlerMethodResolver)。

讀到這裡,也許朋友你會問, ExceptionHandlerExceptionResolver 是快取了,但是,這個玩意兒怎麼用的呢,在哪裡用的呢?

C. @ControllerAdvice + @ExceptionHandler 的啟用

在 spring-webmvc 模組中,有個類叫 WebMvcConfigurationSupport 它用來支援 web 的相關設定,他有一個建立 Bean 的方法

public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {
    // 。。。小張替你省略部分程式碼。。。

    /**
    * 建立例外處理器組合
    **/
    @Bean
    public HandlerExceptionResolver handlerExceptionResolver(@Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
        List<HandlerExceptionResolver> exceptionResolvers = new ArrayList<>();
        // 這裡是空方法1,可以自定義實現,一般不拓展這個方法
        configureHandlerExceptionResolvers(exceptionResolvers);
        // 如果沒有重寫上面的方法,則會走這裡,建立預設的例外處理器
        if (exceptionResolvers.isEmpty()) {
            addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
        }
        // 這裡是空方法2,可以自定義實現,一般拓展這個方法往所給的例外處理器集合裡新增自定義例外處理器
        extendHandlerExceptionResolvers(exceptionResolvers);

        // 把例外處理器集合組裝到 HandlerExceptionResolverComposite 裡, 而 HandlerExceptionResolverComposite  是介面 HandlerExceptionResolver 的實現類
        HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
        composite.setOrder(0);
        composite.setExceptionResolvers(exceptionResolvers);
        return composite;
    }

    /**
    * 根據提供的例外處理器建立例外處理器組合
    **/
    protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers,
            ContentNegotiationManager mvcContentNegotiationManager) {
        //建立 B 章節裡的例外處理器
        ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
        // 。。。小張替你省略部分程式碼。。。

        // 呼叫 InitializingBean 介面方法
        exceptionHandlerResolver.afterPropertiesSet();
        // 新增建立的例外處理器到集合中
        exceptionResolvers.add(exceptionHandlerResolver);

        // 。。。小張替你省略部分程式碼。。。
    }

    // 直接 new 了一個 ExceptionHandlerExceptionResolver
    protected ExceptionHandlerExceptionResolver createExceptionHandlerExceptionResolver() {
        return new ExceptionHandlerExceptionResolver();
    }
}

上面的 @Bean 建立方法做了下面這些事

  1. 提供例外處理器自定義拓展方法 configureHandlerExceptionResolvers()extendHandlerExceptionResolvers()

  2. 如果沒有指定例外處理器,只是拓展例外處理器,則建立預設例外處理器 ExceptionHandlerExceptionResolver

  3. 根據提供的例外處理器建立處理器組合物件 HandlerExceptionResolverComposite ,其也是例外處理器

到此為止 @Bean 方法已經做完了例外處理器的整合過程,常常與 @Bean 方法搭配使用的,是 @Configuration 註解修飾的設定類,然而 WebMvcConfigurationSupport 並沒有這個註解

雞賊的 spring 把 @Configuration 註解放到了它的子類 DelegatingWebMvcConfiguration 上!

package org.springframework.web.servlet.config.annotation;

// 設定類,自動載入
@Configuration(proxyBeanMethods = false)
public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {

    private final WebMvcConfigurerComposite configurers = new WebMvcConfigurerComposite();

    // 這個 WebMvcConfigurer 就是我們在 web 專案中自定義攔截器、例外處理器等需要實現的介面
    @Autowired(required = false)
    public void setConfigurers(List<WebMvcConfigurer> configurers) {
        if (!CollectionUtils.isEmpty(configurers)) {
            this.configurers.addWebMvcConfigurers(configurers);
        }
    }
}

到這裡,使用 @ControllerAdvice 註解和 @ExceptionHandler 註解來處理 Controller 異常的整個載入流程已經剖析完畢了

4.1.2 自定義 HandlerExceptionResolver

[章節 3.3 ](## 3.3 HandlerExceptionResolver)(markdown 什麼時候原生支援頁面內跳轉)已經有了介面簡單描述,我們直接來個介面實現類 demo

public class ExceptionHandlerDemo implements HandlerExceptionResolver {

    private final ModelAndView EMPTY_MODEL_VIEW = new ModelAndView();

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception e) {
        // 自定義例外處理邏輯,可以輸出狀態到 response
        // 記得返回一個空 ModelAndView,證明異常已經被處理
        return EMPTY_MODEL_VIEW;
    }
}

現在實現類有了, 我們把它載入到 spring 的 web 環境中去

// 設定類
@Configuration
public class WebConfigDemo implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new IpInterceptor()).addPathPatterns(
                Arrays.asList(
                        "/index",
                        "/apply",
                        "/product/i",
                        "/product/d/*"
                ));
    }

    // 就是這裡,新增例外處理器
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new ExceptionHandlerDemo());
    }
}

嗯,就是這樣簡單。

什麼?你問我載入進去之後怎麼生效的?

在 章節 4.1.1 中的 C 小節有提到類 DelegatingWebMvcConfiguration ,它的 setConfigurers(List configurers) 方法自動注入了咱們的 WebConfigDemo 設定類,並且,它重寫了其父類別 WebMvcConfigurationSupport 中的 extendHandlerExceptionResolvers()configureHandlerExceptionResolvers() 方法

@Configuration(proxyBeanMethods = false)
public class DelegatingWebMvcConfiguration extends WebMvcConfigurationSupport {

    // 當作是一個 設定類集合
    private final WebMvcConfigurerComposite configurers = new WebMvcConfigurerComposite();

    // 注入 WebConfigDemo
    @Autowired(required = false)
    public void setConfigurers(List<WebMvcConfigurer> configurers) {
        if (!CollectionUtils.isEmpty(configurers)) {
            this.configurers.addWebMvcConfigurers(configurers);
        }
    }

    @Override
    protected void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers) {
        this.configurers.configureHandlerExceptionResolvers(exceptionResolvers);
    }

    // 載入 ExceptionHandlerDemo
    @Override
    protected void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers) {
        this.configurers.extendHandlerExceptionResolvers(exceptionResolvers);
    }

    // 。。。小張替你省略部分程式碼。。。
}

這樣在章節 4.1.1 中 C 小節, 執行 @Bean 方法 handlerExceptionResolver() 方法時候,空方法2 就指向了這裡,進而載入到自定義的 ExceptionHandlerDemo

4.2 攔截器中丟擲的異常

攔截器有3個方法簽名

public interface HandlerInterceptor {

    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        return true;
    }


    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
    }


    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable Exception ex) throws Exception {
    }

}

其中針對方法 afterCompletion() 丟擲的異常,spring 只是簡單列印了一個錯誤紀錄檔,並沒有處理,也許 spring 認為,到這裡,請求內容已經處理完了,所以不再把錯誤返回給呼叫方

void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, @Nullable Exception ex) {
    for (int i = this.interceptorIndex; i >= 0; i--) {
        HandlerInterceptor interceptor = this.interceptorList.get(i);
        try {
            interceptor.afterCompletion(request, response, this.handler, ex);
        } catch (Throwable ex2) {
            // 僅列印錯誤紀錄檔
            logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
        }
    }
}

那麼剩下的兩個方法,其實章節 3.2 當中已經指明瞭,preHandle()postHandle 方法內出現的異常,與 controller 實體請求中的異常一起被處理了,所以章節 4.1 當中的例外處理方式,對於攔截器異常,同樣生效。

4.3 errorPath 異常

介紹 errorPath 之前,先說一下 spring 對於未捕獲到的異常的處理方式

對於未捕獲到的異常,spring 會返回 500 http 狀態碼給呼叫方,並且轉發請求到一個指定地址,這個地址預設值為 /error

在 spring boot 中的預設設定為 server.error.path=/error

以上,小張姑且稱之為:錯誤轉發機制 ,其實不僅僅是 500 狀態,404 狀態也會轉發,你還能再找出些狀態嗎 ?

那麼我們可以自已實現一個 /error controller 來處理異常嗎?可以的,得益於 SpringBoot,我們可以藉助另外一個叫 ErrorAttributes 的 bean 來獲取異常資訊

當請求出現異常時候,我們可以從 Request 當中讀取出來

@Controller
public class ErrorHandleController implements ErrorController {

    private final ErrorAttributes errorAttributes;

    // 注入 SpringBoot 已經替我們建立好的 ErrorAttributes
    public ErrorHandleController(ErrorAttributes errorAttributes) {
        this.errorAttributes = errorAttributes;
    }

    @RequestMapping(value = "/error", produces = "text/html")
    @ResponseBody
    public String errorPage(HttpServletRequest request) {
        return this.getErrorAttributesMapString(request);
    }


    @RequestMapping(value = "/error")
    @ResponseBody
    public String errorHandler(HttpServletRequest request) {
        return this.getErrorAttributesMapString(request);
    }

    private String getErrorAttributesMapString(HttpServletRequest request) {
        ServletWebRequest webRequest = new ServletWebRequest(request);
        // 利用 ErrorAttributes 讀取 request 當中的異常,這裡僅僅是簡單地列印到頁面上
        return this.errorAttributes.getErrorAttributes(webRequest, ErrorAttributeOptions.defaults()).toString();
    }

}

五、結尾

本文並沒有提供相應的 demo 演示,只是側重於帶領大家把 spring 的例外處理從頭到尾過一遍,如果想實驗,自己動手,結果會更快樂的。

有疑問的同學,歡迎評論區留言交流。

想念七七。