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实现整合,避免手动调用接口。实现配置监听,完成灰度。详情见下篇文章

相关文章
相关标签/搜索