34. springboot+springcloud+vue+uniapp b2b2c商城之Zuul過濾器介紹傳遞資料、攔截

跟我學習分散式發表於2021-04-15

在教程《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 中是無效的。

推薦布式微服務商城

相關文章