微服務架構體系中,通常一個業務系統會有很多的微服務,比如:OrderService、ProductService、UserService...,為了讓調用更簡單,一般會在這些服務前端再封裝一層,類似下面這樣:

前面這一層俗稱為“網關層”,其存在意義在於,將"1對N"問題 轉換成了"1對1”問題,同時在請求到達真正的微服務之前,可以做一些預處理,比如:來源合法性檢測,權限校驗,反爬蟲之類...
傳統方式下,最土的辦法,網關層可以人肉封裝,類似以下示例代碼:
LoginResult login(...){
//TODO 預處理...
return userService.login();//調用用戶服務的登錄方法
}
Product queryProduct(...){
//TODO 預處理...
return productService.queryProduct();//調用產品服務的查詢方法
}
Order submitOrder(...){
//TODO 預處理...
return orderService.submitOrder();//調用訂單服務的查詢方法
}
這樣做,當然能跑起來,但是維護量大,以后各個微服務增加了新方法,都需要在網關層手動增加相應的方法封裝,而spring cloud 中的zuul很好的解決了這一問題,示意圖如下:

Zuul做為網關層,自身也是一個微服務,跟其它服務Service-1,Service-2, ... Service-N一樣,都注冊在eureka server上,可以相互發現,zuul能感知到哪些服務在線,同時通過配置路由規則(后面會給出示例),可以將請求自動轉發到指定的后端微服務上,對於一些公用的預處理(比如:權限認證,token合法性校驗,灰度驗證時部分流量引導之類),可以放在所謂的過濾器(ZuulFilter)里處理,這樣后端服務以后新增了服務,zuul層幾乎不用修改。
使用步驟:
一、添加zuul依賴的jar包
compile 'org.springframework.cloud:spring-cloud-starter-zuul'
二、application.yml里配置路由
zuul:
routes:
api-a:
path: /api-user/**
service-id: service-provider
sensitive-headers:
api-b:
path: /api-order/**
service-id: service-consumer
解釋一下:上面這段配置表示,/api-user/開頭的url請求,將轉發到service-provider這個微服務上,/api-order/開頭的url請求,將轉發到service-consumer這個微服務上。
三、熔斷處理
如果網關后面的微服務掛了,zuul還允許定義一個fallback類,用於熔斷處理,參考下面的代碼:
package com.cnblogs.yjmyzz.spring.cloud.study.gateway;
import org.springframework.cloud.netflix.zuul.filters.route.ZuulFallbackProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
/**
* Created by yangjunming on 2017/7/14.
*/
@Component
public class ServiceConsumerFallbackProvider implements ZuulFallbackProvider {
@Override
public String getRoute() {
return "service-consumer";
}
@Override
public ClientHttpResponse fallbackResponse() {
return new ClientHttpResponse() {
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.OK;
}
@Override
public int getRawStatusCode() throws IOException {
return this.getStatusCode().value();
}
@Override
public String getStatusText() throws IOException {
return this.getStatusCode().getReasonPhrase();
}
@Override
public void close() {
}
@Override
public InputStream getBody() throws IOException {
return new ByteArrayInputStream("Service-Consumer不可用".getBytes());
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
MediaType mt = new MediaType("application", "json", Charset.forName("UTF-8"));
headers.setContentType(mt);
return headers;
}
};
}
}
開發人員只要在getRoute這個方法里指定要處理的微服務實例,然后重寫fallbackResponse即可。

此時,如果觀察/health端點,也可以看到hystrix處於融斷開啟狀態

四、ZuulFilter過濾器
過濾器是一個很有用的機制,下面分幾種經典場景演示下:
4.1、token校驗/安全認證
網關直接暴露在公網上時,終端要調用某個服務,通常會把登錄后的token傳過來,網關層對token進行有效性驗證,如果token無效(或沒傳token),提示重新登錄或直接拒絕。另外,網關后面的微服務,如果設置了spring security中的basic Auth(即:不允許匿名訪問,必須提供用戶名、密碼),也可以在Filter中處理。參考下面的代碼:
package com.cnblogs.yjmyzz.spring.cloud.study.gateway;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
/**
* Created by yangjunming on 2017/7/13.
*/
@Component
public class AccessFilter extends ZuulFilter {
private static Logger logger = LoggerFactory.getLogger(AccessFilter.class);
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
Object token = request.getParameter("token");
//校驗token
if (token == null) {
logger.info("token為空,禁止訪問!");
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
return null;
} else {
//TODO 根據token獲取相應的登錄信息,進行校驗(略)
}
//添加Basic Auth認證信息
ctx.addZuulRequestHeader("Authorization", "Basic " + getBase64Credentials("app01", "*****"));
return null;
}
private String getBase64Credentials(String username, String password) {
String plainCreds = username + ":" + password;
byte[] plainCredsBytes = plainCreds.getBytes();
byte[] base64CredsBytes = Base64.encodeBase64(plainCredsBytes);
return new String(base64CredsBytes);
}
}
Filter一共有4種類型,其常量值在org.springframework.cloud.netflix.zuul.filters.support.FilterConstants 中定義
// Zuul Filter TYPE constants -----------------------------------
/**
* {@link ZuulFilter#filterType()} error type.
*/
String ERROR_TYPE = "error";
/**
* {@link ZuulFilter#filterType()} post type.
*/
String POST_TYPE = "post";
/**
* {@link ZuulFilter#filterType()} pre type.
*/
String PRE_TYPE = "pre";
/**
* {@link ZuulFilter#filterType()} route type.
*/
String ROUTE_TYPE = "route";
安全校驗,一般放在請求真正處理之前,所以上面的示例filterType指定為pre,剩下的只要在shouldFilter()、run()方法中重寫自己的邏輯即可。
4.2 動態修改請求參數
zuulFilter可以攔截所有請求參數,並對其進行修改,比如:終端發過來的數據,出於安全要求,可能是經過加密處理的,需要在網關層進行參數解密,再傳遞到后面的服務;再比如:用戶傳過來的token值,需要轉換成userId/userName這些信息,再傳遞到背后的微服務。參考下面的run方法:
public Object run() {
try {
RequestContext context = getCurrentContext();
InputStream in = (InputStream) context.get("requestEntity");
if (in == null) {
in = context.getRequest().getInputStream();
}
String body = StreamUtils.copyToString(in, Charset.forName("UTF-8"));
body = "動態增加一段內容到body中: " + body;
byte[] bytes = body.getBytes("UTF-8");
context.setRequest(new HttpServletRequestWrapper(getCurrentContext().getRequest()) {
@Override
public ServletInputStream getInputStream() throws IOException {
return new ServletInputStreamWrapper(bytes);
}
@Override
public int getContentLength() {
return bytes.length;
}
@Override
public long getContentLengthLong() {
return bytes.length;
}
});
} catch (IOException e) {
rethrowRuntimeException(e);
}
return null;
}
更多filter的示例,可以參考官網:https://github.com/spring-cloud-samples/sample-zuul-filters
4.3 灰度發布(Gated Launch/Gray Release)
大型分布式系統中,灰度發布是保證線上系統安全生產的重要手段,一般的做法為:從集群中指定一台(或某幾台)機器,每次做新版本發布前,先只發布這些機器上,先觀察一下是否正常,如果穩定運行后,再發布到其它機器。這種策略(相當於按部分節點來灰度),大多數情況下可以滿足要求,但是有一些特定場景,可能不太適用。
比如:筆者所在的“美味不用等”公司,主要B端用戶為各餐飲品牌的商家,多數情況下,如果新上了一個功能,希望找一些規模較小的餐廳做試點,先看看上線后的運行情況,如果運行良好,再推廣到其它商家。
再比如:后端服務有N多個版本在同時運行,比如V1、V2,現在新加了一個V3版本(這在手機app應用中很常見),希望只有部分升級了app的用戶訪問最新的V3版本服務,其它用戶仍然訪問舊版本,待系統穩定后,再大規模提示用戶升級。
對於這些看上去需求各異的灰度需求,其實本質是一樣的:將請求(根據參數內容+業務規則),將其轉向到特定的灰度機器上。Spring Cloud MicroService中有一個metadata-map(元數據)設置,可以很好的滿足這類需求。
首先要引入一個jar包:(這是github上開源的一個項目ribbon-discovery-filter-spring-cloud-starter)
compile 'io.jmnarloch:ribbon-discovery-filter-spring-cloud-starter:2.1.0'
示例如下:
在各個服務的application.yml中設置以下metadata-map
eureka:
instance:
metadata-map:
gated-launch: false
即:所有節點發布后,默認灰度模式為false。然后把特定的灰度機器上的配置,該參數改成true(表明這台機器是用於灰度驗證的)。
然后在ZuulFilter中參考下面的代碼:
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
Object token = request.getParameter("token");
//校驗token
if (token == null) {
logger.info("token為空,禁止訪問!");
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(401);
return null;
} else {
//TODO 根據token獲取相應的登錄信息,進行校驗(略)
//灰度示例
RibbonFilterContextHolder.clearCurrentContext();
if (token.equals("1234567890")) {
RibbonFilterContextHolder.getCurrentContext().add("gated-launch", "true");
} else {
RibbonFilterContextHolder.getCurrentContext().add("gated-launch", "false");
}
}
//添加Basic Auth認證信息
ctx.addZuulRequestHeader("Authorization", "Basic " + getBase64Credentials("app01", "*****"));
return null;
}
注意18-23行,這里演示了通過特定的token參數值,將請求引導到gated-lanuch=true的機器上。(注:參考這個原理,大家可以把參數值,換成自己的version-版本號,shopId-商家Id之類)。只要請求參數中的token=1234567890,這次請求就會轉發到灰度節點上。
如果有朋友好奇這是怎么做到的,可以看下io.jmnarloch.spring.cloud.ribbon.predicate.MetadataAwarePredicate 這個類:
@Override
protected boolean apply(DiscoveryEnabledServer server) {
final RibbonFilterContext context = RibbonFilterContextHolder.getCurrentContext();
final Set<Map.Entry<String, String>> attributes = Collections.unmodifiableSet(context.getAttributes().entrySet());
final Map<String, String> metadata = server.getInstanceInfo().getMetadata();
return metadata.entrySet().containsAll(attributes);
}
大致原理就是拿上下文中,開發人員設置的屬性 與 服務節點里的metadata-map 進行比較,如果metadata-map中包括開發人員設置的屬性,就返回成功(即:選擇這台服務器)
