微服務架構體系中,通常一個業務系統會有很多的微服務,比如: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中包括開發人員設置的屬性,就返回成功(即:選擇這臺服務器)
文章列表