案例 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")