天天看點

Spring Exception 常見錯誤

作者:明天過後的西瓜

案例 1:小心過濾器異常

為了友善講解,我們還是沿用之前在事務進行中用到的學生注冊的案例,來讨論異常處理的問題:

(https://www.java567.com,搜"spring")

@Controller
 @Slf4j
 public class StudentController {
     public StudentController(){
         System.out.println("construct");
     }
 
     @PostMapping("/regStudent/{name}")
     @ResponseBody
     public String saveUser(String name) throws Exception {
         System.out.println("......使用者注冊成功");
         return "success";
     }
 }
            

為了保證安全,這裡需要給請求加一個保護,通過驗證 Token 的方式來驗證請求的合法性。這個 Token 需要在每次發送請求的時候帶在請求的 header 中,header 的 key 是 Token。

為了校驗這個 Token,我們引入了一個 Filter 來處理這個校驗工作,這裡我使用了一個最簡單的 Token:111111。

當 Token 校驗失敗時,就會抛出一個自定義的 NotAllowException,交由 Spring 處理:

@WebFilter
 @Component
 public class PermissionFilter implements Filter {
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         HttpServletRequest httpServletRequest = (HttpServletRequest) request;
         String token = httpServletRequest.getHeader("token");
 
         if (!"111111".equals(token)) {
             System.out.println("throw NotAllowException");
             throw new NotAllowException();
         }
         chain.doFilter(request, response);
     }
 
     @Override
     public void init(FilterConfig filterConfig) throws ServletException {
     }
 
     @Override
     public void destroy() {
     }
            

NotAllowException 就是一個簡單的 RuntimeException 的子類:

public class NotAllowException extends RuntimeException {
     public NotAllowException() {
         super();
     }
 }
            

同時,新增了一個 RestControllerAdvice 來處理這個異常,處理方式也很簡單,就是傳回一個 403 的 resultCode:

@RestControllerAdvice
 public class NotAllowExceptionHandler {
     @ExceptionHandler(NotAllowException.class)
     @ResponseBody
     public String handle() {
         System.out.println("403");
         return "{\"resultCode\": 403}";
     }
 }
            

為了驗證一下失敗的情況,我們模拟了一個請求,在 HTTP 請求頭裡加上一個 Token,值為 111,這樣就會引發錯誤了,我們可以看看會不會被 NotAllowExceptionHandler 處理掉。

然而,在控制台上,我們隻看到了下面這樣的輸出,這其實就說明了 NotAllowExceptionHandler 并沒有生效。

throw NotAllowException
            

想下問題出在哪呢?我們不妨對 Spring 的異常處理過程先做一個了解。

案例解析

我們先來回顧一下 第13課 講過的過濾器執行流程圖,這裡我細化了一下:

從這張圖中可以看出,當所有的過濾器被執行完畢以後,Spring 才會進入 Servlet 相關的處理,而 DispatcherServlet 才是整個 Servlet 處理的核心,它是前端控制器設計模式的實作,提供 Spring Web MVC 的集中通路點并負責職責的分派。正是在這裡,Spring 處理了請求和處理器之間的對應關系,以及這個案例我們所關注的問題——統一異常處理。

其實說到這裡,我們已經了解到過濾器内異常無法被統一處理的大緻原因,就是因為異常處理發生在上圖的紅色區域,即DispatcherServlet中的doDispatch(),而此時,過濾器已經全部執行完畢了。

下面我們将深入分析 Spring Web 對異常統一處理的邏輯,深刻了解其内部原理。

首先我們來了解下ControllerAdvice是如何被Spring加載并對外暴露的。 在Spring Web 的核心配置類 WebMvcConfigurationSupport 中,被 @Bean 修飾的 handlerExceptionResolver(),會調用addDefaultHandlerExceptionResolvers() 來添加預設的異常解析器。

@Bean
 public HandlerExceptionResolver handlerExceptionResolver(
       @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
    List<HandlerExceptionResolver> exceptionResolvers = new ArrayList<>();
    configureHandlerExceptionResolvers(exceptionResolvers);
    if (exceptionResolvers.isEmpty()) {
       addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
    }
    extendHandlerExceptionResolvers(exceptionResolvers);
    HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
    composite.setOrder(0);
    composite.setExceptionResolvers(exceptionResolvers);
    return composite;
 }
            

最終按照下圖的調用棧,Spring 執行個體化了ExceptionHandlerExceptionResolver類。

從源碼中我們可以看出,ExceptionHandlerExceptionResolver 類實作了InitializingBean接口,并覆寫了afterPropertiesSet()。

public void afterPropertiesSet() {
    // Do this first, it may add ResponseBodyAdvice beans
    initExceptionHandlerAdviceCache();
     //省略非關鍵代碼
 }
            

并在 initExceptionHandlerAdviceCache() 中完成了所有 ControllerAdvice 中的ExceptionHandler 的初始化。其具體操作,就是查找所有 @ControllerAdvice 注解的 Bean,把它們放到成員變量 exceptionHandlerAdviceCache 中。

在我們這個案例裡,就是指 NotAllowExceptionHandler 這個異常處理器。

private void initExceptionHandlerAdviceCache() {
    //省略非關鍵代碼
    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);
       }
       ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(beanType);
       if (resolver.hasExceptionMappings()) {
          this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
       }
  //省略非關鍵代碼
 }
            

到這,我們可以總結一下,WebMvcConfigurationSupport 中的handlerExceptionResolver() 執行個體化并注冊了一個ExceptionHandlerExceptionResolver 的執行個體,而所有被 @ControllerAdvice 注解修飾的異常處理器,都會在 ExceptionHandlerExceptionResolver 執行個體化的時候自動掃描并裝載在其類成員變量 exceptionHandlerAdviceCache 中。

當第一次請求發生時,DispatcherServlet 中的 initHandlerExceptionResolvers() 将擷取所有注冊到 Spring 的 HandlerExceptionResolver 類型的執行個體,而ExceptionHandlerExceptionResolver 恰好實作了 HandlerExceptionResolver 接口,這些 HandlerExceptionResolver 類型的執行個體則會被寫入到類成員變量handlerExceptionResolvers中。

private void initHandlerExceptionResolvers(ApplicationContext context) {
    this.handlerExceptionResolvers = null;
 
    if (this.detectAllHandlerExceptionResolvers) {
       // Find all HandlerExceptionResolvers in the ApplicationContext, including ancestor contexts.
       Map<String, HandlerExceptionResolver> matchingBeans = BeanFactoryUtils
             .beansOfTypeIncludingAncestors(context, HandlerExceptionResolver.class, true, false);
       if (!matchingBeans.isEmpty()) {
          this.handlerExceptionResolvers = new ArrayList<>(matchingBeans.values());
          // We keep HandlerExceptionResolvers in sorted order.
          AnnotationAwareOrderComparator.sort(this.handlerExceptionResolvers);
       }
       //省略非關鍵代碼
 }
            

接着我們再來了解下ControllerAdvice是如何被Spring消費并處理異常的。 下文貼出的是核心類 DispatcherServlet 中的核心方法 doDispatch() 的部分代碼:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    //省略非關鍵代碼
 
    try {
       ModelAndView mv = null;
       Exception dispatchException = null;
       try {
          //省略非關鍵代碼
          //查找目前請求對應的 handler,并執行
          //省略非關鍵代碼
       }
       catch (Exception ex) {
          dispatchException = ex;
       }
       catch (Throwable err) {
          dispatchException = new NestedServletException("Handler dispatch failed", err);
       }
       processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }
    //省略非關鍵代碼
            

Spring 在執行使用者請求時,當在“查找”和“執行”請求對應的 handler 過程中發生異常,就會把異常指派給 dispatchException,再交給 processDispatchResult() 進行處理。

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
       @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
       @Nullable Exception exception) throws Exception {
    boolean errorView = false;
    if (exception != null) {
       if (exception instanceof ModelAndViewDefiningException) {
          mv = ((ModelAndViewDefiningException) exception).getModelAndView();
       }
       else {
          Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
          mv = processHandlerException(request, response, handler, exception);
          errorView = (mv != null);
       }
    }
    //省略非關鍵代碼
            

進一步處理後,即當 Exception 不為 null 時,繼續交給 processHandlerException處理。

protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
       @Nullable Object handler, Exception ex) throws Exception {
    //省略非關鍵代碼
    ModelAndView exMv = null;
    if (this.handlerExceptionResolvers != null) {
       for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
          exMv = resolver.resolveException(request, response, handler, ex);
          if (exMv != null) {
             break;
          }
       }
    }
    //省略非關鍵代碼
 }
            

然後,processHandlerException 會從類成員變量 handlerExceptionResolvers 中擷取有效的異常解析器,對異常進行解析。

顯然,這裡的 handlerExceptionResolvers 一定包含我們聲明的NotAllowExceptionHandler#NotAllowException 的異常處理器的 ExceptionHandlerExceptionResolver 包裝類。

問題修正

為了利用 Spring MVC 的異常處理機制,我們需要對 Filter 做一些改造。手動捕獲異常,并将異常 HandlerExceptionResolver 進行解析處理。

我們可以這樣修改 PermissionFilter,注入 HandlerExceptionResolver:

@Autowired
 @Qualifier("handlerExceptionResolver")
 private HandlerExceptionResolver resolver;
            

然後,在 doFilter 裡捕獲異常并交給 HandlerExceptionResolver 處理:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         HttpServletRequest httpServletRequest = (HttpServletRequest) request;
         HttpServletResponse httpServletResponse = (HttpServletResponse) response;
         String token = httpServletRequest.getHeader("token");
         if (!"111111".equals(token)) {
             System.out.println("throw NotAllowException");
             resolver.resolveException(httpServletRequest, httpServletResponse, null, new NotAllowException());
             return;
         }
         chain.doFilter(request, response);
     }
            

當我們嘗試用錯誤的 Token 請求,控制台得到了以下資訊:

throw NotAllowException
 403
            

傳回的 JSON 是:

{"resultCode": 403}
            

再換成正确的 Token 請求,這些錯誤資訊就都沒有了,到這,問題解決了。

(https://www.java567.com,搜"spring")

案例 2:特殊的 404 異常

繼續沿用學生注冊的案例,為了防止一些異常的通路,我們需要記錄所有 404 狀态的通路記錄,并傳回一個我們的自定義結果。

一般使用 RESTful 接口時我們會統一傳回 JSON 資料,傳回值格式如下:

{"resultCode": 404}
            

但是 Spring 對 404 異常是進行了預設資源映射的,并不會傳回我們想要的結果,也不會對這種錯誤做記錄。

于是我們添加了一個 ExceptionHandlerController,它被聲明成@RestControllerAdvice來全局捕獲 Spring MVC 中抛出的異常。

ExceptionHandler 的作用正是用來捕獲指定的異常:

@RestControllerAdvice
 public class MyExceptionHandler {
     @ResponseStatus(HttpStatus.NOT_FOUND)
     @ExceptionHandler(Exception.class)
     @ResponseBody
     public String handle404() {
         System.out.println("404");
         return "{\"resultCode\": 404}";
     }
 }
            

我們嘗試發送一個錯誤的 URL 請求到之前實作過的 /regStudent 接口,并把請求位址換成 /regStudent1,得到了以下結果:

{"timestamp":"2021-05-19T22:24:01.559+0000","status":404,"error":"Not Found","message":"No message available","path":"/regStudent1"}
            

很顯然,這個結果不是我們想要的,看起來應該是 Spring 預設的傳回結果。那是什麼原因導緻 Spring 沒有使用我們定義的異常處理器呢?

案例解析

我們可以從異常處理的核心處理代碼開始分析,DispatcherServlet 中的 doDispatch() 核心代碼如下:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
         //省略非關鍵代碼
          mappedHandler = getHandler(processedRequest);
          if (mappedHandler == null) {
             noHandlerFound(processedRequest, response);
             return;
          }
          //省略非關鍵代碼
 }
            

首先調用 getHandler() 擷取目前請求的處理器,如果擷取不到,則調用noHandlerFound():

protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
    if (this.throwExceptionIfNoHandlerFound) {
       throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),
             new ServletServerHttpRequest(request).getHeaders());
    }
    else {
       response.sendError(HttpServletResponse.SC_NOT_FOUND);
    }
 }
            

noHandlerFound() 的邏輯非常簡單,如果 throwExceptionIfNoHandlerFound 屬性為 true,則直接抛出 NoHandlerFoundException 異常,反之則會進一步擷取到對應的請求處理器執行,并将執行結果傳回給用戶端。

到這,真相離我們非常近了,我們隻需要将 throwExceptionIfNoHandlerFound 預設設定為 true 即可,這樣就會抛出 NoHandlerFoundException 異常,進而被 doDispatch()内的 catch 俘獲。進而就像案例1介紹的一樣,最終能夠執行我們自定義的異常處理器MyExceptionHandler。

于是,我們開始嘗試,因為 throwExceptionIfNoHandlerFound 對應的 Spring 配置項為 throw-exception-if-no-handler-found,我們将其加入到 application.properties 配置檔案中,設定其值為 true。

設定完畢後,重新開機服務并再次嘗試,你會發現結果沒有任何變化,這個問題也沒有被解決。

實際上這裡還存在另一個坑,在 Spring Web 的 WebMvcAutoConfiguration 類中,其預設添加的兩個 ResourceHandler,一個是用來處理請求路徑/webjars/* *,而另一個是/**。

即便目前請求沒有定義任何對應的請求處理器,getHandler() 也一定會擷取到一個 Handler 來處理目前請求,因為第二個比對 /** 路徑的 ResourceHandler 決定了任何請求路徑都會被其處理。mappedHandler == null 判斷條件永遠不會成立,顯然就不可能走到 noHandlerFound(),那麼就不會抛出 NoHandlerFoundException 異常,也無法被後續的異常處理器進一步處理。

下面讓我們通過源碼進一步了解下這個預設被添加的 ResourceHandler 的詳細邏輯 。

首先我們來了解下ControllerAdvice是如何被Spring加載并對外暴露的。

同樣是在 WebMvcConfigurationSupport 類中,被 @Bean 修飾的 resourceHandlerMapping(),它建立了 ResourceHandlerRegistry 類執行個體,并通過 addResourceHandlers() 将 ResourceHandler 注冊到 ResourceHandlerRegistry 類執行個體中:

@Bean
 @Nullable
 public HandlerMapping resourceHandlerMapping(
       @Qualifier("mvcUrlPathHelper") UrlPathHelper urlPathHelper,
       @Qualifier("mvcPathMatcher") PathMatcher pathMatcher,
       @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager,
       @Qualifier("mvcConversionService") FormattingConversionService conversionService,
       @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) {
 
    Assert.state(this.applicationContext != null, "No ApplicationContext set");
    Assert.state(this.servletContext != null, "No ServletContext set");
 
    ResourceHandlerRegistry registry = new ResourceHandlerRegistry(this.applicationContext,
          this.servletContext, contentNegotiationManager, urlPathHelper);
    addResourceHandlers(registry);
 
    AbstractHandlerMapping handlerMapping = registry.getHandlerMapping();
    if (handlerMapping == null) {
       return null;
    }
    handlerMapping.setPathMatcher(pathMatcher);
    handlerMapping.setUrlPathHelper(urlPathHelper);
    handlerMapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
    handlerMapping.setCorsConfigurations(getCorsConfigurations());
    return handlerMapping;
 }
            

最終通過 ResourceHandlerRegistry 類執行個體中的 getHandlerMapping() 傳回了 SimpleUrlHandlerMapping 執行個體,它裝載了所有 ResourceHandler 的集合并注冊到了 Spring 容器中:

protected AbstractHandlerMapping getHandlerMapping() {
    //省略非關鍵代碼
    Map<String, HttpRequestHandler> urlMap = new LinkedHashMap<>();
    for (ResourceHandlerRegistration registration : this.registrations) {
       for (String pathPattern : registration.getPathPatterns()) {
          ResourceHttpRequestHandler handler = registration.getRequestHandler();
          //省略非關鍵代碼
          urlMap.put(pathPattern, handler);
       }
    }
    return new SimpleUrlHandlerMapping(urlMap, this.order);
 }
            

我們檢視以下調用棧截圖:

可以了解到,目前方法中的 addResourceHandlers() 最終執行到了 WebMvcAutoConfiguration 類中的 addResourceHandlers(),通過這個方法,我們可以知道目前有哪些 ResourceHandler 的集合被注冊到了Spring容器中:

public void addResourceHandlers(ResourceHandlerRegistry registry) {
    if (!this.resourceProperties.isAddMappings()) {
       logger.debug("Default resource handling disabled");
       return;
    }
    Duration cachePeriod = this.resourceProperties.getCache().getPeriod();
    CacheControl cacheControl = this.resourceProperties.getCache().getCachecontrol().toHttpCacheControl();
    if (!registry.hasMappingForPattern("/webjars/**")) {
       customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**")
             .addResourceLocations("classpath:/META-INF/resources/webjars/")
             .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
    }
    String staticPathPattern = this.mvcProperties.getStaticPathPattern();
    if (!registry.hasMappingForPattern(staticPathPattern)) {
       customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern)
             .addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations()))
             .setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
    }
 }
            

進而驗證我們一開始得出的結論,此處添加了兩個 ResourceHandler,一個是用來處理請求路徑/webjars/* *, 而另一個是/**。

這裡你可以注意一下方法最開始的判斷語句,如果 this.resourceProperties.isAddMappings() 為 false,那麼會直接傳回,後續的兩個 ResourceHandler 也不會被添加。

if (!this.resourceProperties.isAddMappings()) {
       logger.debug("Default resource handling disabled");
       return;
    }
            

至此,有兩個 ResourceHandler 被執行個體化且注冊到了 Spirng 容器中,一個處理路徑為/webjars/* * 的請求,另一個處理路徑為 /**的請求 。

同樣,當第一次請求發生時,DispatcherServlet 中的 initHandlerMappings() 将會擷取所有注冊到 Spring 的 HandlerMapping 類型的執行個體,而 SimpleUrlHandlerMapping 恰好實作了 HandlerMapping 接口,這些 SimpleUrlHandlerMapping 類型的執行個體則會被寫入到類成員變量 handlerMappings 中。

private void initHandlerMappings(ApplicationContext context) {
    this.handlerMappings = null;
 //省略非關鍵代碼
    if (this.detectAllHandlerMappings) {
       // Find all HandlerMappings in the ApplicationContext, including ancestor contexts.
       Map<String, HandlerMapping> matchingBeans =
             BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
       if (!matchingBeans.isEmpty()) {
          this.handlerMappings = new ArrayList<>(matchingBeans.values());
          // We keep HandlerMappings in sorted order.
          AnnotationAwareOrderComparator.sort(this.handlerMappings);
       }
    }
    //省略非關鍵代碼
 }
            

接着我們再來了解下被包裝為 handlerMappings 的 ResourceHandler 是如何被 Spring 消費并處理的。

我們來回顧一下 DispatcherServlet 中的 doDispatch() 核心代碼:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
         //省略非關鍵代碼
          mappedHandler = getHandler(processedRequest);
          if (mappedHandler == null) {
             noHandlerFound(processedRequest, response);
             return;
          }
          //省略非關鍵代碼
 }
            

這裡的 getHandler() 将會周遊成員變量 handlerMappings:

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    if (this.handlerMappings != null) {
       for (HandlerMapping mapping : this.handlerMappings) {
          HandlerExecutionChain handler = mapping.getHandler(request);
          if (handler != null) {
             return handler;
          }
       }
    }
    return null;
 }
            

因為此處有一個 SimpleUrlHandlerMapping,它會攔截所有路徑的請求:

是以最終在 doDispatch() 的 getHandler() 将會擷取到此 handler,進而 mappedHandler==null 條件不能得到滿足,因而無法走到 noHandlerFound(),不會抛出 NoHandlerFoundException 異常,進而無法被後續的異常處理器進一步處理。

問題修正

那如何解決這個問題呢?還記得 WebMvcAutoConfiguration 類中 addResourceHandlers() 的前兩行代碼嗎?如果 this.resourceProperties.isAddMappings() 為 false,那麼此處直接傳回,後續的兩個 ResourceHandler 也不會被添加。

public void addResourceHandlers(ResourceHandlerRegistry registry) {
    if (!this.resourceProperties.isAddMappings()) {
       logger.debug("Default resource handling disabled");
       return;
    }
    //省略非關鍵代碼
 }
            

其調用 ResourceProperties 中的 isAddMappings() 的代碼如下:

public boolean isAddMappings() {
    return this.addMappings;
 }
            

到這,答案也就呼之欲出了,增加兩個配置檔案如下:

spring.resources.add-mappings=false
 spring.mvc.throwExceptionIfNoHandlerFound=true
            

修改 MyExceptionHandler 的 @ExceptionHandler 為 NoHandlerFoundException 即可:

@ExceptionHandler(NoHandlerFoundException.class)
            

這個案例在真實的産線環境遇到的機率還是比較大的,知道如何解決是第一步,了解其内部原理則更為重要。而且當你進一步去研讀代碼後,你會發現這裡的解決方案并不會隻有這一種,而剩下的就留給你去探索了。

重點回顧

通過以上兩個案例的介紹,相信你對 Spring MVC 的異常處理機制,已經有了進一步的了解,這裡我們再次回顧下重點:

  • DispatcherServlet 類中的 doDispatch() 是整個 Servlet 處理的核心,它不僅實作了請求的分發,也提供了異常統一處理等等一系列功能;
  • WebMvcConfigurationSupport 是 Spring Web 中非常核心的一個配置類,無論是異常處理器的包裝注冊(HandlerExceptionResolver),還是資源處理器的包裝注冊(SimpleUrlHandlerMapping),都是依靠這個類來完成的。

(https://www.java567.com,搜"spring")