网关Zuul科普
為什么要使用網關
不同的微服務一般會有不同的網絡地址,而外部客戶端(例如手機APP)可能需要調用多個服務的接口才能完成一個業務需求。例如一個電影購票的手機APP,可能會調用多個微服務的接口,才能完成一次購票的業務流程,如下圖所示。
如果讓客戶端直接與各個微服務通信,會有以下的問題:
-
客戶端會多次請求不同的微服務,增加了客戶端的復雜性。
-
存在跨域請求,在一定場景下處理相對復雜。
-
認證復雜,每個服務都需要獨立認證。
-
難以重構,隨著項目的迭代,可能需要重新劃分微服務。例如,可能將多個服務合并成一個或者將一個服務拆分成多個。如果客戶端直接與微服務通信,那么重構將會很難實施。
-
某些微服務可能使用了防火墻/瀏覽器不友好的協議,直接訪問會有一定的困難。
以上問題可借助微服務網關解決。微服務網關是介于客戶端和服務器端之間的中間層,所有的外部請求都會先經過微服務網關。使用微服務網關后,架構如下所示。
此時,微服務網關封裝了應用程序的內部結構,客戶端只須跟網關交互,而無須直接調用特定微服務的接口。這樣,開發就可以得到簡化。不僅如此,使用微服務網關還有以下優點:
-
易于監控??稍谖⒎站W關收集監控數據并將其推送到外部系統進行分析。
-
易于認證??稍谖⒎站W關上進行認證,然后再將請求轉發到后端的微服務,而無須在每個微服務中進行認證。
-
減少了客戶端與各個微服務之間的交互次數。
Zuul 簡介
Zuul 是Netflix開源的一個API網關(代碼托管地址:https://github.com/Netflix/zuul), 本質上是一個Web Servlet應用。Zuul也是Spring Cloud全家桶中的一員, 它可以和Eureka、Ribbon、Hystrix等組件配合使用。
Zuul的核心是一系列的過濾器,這些過濾器幫助我們完成以下功能:
-
驗證與安全保障: 識別面向各類資源的驗證要求并拒絕那些與要求不符的請求。
-
審查與監控: 在邊緣位置追蹤有意義數據及統計結果,從而為我們帶來準確的生產狀態結論。
-
動態路由: 以動態方式根據需要將請求路由至不同后端集群處。
-
壓力測試: 逐漸增加指向集群的負載流量,從而計算性能水平。
-
負載分配: 為每一種負載類型分配對應容量,并棄用超出限定值的請求。
-
靜態響應處理: 在邊緣位置直接建立部分響應,從而避免其流入內部集群。
-
多區域彈性: 跨越AWS區域進行請求路由,旨在實現ELB使用多樣化并保證邊緣位置與使用者盡可能接近。
除此之外,Netflix公司還利用Zuul的功能通過金絲雀版本實現精確路由與壓力測試。
注:以上介紹來自Zuul官方文檔,但其實開源版本的Zuul以上功能一個都沒有——開源的Zuul只是幾個Jar包而已,以上能力指的應該是Netflix官方自用的Zuul的能力。
快速入門
定義2個服務:hello-server和user-server,他們分別都注冊到eureka服務上,示例如下(這里將下面講到的Zuul也注冊上去了):
在未經過網關時,我們可以通過以下2個接口來分別訪問hello-server和user-server:
http://localhost:8081/hello http://localhost:8082/user現在我們來定義Zuul服務,相關的Maven依賴如下:
<dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-zuul</artifactId><version>2.2.2.RELEASE</version></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-netflix-eureka-client</artifactId><version>2.2.2.RELEASE</version></dependency> </dependencies>application.yml文件中添加如下配置:
spring:application:name: zuul-serviceeureka:client:service-url:defaultZone: http://localhost:8761/eurekaserver:port: 6069啟動類中添加@EnableZuulProxy注解
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.netflix.zuul.EnableZuulProxy;@SpringBootApplication @EnableZuulProxy public class ZuulServiceApplication {public static void main(String[] args) {SpringApplication.run(ZuulServiceApplication.class, args);} }啟動程序之后,我們可以通過服務網關訪問上面的2個接口:
curl http://localhost:6069/hello-server/hello curl http://localhost:6069/user-server/user注意:默認的Zuul結合Eureka會注冊到Eureka的服務名作為訪問的ContextPath。在Zuul中我們可以自定義配置各種路由規則,這里就不再做相關贅述了。
請求過濾
上面的示例中,我們通過Zuul實現了請求路由的功能,這樣我們的微服務應用提供的接口就可以通過統一的API網關入口被客戶端訪問到了。
每個客戶端用戶請求服務應用提供的接口時,他們的訪問權限往往都有一定的限制,系統并不會將所有的微服務接口對他們開放。然而,目前的服務路由并沒有限制權限這樣的功能,所有請求都會被毫無保留的轉發到具體的應用并返回結果,為了實現對客戶端請求的安全校驗和權限控制,最簡單和粗暴的方法就是在每個微服務應用都實現一套用于校驗簽名和鑒別權限的過濾器或攔截器。這樣有個問題就是功能實現太過冗余。比較好的做法就是將這些校驗邏輯剝離出去,構建一個獨立的鑒權服務。在完成剝離之后,直接在微服務應用中通過調用鑒權系統服務來實現校驗,但是這樣僅僅只是解決了鑒權邏輯的分離,并沒有在本質上將這部分不屬于冗余的邏輯從原有的微服務應用中拆出去,冗余的攔截器或者過濾器依然會存在。
對于這樣的問題,更好的做法是通過前置的網關服務來完成這些非業務性質的校驗。由于網關服務的加入,外部客戶端訪問我們的系統已經有了統一的入口,既然這些校驗與具體的業務無關,那何不在請求到達的時候就完成校驗和過濾,微服務應用端就可以去除各種復雜的過濾器和攔截器了,這使得微服務應用接口的開發和測試復雜度也得到了相應的降低。這就涉及到了zuul的另一個主要功能,請求過濾。
下面通過一個簡單的示例來了解一下過濾器的使用:
import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; import com.netflix.zuul.exception.ZuulException; import lombok.extern.log4j.Log4j2;import javax.servlet.http.HttpServletRequest;@Log4j2 public class AccessFilter extends ZuulFilter {//過濾器的類型,它決定過濾器在請求的哪個生命周期中執行,這里定義為pre,代表會在請求被理由之前執行。@Overridepublic String filterType() {return "pre";}//過濾器的執行順序。當請求在一個階段中存在多個過濾器時,需要根據該方法返回的值來依次執行@Overridepublic int filterOrder() {return 0;}//判斷該過濾器是否需要被執行。這里我們直接返回了true,因此該過濾器對所有的請求都生效。實際運行中我們可以利用該函數//來指定過濾器的有效范圍。@Overridepublic boolean shouldFilter() {return true;}//過濾器的具體執行邏輯。@Overridepublic Object run() throws ZuulException {RequestContext ctx = RequestContext.getCurrentContext();HttpServletRequest request = ctx.getRequest();log.info("send {} request to {}", request.getMethod(), request.getRequestURI().toString());Object accessToken = request.getParameter("accessToken");if (accessToken == null) {log.warn("access token is empty");ctx.setSendZuulResponse(false);ctx.setResponseStatusCode(401);} else {log.info("access token ok");}return null;} }代碼示例中的ZuulFilter接口中定義了4個方法:
-
filterType:過濾器的類型(Type),它決定過濾器在請求的哪個生命周期中執行,這里定義為pre,代表會在請求被理由之前執行。(有關過濾器的類型會在下面的篇幅中詳細描述)
-
filterOrder:過濾器的執行順序(Execution Order)。當請求在一個階段中存在多個過濾器時,需要根據該方法返回的值來依次執行。
-
shouldFilter:判斷該過濾器是否需要被執行(Criteria)。這里我們直接返回了true,因此該過濾器對所有的請求都生效。實際運行中我們可以利用該函數。
-
run:過濾器的具體執行邏輯(Action)。
在啟動類中添加上這個過濾器:
@Bean public AccessFilter accessFilter(){return new AccessFilter(); }此時,再訪問 curl http://localhost:6069/hello-server/hello 接口時會報錯,狀態碼為401,正確的訪問姿勢是:
curl http://localhost:6069/hello-server/hello?accessToken=666過濾器的生命周期
Zuul中定義了4種標準的過濾器:pre、routing、post以及error,這些過濾器類型對應于請求的典型生命周期。我們參考下面的生命周期圖來講述一下這4種過濾器的作用以及執行順序。
外部HTTP請求到達API網關服務的時候,首先它會進入第一個階段pre,在這里它會被pre類型的過濾器進行處理。該類型過濾器的主要目的是在進行請求路由之前做一些前置加工,比如權限限制等。
在完成了pre類型的過濾器處理之后,請求進入第二個階段routing,也就是路由請求轉發階段,請求將會被routing類型的過濾器處理。這里的具體處理內容就是將外部請求轉發到具體服務實例上去的過程,當服務實例請求結果都返回之后,routing階段完成,請求進入第三個階段post。
此時請求將會被post類型的過濾器處理,這些過濾器在處理的時候不僅可以獲取到請求信息,還能獲取到服務實例的返回信息,所以在post類型的過濾器中,我們可以對處理結果進行一些加工或轉換等內容,比如為響應添加標準的HTTP Header、收集統計信息和指標等。
另外,還有一個特殊的階段error,該階段只有在上述三個階段中發生異常的時候才會觸發。我們通過下面的過濾器執行流程圖來加深一下對error過濾器的理解。
一般來講,正常流程是pre -> route -> post。如果在pre過濾器階段拋出異常,那么流程是:pre -> error -> post;如果在route過濾階段拋出異常,那么流程是:pre -> route -> error -> post;如果在post過濾階段拋出異常,最終流程是:pre -> route -> post -> error。
除了默認的過濾器類型,Zuul還允許我們創建自定義的過濾器類型。例如,我們可以定制一種STATIC類型的過濾器,直接在Zuul中生成響應,而不將請求轉發到后端的微服務。
過濾器是Zuul實現API網關功能最核心的部件,每一個進入Zuul的HTTP請求都會經過一系列的過濾器處理鏈得到請求響應并返回給客戶端。就以Zuul的路由功能為例,路由功能在真正運行時,它的路由映射和請求轉發都是由幾個不同的過濾器完成的。其中,路由映射主要通過pre類型的過濾器完成,它將請求路徑與配置的路由規則進行匹配,以找到需要轉發的目標地址;而請求轉發的部分則是由route類型的過濾器來完成,對pre類型過濾器獲得的路由地址進行轉發。
Zuul的架構
下圖展示了Zuul Core的工作原理,根據此圖,我們可以更好地理解Zuul。
Zuul 的過濾器基本上是由 Groovy 語言編寫的,這些過濾器起初以文件(以.groovy結尾)的形式存放在特定的目錄下面。Zuul中的 FilterFileManager 會定期輪訓這些目錄,新加入的或者修改過的過濾器會被動態的加載進來。FilterFileManager 讀取完 .groovy 文件之后會使用 GroovyComplier 將其編譯成為JVM Class,之后再實例化(Class.newInstance)成 ZuulFilter 對象(即過濾器),最終保存在 FilterRegistry 中。FilterRegistry 是圖中 FilterLoader 包含的一個對象,所以我們可以說成是:ZuulFilter 對象最終保存在FilterLoader中。
FilterRegistry可以看成是一個ConcurrentHashMap,其中key為.groovy文件的路徑,value是動態加載之后的ZuulFilter對象。
Zuul的過濾器之間沒有直接的相互通信,他們之間通過一個RequestContext(也可以看成是一個ConcurrentHashMap)來進行數據傳遞的。RequestContext 類中由 ThreadLocal 變量來記錄每個 Request 所需要傳遞的數據。
當一個請求進入 Zuul 時,首先是交由 ZuulServlet 處理,ZuulServlet 中有一個ZuulRunner對象,該對象中初始化了前面所說的RequestContext。ZuulRunner中還有一個FilterProcessor,這個FilterProcessor從FilterLoader(FilterRegistry)中獲取ZuulFilter(s)。有了這些ZuulFilter(s)之后,ZuulServlet首先執行的pre類型的過濾器,再執行route類型的過濾器,最后執行的是post 類型的過濾器,如果在執行這些過濾器有錯誤的時候則會執行error類型的過濾器。執行完這些過濾器,最終將請求的結果返回給客戶端。
Zuul 2.x
5 月 21 日,Netflix 在其官方博客上宣布正式開源微服務網關組件 Zuul 2(Zuul是 Netflix 于 2013 年 6 月 12 日開源的,為了便于區分,下面都將前面所講的 Zuul 表述為 Zuul 1)。Zuul 2 和 Zuul 1 在架構方面的主要區別在于,Zuul 2 運行在異步非阻塞的框架上,比如 Netty。Zuul 1 依賴多線程來支持吞吐量的增長,而 Zuul 2 使用的 Netty 框架依賴事件循環和回調函數。
Zuul2是一個在 Netty 上運行一系列Filter的服務,執行完成inbound filters之后將請求通過 Netty Client 轉發出去,然后將請求的結果通過一系列outbound filters返回,如上圖所示。正如之前的ZuulFilter分為了pre、post、routing、error,Zuul 2的Filter分為三種類型:
-
Inbound Filters: 在路由之前執行
-
Endpoint Filters: 路由操作
-
Outbound Filters: 得到相應數據之后執行
Zuul 2大體架構如上圖所示,和Zuul 1沒有本質上的區別。之前ZuulFilter分為了pre、post、routing、error,Zuul 2的Filter分為三種類型:inbound、endpoint、outbound。在Zuul 2中,過濾器前端用Netty Server代替了原本 Zuul 1中的Servlet,后端過濾器使用Netty Client 代替了HttpClient,這樣前后端都可以支持異步(Zuul1可以使用Servlet 3.0規范支持的AsyncServlet進行優化,可以實現前端異步,支持更多的連接數,達到和Zuul2一樣的效果)。相比如Zuul 1,Zuul 2在功能上也豐富和優化了很多,比如對HTTP/2、WebSocket的支持。
Zuul 1 vs Zuul 2
Zuul1設計比較簡單,代碼不多也比較容易讀懂,它本質上就是一個同步Servlet,采用多線程阻塞模型,如下圖所示。
同步Servlet使用thread per connection方式處理請求。簡單講,對于每一個新入站的請求,Servlet容器都要為其分配一個線程,直到響應返回客戶端這個線程才會被釋放返回容器線程池。如果后臺服務調用比較耗時,那么這個線程就會被阻塞,阻塞期間線程資源被占用,不能執行其他任務。Servlet容器線程池的大小是有限制的,當前端請求量大,而后臺慢服務比較多時,很容易耗盡容器線程池內的線程,造成容器無法接受新的請求,Netflix為此還專門研發了Hystrix熔斷組件來解決慢服務耗盡資源問題。
這種同步阻塞模式編程模型比較簡單,整個請求->處理->響應的流程(call flow)都是在一個線程中處理的,開發調試也便于理解,Debug也比較方便。不過,同步阻塞模式一般會啟動很多的線程,必然引入線程切換開銷。另外,同步阻塞模式下,容器線程池的數量一般是固定的,造成對連接數有一定限制,當后臺服務慢,容器線程池易被耗盡,一旦耗盡容器會拒絕新的請求,這個時候容器線程其實并不忙,只是被后臺服務調用IO阻塞,但是干不了其它事情。
總體上,同步阻塞模式比較適用于計算密集型(CPU bound)應用場景。對于IO密集型場景(IO bound),同步阻塞模式會白白消耗很多線程資源,它們都在等待IO的阻塞狀態,沒有做實質性工作。
Zuul2的設計相對比較復雜,代碼也不太容易讀懂,它采用了Netty實現異步非阻塞編程模型,如下圖所示。
如果需要閱讀 Zuul 2源碼,通過《[Zuul2源碼分析](http://springcloud.cn/view/344)》這篇文章輔助一下也許會事半功倍。
在上圖中,你可以簡單理解為前端有一個隊列專門負責處理用戶請求,后端有個隊列專門負責處理后臺服務調用,中間有個事件環線程(Event Loop Thread),它同時監聽前后兩個隊列上的事件,有事件就觸發回調函數處理事件。這種模式下需要的線程比較少,基本上每個CPU核上只需要一個事件環處理線程,前端的連接數可以很多,連接來了只需要進隊列,不需要啟動線程,事件環線程由事件觸發,沒有多線程阻塞問題。
異步非阻塞模式啟動的線程很少,使用的線程資源少,上下文切換開銷也少。非阻塞模式可以接受的連接數大大增加,可以簡單理解為請求來了只需要進隊列,這個隊列的容量可以設得很大,只要不超時,隊列中的請求都會被依次處理。異步模式讓編程模型變得復雜。異步模型沒有一個明確清晰的請求->處理->響應執行流程,它的流程是通過事件觸發的,請求處理的流程隨時可能被切換斷開,內部實現要通過一些關聯id機制才能把整個執行流再串聯起來,這就給開發調試運維引入了很多復雜性,比如你在IDE里頭調試異步請求流就非常困難。
總體上,異步非阻塞模式比較適用于IO密集型(IO bound)場景,這種場景下系統大部分時間在處理IO,CPU計算比較輕,少量事件環線程就能處理。
至于Zuul1和Zuul2的性能比對,Netflix給出了一個比較模糊的數據,大致Zuul2的性能比Zuul1好20%左右,這里的性能主要指每節點每秒處理的請求數。為什么說模糊呢?因為這個數據受實際測試環境,流量場景模式等眾多因素影響,你很難復現這個測試數據。即便這個20%的性能提升是確實的,其實這個性能提升也并不大,和異步引入的復雜性相比,這20%的提升是否值得是個問題。Netflix本身在其Blog [References 5] 和 ppt [References 8] 中也是有點含糊其詞,甚至自身都有一些疑問的。
那么問題來了,你選則使用Zuul1還是Zuul2, 或者是Spring Cloud Gateway,亦或者是Kong?
Referencs
https://netflixtechblog.com/announcing-zuul-edge-service-in-the-cloud-ab3af5be08ee
https://www.jianshu.com/p/9c104186572d
http://www.itmuch.com/spring-cloud/finchley-16/
http://www.itmuch.com/spring-cloud/zuul/zuul-ha/
https://netflixtechblog.com/zuul-2-the-netflix-journey-to-asynchronous-non-blocking-systems-45947377fb5c
https://mp.weixin.qq.com/s/QkeIVTn97VmOc0Y18PAvYQ
https://blog.csdn.net/yang75108/article/details/86991401
https://github.com/strangeloop/StrangeLoop2017/blob/master/slides/ArthurGonigberg-ZuulsJourneyToNonBlocking.pdf
總結
- 上一篇: Spring Boot MongoDB
- 下一篇: 在 Java 项目中打印错误日志的正确姿