服務閘道器zuul之三:zuul統一異常處理
過濾器中丟擲異常的問題
首先,我們可以來看看預設情況下,過濾器中丟擲異常Spring Cloud Zuul會發生什麼現象。我們建立一個pre型別的過濾器,並在該過濾器的run方法實現中丟擲一個異常。比如下面的實現,在run方法中呼叫的doSomething
方法將丟擲RuntimeException
異常。
package com.dxz.zuul; import org.apache.log4j.Logger; import com.netflix.zuul.ZuulFilter; public class ThrowExceptionFilter extends ZuulFilter { private static Logger log = Logger.getLogger(ThrowExceptionFilter.class); @Override public String filterType() { return "pre"; } @Override public int filterOrder() { return 0; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { log.info("This is a pre filter, it will throw a RuntimeException"); doSomething(); return null; } private void doSomething() { throw new RuntimeException("Exist some errors..."); } }
在啟動類為自定義過濾器建立具體的Bean才能啟動該過濾器,如下:
@Bean public ThrowExceptionFilter throwExceptionFilter() { return new ThrowExceptionFilter(); }
執行閘道器程式並訪問某個路由請求,此時我們會發現:在API閘道器服務的控制檯中輸出了ThrowExceptionFilter的過濾邏輯中的日誌資訊,但是並沒有輸出任何異常資訊,同時發起的請求也沒有獲得任何響應結果。為什麼會出現這樣的情況呢?我們又該如何在過濾器中處理異常呢?
解決方案一:嚴格的try-catch處理
執行閘道器程式並訪問某個路由請求,此時我們會發現:在API閘道器服務的控制檯中輸出了ThrowExceptionFilter
的過濾邏輯中的日誌資訊,但是並沒有輸出任何異常資訊,同時發起的請求也沒有獲得任何響應結果。為什麼會出現這樣的情況呢?我們又該如何在過濾器中處理異常呢?
回想一下,我們在上一節中介紹的所有核心過濾器,是否還記得有一個post
過濾器SendErrorFilter
是用來處理異常資訊的?根據正常的處理流程,該過濾器會處理異常資訊,那麼這裡沒有出現任何異常資訊說明很有可能就是這個過濾器沒有被執行。所以,我們不妨來詳細看看SendErrorFilter
的shouldFilter
@Override public boolean shouldFilter() { RequestContext ctx = RequestContext.getCurrentContext(); // only forward to errorPath if it hasn't been forwarded to already return ctx.containsKey("error.status_code") && !ctx.getBoolean(SEND_ERROR_FILTER_RAN, false); }
可以看到該方法的返回值中有一個重要的判斷依據ctx.containsKey("error.status_code")
,也就是說請求上下文中必須有error.status_code
引數,我們實現的ThrowExceptionFilter
中並沒有設定這個引數,所以自然不會進入SendErrorFilter
過濾器的處理邏輯。那麼我們要如何用這個引數呢?我們可以看一下route
型別的幾個過濾器,由於這些過濾器會對外發起請求,所以肯定會有異常需要處理,比如spring-cloud-netflix-core-1.1.4.RELEASE.jar中的org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter
的run
方法實現如下:
@Override public Object run() { RequestContext context = RequestContext.getCurrentContext(); this.helper.addIgnoredHeaders(); try { RibbonCommandContext commandContext = buildCommandContext(context); ClientHttpResponse response = forward(commandContext); setResponse(response); return response; } catch (ZuulException ex) { context.set(ERROR_STATUS_CODE, ex.nStatusCode); context.set("error.message", ex.errorCause); context.set("error.exception", ex); } catch (Exception ex) { context.set("error.status_code", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); context.set("error.exception", ex); } return null; }
可以看到,整個發起請求的邏輯都採用了try-catch
塊處理。在catch
異常的處理邏輯中並沒有做任何輸出操作,而是往請求上下文中新增一些error
相關的引數,主要有下面三個引數:
error.status_code
:錯誤編碼error.exception
:Exception
異常物件error.message
:錯誤資訊
其中,error.status_code
引數就是SendErrorFilter
過濾器用來判斷是否需要執行的重要引數。分析到這裡,實現異常處理的大致思路就開始明朗了,我們可以參考RibbonRoutingFilter
的實現對ThrowExceptionFilter
的run
方法做一些異常處理的改造,具體如下:
@Override public Object run() { log.info("This is a pre filter, it will throw a RuntimeException"); RequestContext ctx = RequestContext.getCurrentContext(); try { doSomething(); } catch (Exception e) { ctx.set("error.status_code", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); ctx.set("error.exception", e); } return null; }
通過上面的改造之後,我們再嘗試訪問之前的介面,這個時候我們可以得到如下響應內容:
{ "timestamp": 1481674980376, "status": 500, "error": "Internal Server Error", "exception": "java.lang.RuntimeException", "message": "Exist some errors..." }
此時,我們的異常資訊已經被SendErrorFilter
過濾器正常處理並返回給客戶端了,同時在閘道器的控制檯中也輸出了異常資訊。從返回的響應資訊中,我們可以看到幾個我們之前設定在請求上下文中的內容,它們的對應關係如下:
status
:對應error.status_code
引數的值exception
:對應error.exception
引數中Exception
的型別message
:對應error.exception
引數中Exception
的message
資訊。對於message
的資訊,我們在過濾器中還可以通過ctx.set("error.message", "自定義異常訊息");
來定義更友好的錯誤資訊。SendErrorFilter
會優先取error.message
來作為返回的message
內容,如果沒有的話才會使用Exception
中的message
資訊
解決方案二:ErrorFilter處理
通過上面的分析與實驗,我們已經知道如何在過濾器中正確的處理異常,讓錯誤資訊能夠順利地流轉到後續的SendErrorFilter
過濾器來組織和輸出。但是,即使我們不斷強調要在過濾器中使用try-catch
來處理業務邏輯並往請求上下文新增異常資訊,但是不可控的人為因素、意料之外的程式因素等,依然會使得一些異常從過濾器中丟擲,對於意外丟擲的異常又會導致沒有控制檯輸出也沒有任何響應資訊的情況出現,那麼是否有什麼好的方法來為這些異常做一個統一的處理呢?
這個時候,我們就可以用到error
型別的過濾器了。由於在請求生命週期的pre
、route
、post
三個階段中有異常丟擲的時候都會進入error
階段的處理,所以我們可以通過建立一個error
型別的過濾器來捕獲這些異常資訊,並根據這些異常資訊在請求上下文中注入需要返回給客戶端的錯誤描述,這裡我們可以直接沿用在try-catch
處理異常資訊時用的那些error引數,這樣就可以讓這些資訊被SendErrorFilter
捕獲並組織成訊息響應返回給客戶端。比如,下面的程式碼就實現了這裡所描述的一個過濾器:
package com.dxz.zuul; import javax.servlet.http.HttpServletResponse; import org.apache.log4j.Logger; import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; public class ErrorFilter extends ZuulFilter { Logger log = Logger.getLogger(ErrorFilter.class); @Override public String filterType() { return "error"; } @Override public int filterOrder() { return 10; } @Override public boolean shouldFilter() { return true; } @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); Throwable throwable = ctx.getThrowable(); log.error("this is a ErrorFilter :" + throwable.getCause().getMessage(), throwable); ctx.set("error.status_code", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); ctx.set("error.exception", throwable.getCause()); return null; } }
在啟動類為自定義過濾器建立具體的Bean才能啟動該過濾器,如下:
@Bean public ErrorFilter errorFilter() { return new ErrorFilter(); }
修改並保留ThrowExceptionFilter,
@Override public Object run() { log.info("This is a pre filter, it will throw a RuntimeException"); doSomething(); /*RequestContext ctx = RequestContext.getCurrentContext(); try { doSomething(); } catch (Exception e) { ctx.set("error.status_code", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); ctx.set("error.exception", e); }*/ return null; }
在將該過濾器加入到我們的API閘道器服務之後,我們可以嘗試使用之前介紹try-catch
處理時實現的ThrowExceptionFilter
(不包含異常處理機制的程式碼),讓該過濾器能夠丟擲異常。這個時候我們再通過API閘道器來訪問服務介面。此時,我們就可以在控制檯中看到ThrowExceptionFilter
過濾器丟擲的異常資訊,並且請求響應中也能獲得如下的錯誤資訊內容,而不是什麼資訊都沒有的情況了。
兩種解決方案:一種是通過在各個階段的過濾器中增加try-catch
塊,實現過濾器內部的異常處理;另一種是利用error
型別過濾器的生命週期特性,集中地處理pre
、route
、post
階段丟擲的異常資訊。通常情況下,我們可以將這兩種手段同時使用,其中第一種是對開發人員的基本要求;而第二種是對第一種處理方式的補充,以防止一些意外情況的發生。這樣的異常處理機制看似已經完美,但是如果在多一些應用實踐或原始碼分析之後,我們會發現依然存在一些不足。
不足之處
下面,我們不妨跟著原始碼來看看,到底上面的方案還有哪些不足之處需要我們注意和進一步優化的。先來看看外部請求到達API閘道器服務之後,各個階段的過濾器是如何進行排程的:
@Override public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException { try { init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse); // Marks this request as having passed through the "Zuul engine", as opposed to servlets // explicitly bound in web.xml, for which requests will not have the same data attached RequestContext context = RequestContext.getCurrentContext(); context.setZuulEngineRan(); try { preRoute(); } catch (ZuulException e) { error(e); postRoute(); return; } try { route(); } catch (ZuulException e) { error(e); postRoute(); return; } try { postRoute(); } catch (ZuulException e) { error(e); return; } } catch (Throwable e) { error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName())); } finally { RequestContext.getCurrentContext().unset(); } }
問題分析與進一步優化上面程式碼源自com.netflix.zuul.http.ZuulServlet
的service
方法實現,它定義了Zuul處理外部請求過程時,各個型別過濾器的執行邏輯。從程式碼中我們可以看到三個try-catch
塊,它們依次分別代表了pre
、route
、post
三個階段的過濾器呼叫,在catch
的異常處理中我們可以看到它們都會被error
型別的過濾器進行處理(之前使用error
過濾器來定義統一的異常處理也正是利用了這個特性);error
型別的過濾器處理完畢之後,除了來自post
階段的異常之外,都會再被post
過濾器進行處理。而對於從post
過濾器中丟擲異常的情況,在經過了error
過濾器處理之後,就沒有其他型別的過濾器來接手了,這就是使用之前所述方案存在不足之處的根源。
回想一下之前實現的兩種異常處理方法,其中非常核心的一點,這兩種處理方法都在異常處理時候往請求上下文中添加了一系列的error.*
引數,而這些引數真正起作用的地方是在post
階段的SendErrorFilter
,在該過濾器中會使用這些引數來組織內容返回給客戶端。而對於post
階段丟擲異常的情況下,由error
過濾器處理之後並不會在呼叫post
階段的請求,自然這些error.*
引數也就不會被SendErrorFilter
消費輸出。所以,如果我們在自定義post
過濾器的時候,沒有正確的處理異常,就依然有可能出現日誌中沒有異常並且請求響應內容為空的問題。我們可以通過修改之前ThrowExceptionFilter
的filterType
修改為post
來驗證這個問題的存在,注意去掉try-catch
塊的處理,讓它能夠丟擲異常。
解決上述問題的方法有很多種,比如最直接的我們可以在實現error
過濾器的時候,直接來組織結果返回就能實現效果,但是這樣的缺點也很明顯,對於錯誤資訊組織和返回的程式碼實現就會存在多份,這樣非常不易於我們日後的程式碼維護工作。所以為了保持對異常返回處理邏輯的一致,我們還是希望將post
過濾器丟擲的異常能夠交給SendErrorFilter
來處理。
在前文中,我們已經實現了一個ErrorFilter
來捕獲pre
、route
、post
過濾器丟擲的異常,並組織error.*
引數儲存到請求的上下文中。由於我們的目標是沿用SendErrorFilter
,這些error.*
引數依然對我們有用,所以我們可以繼續沿用該過濾器,讓它在post
過濾器丟擲異常的時候,繼續組織error.*
引數,只是這裡我們已經無法將這些error.*
引數再傳遞給SendErrorFitler
過濾器來處理了。所以,我們需要在ErrorFilter
過濾器之後再定義一個error
型別的過濾器,讓它來實現SendErrorFilter
的功能,但是這個error
過濾器並不需要處理所有出現異常的情況,它僅對post
過濾器丟擲的異常才有效。根據上面的思路,我們完全可以建立一個繼承自SendErrorFilter
的過濾器,就能複用它的run
方法,然後重寫它的型別、順序以及執行條件,實現對原有邏輯的複用,具體實現如下:
package com.dxz.zuul; import org.springframework.cloud.netflix.zuul.filters.post.SendErrorFilter; import org.springframework.stereotype.Component; @Component public class ErrorExtFilter extends SendErrorFilter { @Override public String filterType() { return "error"; } @Override public int filterOrder() { return 30; // 大於ErrorFilter的值 } @Override public boolean shouldFilter() { // TODO 判斷:僅處理來自post過濾器引起的異常 return true; } }
到這裡,我們在過濾器排程上的實現思路已經很清晰了,但是又有一個問題出現在我們面前:怎麼判斷引起異常的過濾器是來自什麼階段呢?(shouldFilter
方法該如何實現)對於這個問題,我們第一反應會寄希望於請求上下文RequestContext
物件,可是在查閱文件和原始碼後發現其中並沒有儲存異常來源的內容,所以我們不得不擴充套件原來的過濾器處理邏輯,當有異常丟擲的時候,記錄下丟擲異常的過濾器,這樣我們就可以在ErrorExtFilter
過濾器的shouldFilter
方法中獲取並以此判斷異常是否來自post
階段的過濾器了。
為了擴充套件過濾器的處理邏輯,為請求上下文增加一些自定義屬性,我們需要深入瞭解一下Zuul過濾器的核心處理器:com.netflix.zuul.FilterProcessor
。該類中定義了下面過濾器呼叫和處理相關的核心方法:到這裡,我們在過濾器排程上的實現思路已經很清晰了,但是又有一個問題出現在我們面前:怎麼判斷引起異常的過濾器是來自什麼階段呢?(shouldFilter
方法該如何實現)對於這個問題,我們第一反應會寄希望於請求上下文RequestContext
物件,可是在查閱文件和原始碼後發現其中並沒有儲存異常來源的內容,所以我們不得不擴充套件原來的過濾器處理邏輯,當有異常丟擲的時候,記錄下丟擲異常的過濾器,這樣我們就可以在ErrorExtFilter
過濾器的shouldFilter
方法中獲取並以此判斷異常是否來自post
階段的過濾器了。
getInstance()
:該方法用來獲取當前處理器的例項setProcessor(FilterProcessor processor)
:該方法用來設定處理器例項,可以使用此方法來設定自定義的處理器processZuulFilter(ZuulFilter filter)
:該方法定義了用來執行filter
的具體邏輯,包括對請求上下文的設定,判斷是否應該執行,執行時一些異常處理等getFiltersByType(String filterType)
:該方法用來根據傳入的filterType
獲取API閘道器中對應型別的過濾器,並根據這些過濾器的filterOrder
從小到大排序,組織成一個列表返回runFilters(String sType)
:該方法會根據傳入的filterType
來呼叫getFiltersByType(String filterType)
獲取排序後的過濾器列表,然後輪詢這些過濾器,並呼叫processZuulFilter(ZuulFilter filter)
來依次執行它們preRoute()
:呼叫runFilters("pre")
來執行所有pre
型別的過濾器route()
:呼叫runFilters("route")
來執行所有route
型別的過濾器postRoute()
:呼叫runFilters("post")
來執行所有post
型別的過濾器error()
:呼叫runFilters("error")
來執行所有error
型別的過濾器
根據我們之前的設計,我們可以直接通過擴充套件processZuulFilter(ZuulFilter filter)
方法,當過濾器執行丟擲異常的時候,我們捕獲它,並往請求上下中記錄一些資訊。比如下面的具體實現:
package com.dxz.zuul; import com.netflix.zuul.FilterProcessor; import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; import com.netflix.zuul.exception.ZuulException; public class DidiFilterProcessor extends FilterProcessor { @Override public Object processZuulFilter(ZuulFilter filter) throws ZuulException { try { return super.processZuulFilter(filter); } catch (ZuulException e) { RequestContext ctx = RequestContext.getCurrentContext(); ctx.set("failed.filter", filter); throw e; } } }
在上面程式碼的實現中,我們建立了一個FilterProcessor
的子類,並重寫了processZuulFilter(ZuulFilter filter)
,雖然主邏輯依然使用了父類的實現,但是在最外層,我們為其增加了異常捕獲,並在異常處理中為請求上下文添加了failed.filter
屬性,以儲存丟擲異常的過濾器例項。在實現了這個擴充套件之後,我們也就可以完善之前ErrorExtFilter
中的shouldFilter()
方法,通過從請求上下文中獲取該資訊作出正確的判斷,具體實現如下:
package com.dxz.zuul; import org.springframework.cloud.netflix.zuul.filters.post.SendErrorFilter; import org.springframework.stereotype.Component; import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; @Component public class ErrorExtFilter extends SendErrorFilter { @Override public String filterType() { return "error"; } @Override public int filterOrder() { return 30; // 大於ErrorFilter的值 } @Override public boolean shouldFilter() { // 判斷:僅處理來自post過濾器引起的異常 RequestContext ctx = RequestContext.getCurrentContext(); ZuulFilter failedFilter = (ZuulFilter) ctx.get("failed.filter"); if (failedFilter != null && failedFilter.filterType().equals("post")) { return true; } return false; } }
到這裡,我們的優化任務還沒有完成,因為擴充套件的過濾器處理類並還沒有生效。最後,我們需要在應用主類中,通過呼叫FilterProcessor.setProcessor(new DidiFilterProcessor());
方法來啟用自定義的核心處理器以完成我們的優化目標