鎮博圖

springcloud 總集:https://www.tapme.top/blog/detail/2019-02-28-11-33
本篇中 Zuul 版本為 1.x,目前最新的是 2.x,二者在過濾器的使用上有較大區別
超長警告
項目代碼見文章結尾
一、背景
微服務架構將一個應用拆分為很多個微小應用,這樣會導致之前不是問題的問題出現,比如:
- 安全問題如何實現?
- 日志記錄如何實現?
- 用戶跟蹤如何實現?
上面的問題在傳統的單機應用很容易解決,只需要當作一個功能實現即可。但是在微服務中就行不通了,讓每個服務都實現一份上述功能,那是相當不現實的,費時,費力還容易出問題。
為了解決這個問題,需要將這些橫切關注點(分布式系統級別的橫切關注點和 spring 中的基本一個意思)抽象成一個獨立的且作為應用程序中所有微服務調用的過濾器和路由器的服務。這樣的服務被稱為——服務網管(service gateway),服務客戶端不再直接調用服務。取而代之的是,服務網關作為單個策略執行點(Policy Enforcement Point,PEP) , 所有調用都通過服務網管進行路由,然后送到目的地。
二、服務網關
1、什么是服務網關
之前的幾節中我們是通過 http 請求直接調用各個服務,通常在實際系統中不會直接調用。而是通過服務網關來進行服務調用。服務網關充當了服務客戶端和被調用服務間的中介。服務客戶端僅與服務網關管理的單個 url 進行對話。下圖說了服務網關在一個系統中的作用:

服務網關位於服務客戶端和相應的服務實例之間。所有的服務調用(內部和外部)都應流經服務網關。
2、功能
由於服務網關代理了所有的服務調用,因此它還能充當服務調用的中央策略執行點(PEP),通俗的說就能能夠在此實現橫切關注點,不用在各個微服務中實現。主要有以下幾個:
-
靜態路由——服務網關將所有的服務調用放置在單個 URL 和 API 路由后,每個服務對應一個固定的服務端點,方便開發人員的服務調用。
-
動態路由——服務網關可以檢測傳入的請求,根據請求數據和請求者執行職能路由。比如將一部分的調用路由到特定的服務實例上,比如測試版本。
-
驗證和授權——所有服務調用都經過服務網關,顯然可以在此進行權限驗證,確保系統安全。
-
日志記錄——當服務調用經過服務網關時,可以使用服務網關來收集數據和日志信息(比如服務調用次數,服務響應時間等)。還能確保在用戶請求上提供關鍵信息以確保日志統計(比如給每個用戶請求加一個 url 參數,每個服務中可通過該參數將關鍵信息對應到某個用戶請求)。
看到這兒可能會有這樣的疑問:所有調用都通過服務網關,難道服務網關不是單點故障和潛在瓶頸嗎?
1. 在單獨的服務器前,負載均衡器是很有用的。將負載均衡器放到多個服務網關前面是比較好的設計,確保服務網關可以實現伸縮。但是如果將負載均衡器置於所有服務前便不是一個好主意,會造成瓶頸。
2. 服務網關的代碼應該是無狀態的。有狀態的應用實現伸縮性較為麻煩
3. 服務網關的代碼應該輕量的。服務網關是服務調用的“阻塞點”,不易在服務網關處耽誤較長的時間,比如進行同步數據庫操作
三、實戰
使用 Netflix Zuul 來構建服務網關,配合之前的代碼,讓服務網關來管理服務調用。
在生產環境中不建議使用 zuul,該組件性能較弱,且已經停止更新
1、創建 zuulsvr 項目
詳細過程不贅述,和之前一樣(注意 spring cloud 版本要和之前一致),主要 pom 依賴如下:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka</artifactId>
</dependency>
2、配置 zuul
首先在啟動加入注解開啟 zuul 並注冊到 eureka 中

然后編寫配置文件:
spring:
application:
name: zuulservice
#服務發現配置
eureka:
instance:
prefer-ip-address: true
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka/
server:
port: 5555
這樣便以默認配置啟動了 zuul 服務網關。
3、路由配置
Zuul 核心就是一個反向代理。在微服務架構下,Zuul 從客戶端接受微服務調用並將其轉發給下游服務。要和下游服務進行溝通,Zuul 必須知道如何將進來的調用映射到下游路由中。Zuul 有一以下幾種路由機制:
- 通過服務發現自動映射路由
- 通過服務發現手動映射路由
- 使用靜態 URL 手動映射
1)、服務發現自動映射
默認情況下,Zuul 根據服務 ID 來進行自動路由。先將組織服務中的延時去掉

啟動之前的所有服務實例,然后通過 postman 訪問localhost:5555/organizationservice/organization/12,得到結果如下:

說明服務網關自動路由成功。
如果要查看 Zuul 服務器管理的路由,可以通過訪問 Zuul 服務器上的/routes,返回結果如下:
{
"/confsvr/**": "confsvr",
"/licensingservice/**": "licensingservice",
"/organizationservice/**": "organizationservice"
}
左邊的路由由基於 Eureka 的服務 ID 自動創建的,右邊為路由所有映射的 Eureka 服務 ID。
2)、服務發現手動手動
如果覺得自動路由不好用,我們還可以更細粒度地明確定義路由映射。例如想要縮短組織服務名稱來簡化路由,可在application.yml配置中定義路由映射,在配置文件中加入如下配置:
zuul:
routes:
organizationservice: /org/**
上面的配置將org開頭的路徑映射到組織服務上了。重啟服務器,訪問localhost:5555/org/organization/12,仍然能夠獲取到數據。
現在訪問/routes端點可以看到如下結果:
{
"/org/**": "organizationservice",
"/confsvr/**": "confsvr",
"/licensingservice/**": "licensingservice",
"/organizationservice/**": "organizationservice"
}
可以看到不光有自定義的組織路由,自動映射的組織路由也存在,如果想要排除自動映射的路由可配置ignored-services屬性,用法如下:
zuul:
routes:
organizationservice: /org/**
# 使用","分隔,“*”表示全部忽略
ignored-services: 'organizationservice'
服務網關有一種常見模式是通過使用/api之類的標記來為所有服務調用添加前綴,可通過配置prefix屬性來支持。用法如下:
zuul:
routes:
organizationservice: /org/**
# 使用","分隔,“*”表示全部忽略
ignored-services: 'organizationservice'
prefix: /api
配置后再次訪問/routes端點可以看到路徑前都加上了/api
3)、靜態 URL 手動映射
如果系統系統中還存在一些不受 Eureka 管理的服務,可以建立 Zuul 直接路由到一個靜態定義的 URL。假設許可證服務是其他語言編寫的 web 項目,並且希望通過 Zuul 來代理,可這樣配置:
zuul:
routes:
#用於內部識別關鍵字
licensestatic:
path: /licensestatic/**
url: http://localhost:8091
配置完成后重啟 zuul 訪問/routes端點如下所示,靜態路由已經加入:
{
"/api/licensestatic/**": "http://localhost:8091",
"/api/org/**": "organizationservice",
"/api/confsvr/**": "confsvr",
"/api/licensingservice/**": "licensingservice",
"/api/zuulservice/**": "zuulservice"
}
licensestatic 端點不再使用 Eureka,直接將請求路由到localhost:8091。但是這里存在一個問題,如果許可證服務有多個實例,該如何用到負載均衡?這里只能配置一條路徑指向請求。這里又有一個配置項來禁用 Ribbon 與 Eureka 集成,然后列出許可證服務的所有實例,配置如下:
#zuul配置
zuul:
routes:
#用於內部識別關鍵字
licensestatic:
path: /licensestatic/**
serviceId: licensestatic
organizationservice: /org/**
# 使用","分隔,“*”表示全部忽略
ignored-services: 'organizationservice'
prefix: /api
ribbon:
eureka:
#禁用Eureka支持
enabled: false
licensestatic:
ribbon:
#licensestatic服務將會路由到下列地址
listOfServers: http://localhost:10011,http://localhost:10012
配置完畢后,訪問/routes端點發現licensestatic/**映射到了 licensestatic 服務上,相當於 Zuul 模擬了一個服務出來。但是 Eureka 上是沒有這個服務的,所以需要禁用掉 Ribbon 的 Eureka 支持,不然是無法訪問成功的(Ribbon 向 Eureka 查詢該服務不存在,報錯)。現在 x=連續訪問localhost:5555//api/licensestatic/licensing/12,可以發現正常響應和 404 交替出現(10011 上能否訪問成功,10012 報錯 404),說明配置的多個地址生效了。
問題又來了
禁用eureka支持會導致所有服務的地址都需要手動指定,ribbon不會再從eureka中獲取服務實例信息。所以沒辦法混合使用
目前有兩種辦法來規避這個問題:
-
對於不能用 Eureka 管理的應用,可以建立一個單獨的 Zuul 服務器來處理這些路由。
-
建立一個 Spring Cloud Sidecar 實例。Spring Cloud Sidecar 允許開發使用 Eureka 實例注冊非 JVM 服務,然后再通過 Zuul 代理,相當於曲線救國。
4、動態重載路由
zuul 還有一個動態加載路由的功能,也就是在不重啟 zuul 服務的情況下刷新路由。
直接修改application.yml將 prefix 從/api改為/apis。注意這里修改后要讓修改生效需編譯一次 application.yml 讓修改替換到 target 文件中(idea 如此,eclipse 應該類似),或者直接到編譯文件夾下修改 application.yml
然后訪問/refresh路徑,可以看到如下返回值:

響應表明更新 prefix。然后訪問/routes路徑會發現前綴變成了apis
這個功能與 spring cloud config 配合,用起來就是爽。
5、服務超時
Zuul 使用 Netflix 的 Hystrix 和 Ribbon 庫來進行 http 請求。so 也是有超時機制存在的。配置方法和前面的一篇類似。但是只能通過配置文件來進行,無法通過注解(這是 Zuul 管理的沒有地方給你寫注解)。通過配置hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds屬性來實現。如果要為特定的服務配置只需將 default 替換為服務名就行了。
注意還要只有有另一個超時機制。雖然覆蓋了 hystrix 的超時,但是 Ribbon 也會超時任何超過 5s 的調用。so 如果超時時間大於 5s 還要配置 Ribbon 的超時,配置方式如下:
#對所有服務生效
ribbon.readTimeout: 7000
#對組織服務生效
licensingservice.ribbon.readTimeout: 7000
6、重點:過濾器
這才是服務網關真正重要的東西。有了過濾器才能實現自定義的通用處理邏輯。可在此進行通用的安全驗證、日志、服務跟蹤等操作。和 springboot 中的過濾器概念類似,這里就不做說明了。
Zuul 支持以下四種過濾器:
-
前置過濾器——在將請求發送到目的地之前被調用。通常進行請求格式檢查、身份驗證等操作。
-
后置過濾器——在目標服務被調用被將響應發回調用者后被調用。通常用於記錄從目標服務返回的響應、處理錯誤或審核敏感信息。
-
路由過濾器——在目標服務被調用之前攔截調用。通常用來做動態路由。
-
錯誤過濾器——在產生錯誤是調用,用於對錯誤進行統一處理。
下圖展示了在處理客戶端請求時,各種過濾器時如何工作的:

下面說說如何來使用這些過濾器:
a、前置過濾器
這里我們來實現一個過濾器-IdFilter,對每個請求檢查請求頭中是否有一個關聯 id,無 id 生成一個 id 加入到 header 中。代碼如下:
@Component
public class IdFilter extends ZuulFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(IdFilter.class);
/**
* 返回過濾器類型 ;pre:前置過濾器。post:后置過濾器。routing:路由過濾器。error:錯誤過濾器
*/
@Override
public String filterType() {
return "pre";
}
/**
* 過濾器執行順序
*/
@Override
public int filterOrder() {
return 1;
}
/**
* 是否啟動此過濾器
*/
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
String id = ctx.getRequest().getHeader("id");
//如果request找不到,再到zuul的方法中找id.request不允許直接修改response中的header,
// 所以為了讓后續的過濾器能夠獲取到id才有下面的語法
if(id==null){
id = ctx.getZuulRequestHeaders().get("id");
}
if (id == null) {
id = UUID.randomUUID().toString();
LOGGER.info("{} 無id,生成id:{}",ctx.getRequest().getRequestURI(), id);
ctx.addZuulRequestHeader("id", id);
} else {
LOGGER.info("{}存在id:{}", ctx.getRequest().getRequestURI(), id);
}
return null;
}
}
要在 Zuul 中實現過濾器,必須拓展 ZuulFilter 類(2.x 版本中不是這樣的),然后覆蓋上述 4 個方法。
要給請求頭加入一個 header 需要在ctx.addZuulRequestHreader("","")(上面代碼中的 RequestContext 是 zuul 重寫的,在其中加入了一些方法)方法中操作,zuul 會在發出請求是把 header 加到請求頭中。(因為 Zuul 本質是一個代理,它截取請求,然后自己再發送這個請求,所有不能也沒有必要在原來的 request 上加 header。
重啟項目 Zuul,訪問localhost:5555/apis/licensestatic/licensing/12,可以看到控制台有如下打印:

說明前置過濾器生效。
現在從 zuul 服務網關發往許可證服務的 http 請求已經攜帶了 id。
b、后置過濾器
后置過濾器通常用於進行敏感信息過濾和響應記錄。這里我們實現一個后置過濾器,將許可證服務請求的響應內容打印到控制台上同時把idheader 插入到服務客戶端請求的 response 中。
@Component
public class ResponseFilter extends ZuulFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(ResponseFilter.class);
/**
* 返回過濾器類型 ;pre:前置過濾器。post:后置過濾器。routing:路由過濾器。error:錯誤過濾器
*/
@Override
public String filterType() {
return "post";
}
/**
* 過濾器執行順序
*/
@Override
public int filterOrder() {
return 1;
}
/**
* 是否啟動此過濾器
*/
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run(){
RequestContext ctx = RequestContext.getCurrentContext();
String id = ctx.getZuulRequestHeaders().get("id");
ctx.getResponse().addHeader("id", id);
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(ctx.getResponseDataStream()));
String response = reader.readLine();
LOGGER.info("響應為:{}", response);
//寫到輸出流中,本來可以由zuul框架來操作,但是我們已經讀取了輸入流,zuul讀不到數據了,所以要手動寫響應到response
ctx.getResponse().setHeader("Content-Type","application/json;charset=utf-8");
ctx.getResponse().getWriter().write(response);
} catch (Exception e) {
}
return null;
}
}
經過這樣一波操作,就能達到目的了。訪問:localhost:5555/apis/licensestatic/licensing/12。控制台打印如下:

請求響應如下:

c、路由過濾器
路由過濾器用起來有點復雜,這里不寫具體的實際代碼,只是寫一個思路。具體代碼可以參考spring 微服務
- 獲取當前請求路徑
- 判斷是否需要進行特殊路由
- 如需要進行特殊路由,在此進行 http 調用
- 將 http 調用的 response 寫入到當前請求的 response 中
結束
終於寫完了,微服務的基礎學習又近了一步,加油!
本篇代碼存放於:github
本篇原創發布於:FleyX 的個人博客
