高性能 Java 應(yīng)用層網(wǎng)關(guān)設(shè)計實踐
前言
上文我們簡單闡述了一下接入層網(wǎng)關(guān)的實現(xiàn)原理
不少人對 Java 網(wǎng)關(guān)的實現(xiàn)也比較感興趣,所以這篇文章我們來簡單談?wù)?Java 應(yīng)用網(wǎng)關(guān)設(shè)計,本文將會從以下幾個方面來闡述 Java 應(yīng)用層網(wǎng)關(guān)的設(shè)計
- Java 應(yīng)用層網(wǎng)關(guān)的必要性
- 核心網(wǎng)關(guān)技術(shù)選型
- 嵌入式網(wǎng)關(guān) 設(shè)計
Java 應(yīng)用層網(wǎng)關(guān)的必要性
我們的 Java 網(wǎng)關(guān)分為應(yīng)用層網(wǎng)關(guān)和業(yè)務(wù)嵌入式網(wǎng)關(guān)兩部分,架構(gòu)圖如下
在這里插入圖片描述
Java 網(wǎng)關(guān)分為核心網(wǎng)關(guān)和業(yè)務(wù)嵌入式網(wǎng)關(guān)服務(wù)兩部分,主要工作原理如下
接入層流量首先進入 Java 核心網(wǎng)關(guān),經(jīng)過一系列的 pipeline 處理(風(fēng)控,路由協(xié)議轉(zhuǎn)換、流控、降級等操作)后發(fā)起泛化調(diào)用再打入業(yè)務(wù)層網(wǎng)關(guān)
業(yè)務(wù)層網(wǎng)關(guān)也會經(jīng)過一系列的 pipeline(接口校驗,驗簽,session 校驗等)進入最終的業(yè)務(wù)邏輯,然后再調(diào)用相關(guān) dubbo 服務(wù)最終完成本次 Java 請求的響應(yīng)。
核心網(wǎng)關(guān)與嵌入式業(yè)務(wù)網(wǎng)關(guān)的功能如下
在這里插入圖片描述
其中嵌入式網(wǎng)關(guān)是以 jar 包的形式集成到業(yè)務(wù)的工程里的,具體為啥要這樣設(shè)計,后文會詳述。
首先來看 Java 網(wǎng)關(guān)為啥要分成核心網(wǎng)關(guān)和嵌入式業(yè)務(wù)網(wǎng)關(guān)兩部分,直接從接入層打到業(yè)務(wù)網(wǎng)關(guān)不是更省事嗎,何必多此一舉再加一層核心網(wǎng)關(guān),多加一層不是多了一個損耗嗎。
這里有三個原因
- 核心網(wǎng)關(guān)主要起著風(fēng)控,鑒權(quán)、路由協(xié)議轉(zhuǎn)換、流控、降級,打點統(tǒng)計(請求報錯等)等作用,這些功能對每一個層請求來說都是通用的,統(tǒng)一將這些功能抽離放在核心網(wǎng)關(guān)實現(xiàn)更合理。
- 當(dāng)然了,可以統(tǒng)一把第一點所述的這些功能放在接入層實現(xiàn),但這樣會讓接入層顯得很臃腫,另外第一點中有一個很重要的功能,路由協(xié)議轉(zhuǎn)換(將 http 轉(zhuǎn)成 dubbo),由于我們的接入層用的是 OpenResty,它是不支持這種協(xié)議轉(zhuǎn)換的,除非基于 OpenResty 做二次開發(fā),這樣費時費力,也無必要,這樣看來抽出一個 Java 核心網(wǎng)關(guān)來擔(dān)任第一點所述的功能是更合理的,計算機界不有一句話么:任何問題,在計算機界都可以通過加入一個中間層來解決。加一個 Java 核心網(wǎng)關(guān)符合單一職責(zé),分層的設(shè)計理念。
- 加入一個核心網(wǎng)關(guān),確實多了一層,也多了一個損耗,不過核心網(wǎng)關(guān)并不處理具體的邏輯,它主要起著流量轉(zhuǎn)發(fā)的作用,而且在下文我們可以看到,它采用了 webflux 這種反應(yīng)式編程框架,帶來的損耗比起引入它帶來的優(yōu)勢可以忽略不計。
接下來我們簡單談?wù)労诵木W(wǎng)關(guān)和業(yè)務(wù)網(wǎng)關(guān)的設(shè)計思路。
核心網(wǎng)關(guān)技術(shù)選型
同步阻塞 VS 異步非阻塞
上節(jié)介紹可知 Java 核心網(wǎng)關(guān)承擔(dān)著所有的流量入口,本身會調(diào)用大量的業(yè)務(wù)接口(打到業(yè)務(wù)網(wǎng)關(guān)里),所以 IO 操作會很頻繁,在技術(shù)選型上是有要求的, 首先來看看傳統(tǒng)的 Spring MVC(servlet 3.0之前)
很明顯它是同步阻塞的, 一個請求需要對應(yīng)一個 Servlet Thread 來處理,當(dāng)有 DB,網(wǎng)絡(luò) IO 時,此線程會阻塞,可想而知用這種方案線程很快會占滿,導(dǎo)致系統(tǒng)不可用。
顯然我們應(yīng)該采用異步非阻塞的編程模型,它是如何工作的呢,如下圖示
工作原理如下
- 只有一個 request 線程負責(zé) accept 所有的請求,每個請求都有一個 Event handler 和回調(diào),request 線程接收到 request 請求后,首先會為此請求在 Event Loop 中注冊一個回調(diào)函數(shù),緊接著馬上把這個請求丟給線程池中的某個線程處理,然后此 request 線程立馬返回,馬上就可以處理另外的請求了。
- 線程池中的線程處理完請求的 Event Handler(DB,網(wǎng)絡(luò)IO等邏輯) 后,會去調(diào)用之前注冊好的回調(diào)函數(shù)返回請求結(jié)果
從以上的工作原理可以看出,負責(zé)處理請求的 request 線程只需求一個,線程數(shù)大大減少!更少的線程意味著更高的內(nèi)存利用,也意味著線程間的切換開銷大大減少!所以顯然應(yīng)該使用這種編程模型。
打個簡單的比方,相信大家都有去酒店就餐的經(jīng)歷,對于酒店來說,怎么才能最大化地提高接客效率呢
- 一種方式是對每一個客人,都安排一位接待員,這名接待員負責(zé)客人的接待,入座,上菜等所有流程,顯然如果這樣安排的話有多少位客人就等安排多少位接待員。
- 第二種方式是只安排一位接待員,這名接待員在接待客人入座后,立刻回到門口迎接客人,剩下的交給上菜服務(wù)員(線程池工作),這樣的話接待員的人數(shù)就大大減少了,能極大地提升效率。
最終我們選擇了 Spring WebFlux 這種反應(yīng)式(Reactive),基于事件驅(qū)動的異步非阻塞框架。
反應(yīng)式編程與 Spring WebFlux 簡介
反應(yīng)式編程簡介
反應(yīng)式編程 (reactive programming) 是一種基于數(shù)據(jù)流 (data stream) 和 變化傳遞 (propagation of change) 的 聲明式 (declarative) 的編程范式。它是一種編程思想,能夠基于數(shù)據(jù)流中的事件(變化)進行相關(guān)反應(yīng)處理,舉個簡單的例子:在 a = b + c 這個語句中,要得到 a 的值,如果用傳統(tǒng)的編程模型,每次 b 或 c 變化后都需要重新計算以獲得 a,而在反應(yīng)式編程中,我們把 b,c 當(dāng)作數(shù)據(jù)流,a 會對 b,c 作出的變化實時響應(yīng)。
反應(yīng)式編程有以下幾個特點
1、事件驅(qū)動
在事件驅(qū)動的程序中,組件之間通過松藕合的生產(chǎn)者(也稱被訂閱者,即 Publisher)和訂閱者模式(Subscriber)來實現(xiàn),這些事件是以異步和非阻塞的方式來接收和發(fā)送的,基于事件驅(qū)動的編程有啥好處呢,簡單地說它是依靠推模式而不是拉模式來動作的,也就是說只有生產(chǎn)者有消息(變化)時才會通知消費者作出響應(yīng),也就意味著消費者不需要輪詢也不需要等待數(shù)據(jù)。
2、實時響應(yīng)
以我們的網(wǎng)關(guān)為例, request 線程接收請求后,快速返回存儲結(jié)果的上下文,把具體執(zhí)行交給線程池里的線程(可以認為是后臺線程),處理完成后,異步地將調(diào)用結(jié)果封裝到結(jié)果的上下文中,可以看到此過程是完全異步的,也就是說實時響應(yīng)必須通過異步編程實現(xiàn),在 Java 8 中,發(fā)起調(diào)用后可以快速返回 CompletableFuture 對象。
3、彈性機制
事件驅(qū)動的松散耦合提供了組件在失敗下可以抓獲完全隔離的上下文場景,作為消息封裝,發(fā)送到其他組件時,在具體編程時可以檢查錯誤比如是否接受到,接受的命令是否可執(zhí)行等等,并決定如何應(yīng)對。
反應(yīng)式編程主要工作流程如下
- 被訂閱者主動推送數(shù)據(jù)給訂閱者,在異步或完成時觸發(fā)另外的兩個方法
- 被訂閱者發(fā)生異常,會觸發(fā) onError
- 所有的推送完成無異常,最終會執(zhí)行 onSuccess 方法
還有一個問題,如果 Publisher 發(fā)送消息過快超過 Subscriber 的處理速度了怎么辦,所以就得提一下背壓(BackPressure)的概念了,知乎網(wǎng)友扔物線對此概念解釋我認為非常到位:
backpressure 是源自工程學(xué)中的概念:在管道運輸中,氣流或液流由于管道突然變細、急彎等原因?qū)е掠赡程幊霈F(xiàn)了下游向上游的逆向壓力,這種情況稱為「backpressure」,相應(yīng)的在反應(yīng)式編程中,在數(shù)據(jù)流從上游生產(chǎn)者向下游消費者傳輸?shù)倪^程中,上游生產(chǎn)速度大于下游消費速度,導(dǎo)致下游的 Buffer 溢出,這種現(xiàn)象就叫做 Backpressure 出現(xiàn),這里的重點在于「Buffer 溢出」,為什么需要 buffer, 因為 Publisher 生產(chǎn)速度大于 Subscriber 的消費速度,所以需要 Buffer, 因為外部條件限制,顯然 Buffer 是有上限的,如果生產(chǎn)速度超過 buffer, 則 backpressure 產(chǎn)生,超過 buffer 的話,唯一的選擇就是丟掉新事件。
這就好比,比如你的 server 只能承受 5000~6000 的請求,如果你把 buffer 設(shè)置為 5000,則一旦請求數(shù)超過 5000,則背壓產(chǎn)生,超過的請求數(shù)丟棄,這樣保證了機器不會被源源不斷的 Publisher 生產(chǎn)事件壓垮,有效提升了網(wǎng)關(guān)的可用性。
Spring WebFlux 簡介
為了更好地促進反應(yīng)式編程的應(yīng)用,在 Java 平臺上,Netflix(開發(fā)了 RxJava)、TypeSafe(開發(fā)了 Scala、Akka)、Pivatol(開發(fā)了 Spring、Reactor)共同制定了一個被稱為 Reactive Streams 項目(規(guī)范),用于制定反應(yīng)式編程相關(guān)的規(guī)范以及接口。
Reactor 基于 Reactive Stream 定制了一套反應(yīng)式編程框架,而 WebFlux 則是以 Reactor 為基礎(chǔ)實現(xiàn)了 Web 領(lǐng)域的反應(yīng)式編程框架,由于反應(yīng)式編程的異步非阻塞特性,所以 WebFlux 運行于 Netty , Undertow 等支持異步編程模型的 server 之上,當(dāng)然也可運行于支持 Servlet 3.1 的 Server 容器上(Servlet 3.1 開始支持異步)
如圖示,左側(cè)是傳統(tǒng)的 Spring MVC 結(jié)構(gòu), 右側(cè)是 webflux 組件。
為了讓大家更好利用 webflux 編程,Spring 貼心地兼容了 @Controller 等 Spring MVC 的注解在 webflux 的使用,能讓使用者更好地過渡到 webflux 編程中來,不過在底層實現(xiàn)中,與 Spring MVC 的實現(xiàn)的請求 InputStream 和響應(yīng) OutputStream 不同,webflux 實現(xiàn)了一套反應(yīng)式的請求(ServerHttpRequest) 和響應(yīng)(ServerHttpResponse),這兩個類將請求體與響應(yīng)體以 Flux(Flux 下文會簡單介紹下)的形式暴露出來,同時 webflux 底層也實現(xiàn)了基于 Flux的 JSON,XML 的序列化和反序列化,HTML 實圖的渲染,Server 發(fā)送事件等。
通過介紹可以看到 webflux 實現(xiàn)了從請求到響應(yīng),到渲染,事件發(fā)送等一整套反應(yīng)式事件的支持,是的,要最大程度地發(fā)揮 webflux 的性能,中間所有的事件都應(yīng)該以 Mono 或 Flux 響應(yīng)式事件流的形式存在!
WebFlux 的底層實現(xiàn)其實是基于 Reactor 實現(xiàn)的,在 Reactor 的核心類中,以下兩個類代表了發(fā)布者
- Mono: 代表 0 到 1 個元素的發(fā)布者
- Flux:代表 0 到 N 個元素的發(fā)布者
這玩意怎么用呢,如下圖示
- @RequestMapping("/demo")
- @RestController
- public class DemoController {
- @RequestMapping(value = "/foobar")
- public Mono<Foobar> foobar() {
- return Mono.just(new Foobar());
- }
- }
本來是要返回 foobar 對象的,結(jié)果最終以 Mono(或 Flux)的形式存在,這樣就構(gòu)建了響應(yīng)式編程中的生產(chǎn)者(Publisher),再調(diào)用 subscribe 即可完成對生產(chǎn)者的監(jiān)聽消費。
在我們的網(wǎng)關(guān)設(shè)計中,當(dāng)收到請求后,使用了 Mono 來充當(dāng)發(fā)布者,如果中間出現(xiàn)了問題,會調(diào)用 onError, 最終成功后會調(diào)用 onSuccess,以下是網(wǎng)關(guān)實現(xiàn)采用的總體框架。
圖中 Mono.empty 代表創(chuàng)建一個不包含任何元素,只發(fā)布消息的隊列。發(fā)送消息后,會在線程池里處理網(wǎng)關(guān)的 slot ,最后處理成功后會調(diào)用 onSuccess 方法,處理失敗則會調(diào)用 onError。下一節(jié)我們來看看這些網(wǎng)關(guān) slot 是如何處理的。
網(wǎng)關(guān)的責(zé)任鏈設(shè)計
不管是核心網(wǎng)關(guān)還是嵌入式網(wǎng)關(guān)我們都采用了責(zé)任鏈模式來實現(xiàn)網(wǎng)關(guān)的核心處理流程,將每個處理邏輯看成一個slot,每個 slot 按照預(yù)先設(shè)定的順序先后執(zhí)行,與開源kong,zuul等類似,我們也采用了PRPE模式(Pre、Routing、Post、Error)
Pre 階段:
- initParamsSlot 初始化組裝請求上下文參數(shù)
- sentinelSlot 流控組件引入 ,做集群限流、降級、熔斷使用
- riskSlot 風(fēng)控處理
Route 階段:
- dubboSlot 通過 dubbo 泛化調(diào)用轉(zhuǎn)換成 dubbo 協(xié)議進行遠程調(diào)用
POST Slots: 后置處理
- APMMonitorSlot APM 監(jiān)控處理,請求出錯等打點監(jiān)控
采用這樣的設(shè)計方式,各個 slot 各司其職,也有較好的可擴展性,如果還想加什么 slot,定義好此 slot 功能,指定好其在調(diào)用鏈中的位置即可。
需要注意的是有些 Slot 的請求結(jié)果依賴于前面 Slot 的執(zhí)行結(jié)果,這種情況下需要對前面的執(zhí)行事件用 Mono 的形式封裝起來,這樣這些 slot 就構(gòu)成了一個個的響應(yīng)式事件流,保證了這些 Slot 都是異步執(zhí)行的,不會阻塞主線程。
另外注意高亮的 dubboSlot 階段,在 dubbo 2.7 之前 dubbo 底層返回 Future(會一直占用一個線程輪詢結(jié)果),對異步編程不友好,2.7 之后返回了 CompleteFuture,與 webflux 的異步編程模型完美結(jié)合(發(fā)起調(diào)用嵌入式網(wǎng)關(guān)后立馬返回,等調(diào)用完成后才執(zhí)行,是真正的異步)。
嵌入式網(wǎng)關(guān)設(shè)計
首先我們要明白為啥會有嵌入式網(wǎng)關(guān)的需求,主要有三個原因
- 目前有 H5, 小程序,app 端,各端的 session 存儲不一樣,需要根據(jù)請求的各端來查找 session 對應(yīng)的 uid,這個操作顯然應(yīng)該在網(wǎng)關(guān)層面來做,放在嵌入式網(wǎng)關(guān)來實現(xiàn)更合理
- 每個請求進入業(yè)務(wù)層之后,我們需要對其時間戳,app 簽名,小程序簽名等進行校驗,這些校驗對每個端的請求都是必要的,所以顯然應(yīng)該在網(wǎng)關(guān)來做
- 有些業(yè)務(wù)需要在執(zhí)行業(yè)務(wù)前后做一些擴展,比如執(zhí)行前后需要打點分析等,對擴展的實現(xiàn)網(wǎng)關(guān)也應(yīng)該支持
那么嵌入式網(wǎng)關(guān)如何實現(xiàn)呢,業(yè)務(wù)服務(wù)是以 dubbo 服務(wù)的形式存在的,而在 dubbo 中有一個 Filter 機制,是專門為服務(wù)提供方和服務(wù)消費方調(diào)用過程進行攔截設(shè)計的,每次遠程方法執(zhí)行,該攔截都會被執(zhí)行。這樣就為開發(fā)者提供了非常方便的擴展性,所以嵌入式網(wǎng)關(guān)的主要設(shè)計思路就是自定義 dubbo 的 filter,然后在此 filter 中執(zhí)行相關(guān)的擴展邏輯即可,偽代碼如下:
這樣通過自定義 filter 的方式我們解決了擴展性的問題,注意我們使用了Activate注解,這樣 dubbo 就會把注釋的Filter 作為 dubbo 原生的 Filter 自動加載,而不需要顯示的配置 provider 或者 consumer 的 filter,也就避免了對代碼的侵入性。
這里的業(yè)務(wù)邏輯執(zhí)行前后的擴展也是通過責(zé)任鏈的模式來執(zhí)行一個個的的 slot, 我們先定義好時間戳校驗,簽名校驗,Session轉(zhuǎn)id等 slot, 然后在 xml 中指定這些 slot 的執(zhí)行順序
每個業(yè)務(wù)都有一個 gateway.xml 文件,可以在此文件中配置 H5, app, 小程序需要執(zhí)行的 slot。
以對 app 請求配置需要執(zhí)行的前置 slot 和后置處理 slot 為例 ,偽代碼如下
這樣只要在啟動函數(shù)中引入(ImportResource)需要支持的 gateway 的 xml 文件,配置的 bean 就能生效,然后在 filter 中會分別取 bizChannel(請求必傳,代表是業(yè)務(wù)哪一端標(biāo)識,如 biz_h5, biz_app, biz_小程序)對應(yīng)的 slotBizList 即可執(zhí)行業(yè)務(wù)邏輯前后的擴展。
通過這樣的方式就有效地指定了業(yè)務(wù)邏輯執(zhí)行前后需要執(zhí)行的 slot,每個業(yè)務(wù)如果想在業(yè)務(wù)邏輯執(zhí)行前后進行擴展,只要定義好自己的 slot 邏輯,在 xml 文件中指定此 slot 的位置即可生效。
嵌入式網(wǎng)關(guān)按以上思路實現(xiàn)后,就通過 jar 包分發(fā)到各個業(yè)務(wù)系統(tǒng)。好處是:穩(wěn)定性提升,每個業(yè)務(wù)集成一個穩(wěn)定版本的網(wǎng)關(guān) Jar,某一個業(yè)務(wù)系統(tǒng)做網(wǎng)關(guān) Jar 升級時,其他業(yè)務(wù)系統(tǒng)都不受干擾
總結(jié)
本文詳細介紹了網(wǎng)關(guān)的實踐思路,相信大家對反應(yīng)式編程,dubbo filter 等應(yīng)該有了一定的了解,首先 Java 核心網(wǎng)關(guān)作為承載所有流量的入口,必然對其性能有較高的要求,而使用反應(yīng)式編程的異步非阻塞編程模型能很好地滿足我們的需求(關(guān)于反應(yīng)式編程的介紹如有不明白的,可以再看看文末的參考鏈接,介紹的清晰明了),其次不同業(yè)務(wù)在業(yè)務(wù)邏輯執(zhí)行前后需要做各種各樣的擴展,所以我們使用自定義的 filter 實現(xiàn)了這種需求,這種需求顯然放在嵌入式網(wǎng)關(guān)實現(xiàn)更合理,而讓嵌入式網(wǎng)關(guān)以 jar 包的形式嵌入業(yè)務(wù)服務(wù)中,做到了對業(yè)務(wù)層的無侵入,也有較強的可擴展性。
本文轉(zhuǎn)載自微信公眾號「碼?!梗梢酝ㄟ^以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系碼海公眾號。