在教程《Zuul閘道器的介紹及使用》中一開始就介紹過,Zuul 可以實現很多高階的功能,比如限流、認證等。想要實現這些功能,必須要基於 Zuul 給我們提供的核心元件“過濾器”。下面我們一起來了解一下 Zuul 的過濾器。
過濾器型別
Zuul 中的過濾器跟我們之前使用的 javax.servlet.Filter 不一樣,javax.servlet.Filter 只有一種型別,可以透過配置 urlPatterns 來攔截對應的請求。
而 Zuul 中的過濾器總共有 4 種型別,且每種型別都有對應的使用場景。
1)pre
可以在請求被路由之前呼叫。適用於身份認證的場景,認證透過後再繼續執行下面的流程。
2)route
在路由請求時被呼叫。適用於灰度釋出場景,在將要路由的時候可以做一些自定義的邏輯。
3)post
在 route 和 error 過濾器之後被呼叫。這種過濾器將請求路由到達具體的服務之後執行。適用於需要新增響應頭,記錄響應日誌等應用場景。
4)error
處理請求時發生錯誤時被呼叫。在執行過程中傳送錯誤時會進入 error 過濾器,可以用來統一記錄錯誤資訊。
請求生命週期
可以透過圖 1 看出整個過濾器的執行生命週期,此圖來自 Zuul GitHub wiki 主頁。 推薦布式微服務商城
透過上面的圖可以清楚地知道整個執行的順序,請求發過來首先到 pre 過濾器,再到 routing 過濾器,最後到 post 過濾器,任何一個過濾器有異常都會進入 error 過濾器。
透過 com.netflix.zuul.http.ZuulServlet 也可以看出完整執行順序,ZuulServlet 類似 Spring MVC 的 DispatcherServlet,所有的 Request 都要經過 ZuulServlet 的處理。
ZuulServlet 原始碼如下所示:
@Override
public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse)
throws ServletException, IOException {
try {
init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);
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();
}
}
使用過濾器
我們建立一個 pre 過濾器,來實現 IP 黑名單的過濾操作,程式碼如下所示。
public class IpFilter extends ZuulFilter {
// IP黑名單列表
private List<String> blackIpList = Arrays.asList("127.0.0.1");
public IpFilter() {
super();
}
@Override
public boolean shouldFilter() {
return true
}
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 1;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
String ip = IpUtils.getIpAddr(ctx.getRequest());
// 在黑名單中禁用
if (StringUtils.isNotBlank(ip) && blackIpList.contains(ip)) {
ctx.setSendZuulResponse(false);
ResponseData data = ResponseData.fail("非法請求 ", ResponseCode.NO_AUTH_CODE.getCode());
ctx.setResponseBody(JsonUtils.toJson(data));
ctx.getResponse().setContentType("application/json; charset=utf-8");
return null;
}
return null;
}
}
由程式碼可知,自定義過濾器需要繼承 ZuulFilter,並且需要實現下面幾個方法:
1)shouldFilter
是否執行該過濾器,true 為執行,false 為不執行,這個也可以利用配置中心來實現,達到動態的開啟和關閉過濾器。
2)filterType
過濾器型別,可選值有 pre、route、post、error。
3)filterOrder
過濾器的執行順序,數值越小,優先順序越高。
4)run
執行自己的業務邏輯,本段程式碼中是透過判斷請求的 IP 是否在黑名單中,決定是否進行攔截。blackIpList 欄位是 IP 的黑名單,判斷條件成立之後,透過設定 ctx.setSendZuulResponse(false),告訴 Zuul 不需要將當前請求轉發到後端的服務了。透過 setResponseBody 返回資料給客戶端。
過濾器定義完成之後我們需要配置過濾器才能生效,IP 過濾器配置程式碼如下所示。
@Configuration
public class FilterConfig {
@Bean
public IpFilter ipFilter() {
return new IpFilter();
}
}
過濾器禁用
有的場景下,我們需要禁用過濾器,此時可以採取下面的兩種方式來實現:
利用 shouldFilter 方法中的 return false 讓過濾器不再執行
透過配置方式來禁用過濾器,格式為“zuul. 過濾器的類名.過濾器型別 .disable=true”。如果我們需要禁用“使用過濾器”部分中的 IpFilter,可以用下面的配置:
zuul.IpFilter.pre.disable=true
過濾器中傳遞資料
專案中往往會存在很多的過濾器,執行的順序是根據 filterOrder 決定的,那麼肯定有一些過濾器是在後面執行的,如果你有這樣的需求:第一個過濾器需要告訴第二個過濾器一些資訊,這個時候就涉及在過濾器中怎麼去傳遞資料給後面的過濾器。
實現這種傳值的方式筆者第一時間就想到了用 ThreadLocal,既然我們用了 Zuul,那麼 Zuul 肯定有解決方案,比如可以透過 RequestContext 的 set 方法進行傳遞,RequestContext 的原理就是 ThreadLocal。
RequestContext ctx = RequestContext.getCurrentContext();
ctx.set("msg", "你好嗎");
後面的過濾就可以透過 RequestContext 的 get 方法來獲取資料:
RequestContext ctx = RequestContext.getCurrentContext();
ctx.get("msg");
上面我們說到 RequestContext 的原理就是 ThreadLocal,這不是筆者自己隨便說的,而是筆者看過原始碼得出來的結論,下面請看原始碼,程式碼如下所示。
protected static final ThreadLocal<? extends RequestContext> threadLocal = new ThreadLocal<RequestContext>() {
@Override
protected RequestContext initialValue() {
try {
return contextClass.newInstance();
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
};
public static RequestContext getCurrentContext() {
if (testContext != null)
return testContext;
RequestContext context = threadLocal.get();
return context;
}
過濾器攔截請求
在過濾器中對請求進行攔截是一個很常見的需求,本節的“使用過濾器”部分中講解的 IP 黑名單限制就是這樣的一個需求。如果請求在黑名單中,就不能讓該請求繼續往下執行,需要對其進行攔截並返回結果給客戶端。
攔截和返回結果只需要 5 行程式碼即可實現,程式碼如下所示。
RequestContext ctx = RequestContext.getCurrentContext();
ctx.setSendZuulResponse(false);
ctx.set("sendForwardFilter.ran", true);
ctx.setResponseBody("返回資訊");
return null;
ctx.setSendZuulResponse(false) 告訴 Zuul 不需要將當前請求轉發到後端的服務。原理體現在 shouldFilter() 方法上,原始碼在 org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter 中的 shouldFilter() 方法裡,程式碼如下所示。
@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return (ctx.getRouteHost() == null && ctx.get(SERVICE_ID_KEY) != null && ctx.sendZuulResponse());
}
程式碼“ctx.set("sendForwardFilter.ran",true);”是用來攔截本地轉發請求的,當我們配置了 forward:/local 的路由,ctx.setSendZuulResponse(false) 對 forward 是不起作用的,需要設定 ctx.set("sendForwardFilter.ran",true) 才行。
對應實現的原始碼體現在 org.springframework.cloud.netflix.zuul.filters.route.SendForwardFilter的shouldFilter() 方法中,程式碼如下所示。
protected static final String SEND_FORWARD_FILTER_RAN = "sendForwardFilter.ran";
@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return ctx.containsKey(FORWARD_TO_KEY) && !ctx.getBoolean(SEND_FORWARD_FILTER_RAN, false);
}
到這一步之後,當前的過濾器中確實將請求進行攔截了,並且可以給客戶端返回資訊。但是當你的專案中有多個過濾器的時候,假如你需要過濾的那個過濾器是第一個執行的,發現非法請求,然後進行攔截,以筆者之前使用 javax.servlet.Filter 的經驗,進行攔截之後,在 chain.doFilter 之前進行返回就可以讓過濾器不往下執行了。
但是 Zuul 中的過濾器不一樣,即使你剛剛透過 ctx.setSendZuulResponse(false) 設定了不路由到服務,並且返回 null,那只是當前的過濾器執行完成了,後面還有很多過濾器在等著執行。
透過原始碼可以看出,Zuul 中 Filter 的執行邏輯如下:在 ZuulServlet 中的 service 方法中執行對應的 Filter,比如 preRoute()。preRoute 中會透過 ZuulRunner 來執行(程式碼如下所示)。
void preRoute() throws ZuulException {
zuulRunner.preRoute();
}
zuulRunner 中透過呼叫 FilterProcessor 來執行 Filter(程式碼如下所示)。
public void preRoute() throws ZuulException {
FilterProcessor.getInstance().preRoute();
}
FilterProcessor 透過過濾器型別獲取所有過濾器,並迴圈執行(程式碼如下所示)。
public Object runFilters(String sType) throws Throwable {
if (RequestContext.getCurrentContext().debugRouting()) {
Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
}
boolean bResult = false;
List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
if (list != null) {
for (int i = 0; i < list.size(); i++) {
ZuulFilter zuulFilter = list.get(i);
Object result = processZuulFilter(zuulFilter);
if (result != null && result instanceof Boolean) {
bResult |= ((Boolean) result);
}
}
}
return bResult;
}
透過上面的講解,我們大致知道了為什麼所有的過濾器都會執行,解決這個問題的辦法就是透過 shouldFilter 來處理,即在攔截之後透過資料傳遞的方式告訴下一個過濾器是否要執行。
改造上面的攔截程式碼,增加一行資料傳遞的程式碼:
ctx.set("isSuccess", false);
在 RequestContext 中設定一個值來標識是否成功,當為 true 的時候,後續的過濾器才執行,若為 false 則不執行。
利用這種方法,在後面的過濾器就需要用到這個值來決定自己此時是否需要執行,此時只需要在 shouldFilter 方法中加上如下所示的程式碼即可。
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
Object success = ctx.get("isSuccess");
return success == null ? true : Boolean.parseBoolean(success.toString());
}
過濾器中異常處理
對於異常來說,無論在哪個地方都需要處理。過濾器中的異常主要發生在 run 方法中,可以用 try catch 來處理。Zuul 中也為我們提供了一個異常處理的過濾器,當過濾器在執行過程中發生異常,若沒有被捕獲到,就會進入 error 過濾器中。
我們可以定義一個 error 過濾器來記錄異常資訊,程式碼如下所示。
public class ErrorFilter extends ZuulFilter {
private Logger log = LoggerFactory.getLogger(ErrorFilter.class);
@Override
public String filterType() {
return "error";
}
@Override
public int filterOrder() {
return 100;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
Throwable throwable = ctx.getThrowable();
log.error("Filter Erroe : {}", throwable.getCause().getMessage());
return null;
}
}
然後我們在其他過濾器中模擬一個異常資訊,改造本節“使用過濾器”部分中的 IpFilter 程式碼,在 run 方法中增加下面的程式碼來模擬 java.lang.ArithmeticException:/by zero。
System.out.println(2/0);
訪問我們的服務介面可以看到圖 2 所示的內容,500 錯誤資訊表示控制檯也有異常日誌輸出。
我們後端的介面服務都是 REST 風格的API,返回的資料都有固定的 Json 格式,現在變成這樣一個頁面了,讓客戶端那邊怎麼處理?我們透過實現 ErrorController 來解決這個問題。
ErrorController 的程式碼如下所示:
@RestController
public class ErrorHandlerController implements ErrorController {
@Autowired
private ErrorAttributes errorAttributes;
@Override
public String getErrorPath() {
return "/error";
}
@RequestMapping("/error")
public ResponseData error(HttpServletRequest request) {
Map<String, Object> errorAttributes = getErrorAttributes(request);
String message = (String) errorAttributes.get("message");
String trace = (String) errorAttributes.get("trace");
if (StringUtils.isNotBlank(trace)) {
message += String.format("and trace %s", trace);
}
return ResponseData.fail(message, ResponseCode.SERVER_ERROR_CODE.getCode());
}
private Map<String, Object> getErrorAttributes(HttpServletRequest request) {
return errorAttributes.getErrorAttributes(new ServletWebRequest(request), true);
}
}
我們再次訪問之前的介面,這次就不是一個錯誤頁面了,而是我們固定好的 Json 格式的資料,如圖 3 所示。
之前我們講解過 Spring Boot 中統一進行異常處理的辦法,也就是把頁面的錯誤轉換成了統一的 Json 格式資料返回給呼叫方,為什麼這裡還要用另一種辦法來實現呢?
因為 @ControllerAdvice 註解主要用來針對 Controller 中的方法做處理,作用於 @RequestMapping 標註的方法上,只對我們定義的介面異常有效,在 Zuul 中是無效的。