SpringCloud灰度發佈實踐(附源碼)

代碼GITjava

前言

​ 在平時的業務開發過程當中,後端服務與服務之間的調用每每經過fegin或者resttemplate兩種方式。可是咱們在調用服務的時候每每只須要寫服務名就能夠作到路由到具體的服務,這其中的原理相比你們都知道是SpringCloudribbon組件幫咱們作了負載均衡的功能。 git

灰度的核心就是路由,若是咱們可以重寫ribbon默認的負載均衡算法是否是就意味着咱們可以控制服務的轉發呢?是的!github

調用鏈分析

外部調用

  • 請求==>zuul==>服務
zuul在轉發請求的時候,也會根據 Ribbon從服務實例列表中選擇一個對應的服務,而後選擇轉發.

內部調用

  • 請求==>zuul==>服務Resttemplate調用==>服務
  • 請求==>zuul==>服務Fegin調用==>服務
不管是經過 Resttemplate仍是 Fegin的方式進行服務間的調用,他們都會從 Ribbon選擇一個服務實例返回.

上面幾種調用方式應該涵蓋了咱們平時調用中的場景,不管是經過哪一種方式調用(排除直接ip:port調用),最後都會經過Ribbon,而後返回服務實例.算法

預備知識

eureka元數據

Eureka的元數據有兩種,分別爲標準元數據和自定義元數據。spring

標準元數據:主機名、IP地址、端口號、狀態頁和健康檢查等信息,這些信息都會被髮布在服務註冊表中,用於服務之間的調用。

自定義元數據:自定義元數據可使用eureka.instance.metadata-map配置,這些元數據能夠在遠程客戶端中訪問,可是通常不會改變客戶端的行爲,除非客戶端知道該元數據的含義segmentfault

eureka RestFul接口

請求名稱 請求方式 HTTP地址 請求描述
註冊新服務 POST /eureka/apps/{appID} 傳遞JSON或者XML格式參數內容,HTTP code爲204時表示成功
取消註冊服務 DELETE /eureka/apps/{appID}/{instanceID} HTTP code爲200時表示成功
發送服務心跳 PUT /eureka/apps/{appID}/{instanceID} HTTP code爲200時表示成功
查詢全部服務 GET /eureka/apps HTTP code爲200時表示成功,返回XML/JSON數據內容
查詢指定appID的服務列表 GET /eureka/apps/{appID} HTTP code爲200時表示成功,返回XML/JSON數據內容
查詢指定appID&instanceID GET /eureka/apps/{appID}/{instanceID} 獲取指定appID以及InstanceId的服務信息,HTTP code爲200時表示成功,返回XML/JSON數據內容
查詢指定instanceID服務列表 GET /eureka/apps/instances/{instanceID} 獲取指定instanceID的服務列表,HTTP code爲200時表示成功,返回XML/JSON數據內容
變動服務狀態 PUT /eureka/apps/{appID}/{instanceID}/status?value=DOWN 服務上線、服務下線等狀態變更,HTTP code爲200時表示成功
變動元數據 PUT /eureka/apps/{appID}/{instanceID}/metadata?key=value HTTP code爲200時表示成功

更改自定義元數據

配置文件方式:後端

eureka.instance.metadata-map.version = v1

接口請求:緩存

PUT                  /eureka/apps/{appID}/{instanceID}/metadata?key=value

實現流程

灰度設計

原圖連接app

  1. 用戶請求首先到達Nginx而後轉發到網關zuul,此時zuul攔截器會根據用戶攜帶請求token解析出對應的userId
  2. 網關從Apollo配置中心拉取灰度用戶列表,而後根據灰度用戶策略判斷該用戶是不是灰度用戶。如是,則給該請求添加請求頭線程變量添加信息version=xxx;若不是,則不作任何處理放行
  3. zuul攔截器執行完畢後,zuul在進行轉發請求時會經過負載均衡器Ribbon。
  4. 負載均衡Ribbon被重寫。當請求到達時候,Ribbon會取出zuul存入線程變量version。於此同時,Ribbon還會取出全部緩存的服務列表(按期從eureka刷新獲取最新列表)及其該服務的metadata-map信息。而後取出服務metadata-mapversion信息與線程變量version進行判斷對比,若值一直則選擇該服務做爲返回。若全部服務列表的version信息與之不匹配,則返回null,此時Ribbon選取不到對應的服務則會報錯!
  5. 當服務爲非灰度服務,即沒有version信息時,此時Ribbon會收集全部非灰度服務列表,而後利用Ribbon默認的規則從這些非灰度服務列表中返回一個服務。負載均衡


  6. zuul經過Ribbon將請求轉發到consumer服務後,可能還會經過feginresttemplate調用其餘服務,如provider服務。可是不管是經過fegin仍是resttemplate,他們最後在選取服務轉發的時候都會經過Ribbon
  7. 那麼在經過feginresttemplate調用另一個服務的時候須要設置一個攔截器,將請求頭version=xxx給帶上,而後存入線程變量。
  8. 在通過feginresttemplate 的攔截器後最後會到Ribbon,Ribbon會從線程變量裏面取出version信息。而後重複步驟(4)和(5)

灰度流程

設計思路

首先,咱們經過更改服務在eureka的元數據標識該服務爲灰度服務,筆者這邊用的元數據字段爲version

 

1.首先更改服務元數據信息,標記其灰度版本。經過eureka RestFul接口或者配置文件添加以下信息eureka.instance.metadata-map.version=v1

2.自定義zuul攔截器GrayFilter。此處筆者獲取的請求頭爲token,而後將根據JWT的思想獲取userId,而後獲取灰度用戶列表及其灰度版本信息,判斷該用戶是否爲灰度用戶。

若爲灰度用戶,則將灰度版本信息version存放在線程變量裏面。此處不能用Threadlocal存儲線程變量,由於SpringCloud用hystrix作線程池隔離,而線程池是沒法獲取到ThreadLocal中的信息的! 因此這個時候咱們能夠參考Sleuth作分佈式鏈路追蹤的思路或者使用阿里開源的TransmittableThreadLocal方案。此處使用HystrixRequestVariableDefault實現跨線程池傳遞線程變量。

3.zuul攔截器處理完畢後,會通過ribbon組件從服務實例列表中獲取一個實例選擇轉發。Ribbon默認的Rule爲ZoneAvoidanceRule`。而此處咱們繼承該類,重寫了其父類選擇服務實例的方法。

如下爲Ribbon源碼:

public abstract class PredicateBasedRule extends ClientConfigEnabledRoundRobinRule {
   // 略....
    @Override
    public Server choose(Object key) {
        ILoadBalancer lb = getLoadBalancer();
        Optional<Server> server = getPredicate().chooseRoundRobinAfterFiltering(lb.getAllServers(), key);
        if (server.isPresent()) {
            return server.get();
        } else {
            return null;
        }       
    }
}

如下爲自定義實現的僞代碼:

public class GrayMetadataRule extends ZoneAvoidanceRule {
   // 略....
    @Override
    public Server choose(Object key) {
      //1.從線程變量獲取version信息
        String version = HystrixRequestVariableDefault.get();
        
      //2.獲取服務實例列表
        List<Server> serverList = this.getPredicate().getEligibleServers(this.getLoadBalancer().getAllServers(), key);
        
       //3.循環serverList,選擇version匹配的服務並返回
                for (Server server : serverList) {
            Map<String, String> metadata = ((DiscoveryEnabledServer) server).getInstanceInfo().getMetadata();

            String metaVersion = metadata.get("version);
            if (!StringUtils.isEmpty(metaVersion)) {
                if (metaVersion.equals(hystrixVer)) {
                    return server;
                }
            }
        }
    }
}

4.此時,只是已經完成了 請求==》zuul==》zuul攔截器==》自定義ribbon負載均衡算法==》灰度服務這個流程,並無涉及到 服務==》服務的調用。

服務到服務的調用不管是經過resttemplate仍是fegin,最後也會走ribbon的負載均衡算法,即服務==》Ribbon 自定義Rule==》服務。由於此時自定義的GrayMetadataRule並不能從線程變量中取到version,由於已經到了另一個服務裏面了。

5.此時依然能夠參考Sleuth的源碼org.springframework.cloud.sleuth.Span,這裏不作贅述只是大體講一下該類的實現思想。 就是在請求裏面添加請求頭,以便下個服務可以從請求頭中獲取信息。

此處,咱們能夠經過在 步驟2中,讓zuul添加添加線程變量的時候也在請求頭中添加信息。而後,再自定義 HandlerInterceptorAdapter攔截器,使之在到達服務以前將請求頭中的信息存入到線程變量HystrixRequestVariableDefault中。

而後服務再調用另一個服務以前,設置resttemplate和fegin的攔截器,添加頭信息。

resttemplate攔截器

public class CoreHttpRequestInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        HttpRequestWrapper requestWrapper = new HttpRequestWrapper(request);
        String hystrixVer = CoreHeaderInterceptor.version.get();
        requestWrapper.getHeaders().add(CoreHeaderInterceptor.HEADER_VERSION, hystrixVer);
        return execution.execute(requestWrapper, body);
    }
}

fegin攔截器

public class CoreFeignRequestInterceptor implements RequestInterceptor {
   @Override
   public void apply(RequestTemplate template) {
        String hystrixVer = CoreHeaderInterceptor.version.get();
        logger.debug("====>fegin version:{} ",hystrixVer); 
      template.header(CoreHeaderInterceptor.HEADER_VERSION, hystrixVer);
   }

}

6.到這裏基本上整個請求流程就比較完整了,可是咱們怎麼讓Ribbon使用自定義的Rule?這裏其實很是簡單,只須要在服務的配置文件中配置一下代碼便可.

yourServiceId.ribbon.NFLoadBalancerRuleClassName=自定義的負載均衡策略類

可是這樣配置須要指定服務名,意味着須要在每一個服務的配置文件中這麼配置一次,因此須要對此作一下擴展.打開源碼org.springframework.cloud.netflix.ribbon.RibbonClientConfiguration類,該類是Ribbon的默認配置類.能夠清楚的發現該類注入了一個PropertiesFactory類型的屬性,能夠看到PropertiesFactory類的構造方法

public PropertiesFactory() {
        classToProperty.put(ILoadBalancer.class, "NFLoadBalancerClassName");
        classToProperty.put(IPing.class, "NFLoadBalancerPingClassName");
        classToProperty.put(IRule.class, "NFLoadBalancerRuleClassName");
        classToProperty.put(ServerList.class, "NIWSServerListClassName");
        classToProperty.put(ServerListFilter.class, "NIWSServerListFilterClassName");
    }

因此,咱們能夠繼承該類從而實現咱們的擴展,這樣一來就不用配置具體的服務名了.至於Ribbon是如何工做的,這裏有一篇方誌明的文章(傳送門)能夠增強對Ribbon工做機制的理解

7.到這裏基本上整個請求流程就比較完整了,上述例子中是以用戶ID做爲灰度的維度,固然這裏能夠實現更多的灰度策略,好比IP等,基本上均可以基於此方式作擴展

灰度使用

配置文件示例

spring.application.name = provide-test
server.port = 7770
eureka.client.service-url.defaultZone = http://localhost:1111/eureka/

#啓動後直接將該元數據信息註冊到eureka
#eureka.instance.metadata-map.version = v1

測試案例

​ 分別啓動四個測試實例,有version表明灰度服務,無version則爲普通服務。當灰度服務測試沒問題的時候,經過PUT請求eureka接口將version信息去除,使其變成普通服務.

實例列表

  • [x] zuul-server
  • [x] provider-test
    port:7770 version:無
    port: 7771 version:v1
  • [x] consumer-test

    port:8880 version:無

    port: 8881 version:v1

修改服務信息

​ 服務在eureka的元數據信息可經過接口http://localhost:1111/eureka/apps訪問到。

服務信息實例:

訪問接口查看信息http://localhost:1111/eureka/apps/PROVIDE-TEST

服務info信息

注意事項

​ 經過此種方法更改server的元數據後,因爲ribbon會緩存實力列表,因此在測試改變服務信息時,ribbon並不會立馬從eureka拉去最新信息m,這個拉取信息的時間可自行配置。

同時,當服務重啓時服務會從新將配置文件的version信息註冊上去。

測試演示

zuul==>provider服務

用戶andy爲灰度用戶。
1.測試灰度用戶andy,是否路由到灰度服務 provider-test:7771
2.測試非灰度用戶andyaaa(任意用戶)是否能被路由到普通服務 provider-test:7770

zuul-服務

zuul==>consumer服務>provider服務

以一樣的方式再啓動兩個consumer-test服務,這裏再也不截圖演示。

請求從zuul==>consumer-test==>provider-test,經過feginresttemplate兩種請求方式測試

Resttemplate請求方式

zuul-服務-resttemplate服務

fegin請求方式

zuul-服務-fegin

自動化配置

與Apollo實現整合,避免手動調用接口。實現配置監聽,完成灰度。詳情見下篇文章

相關文章
相關標籤/搜索