1、背景(灰度部署)

在咱们零碎公布生产环境时,有时为了确保新的服务逻辑没有问题,会让一小部分特定的用户来应用新的版本(比方客户端的内测版本),而其余的用户应用旧的版本,那么这个在Spring Cloud中该如何来实现呢?

负载平衡组件应用:Spring Cloud LoadBalancer

2、需要

3、实现思路


通过翻阅Spring Cloud的官网文档,咱们晓得,大略能够通过2种形式来达到咱们的目标。

  1. 实现 ReactiveLoadBalancer接口,重写负载平衡算法。
  2. 实现ServiceInstanceListSupplier接口,重写get办法,返回自定义的服务列表

ServiceInstanceListSupplier: 能够实现如下性能,比方咱们的 user-service在注册核心上存在5个,此处我能够只返回3个。

4、Spring Cloud中是否有我上方相似需要的例子

查阅Spring Cloud官网文档,发现org.springframework.cloud.loadbalancer.core.HintBasedServiceInstanceListSupplier 类能够实现相似的性能。

那可能有人会说,既然Spring Cloud曾经提供了这个性能,为什么你还要重写一个? 此处只是为了一个记录,因为工作中的需要可能各种各样,万一前期有相似的需要,此处记录了,前期晓得怎么实现。

5、外围代码实现

5.1 灰度外围代码

5.1.1 灰度服务实例选择器实现

package com.huan.loadbalancer;import lombok.extern.slf4j.Slf4j;import org.springframework.cloud.client.ServiceInstance;import org.springframework.cloud.client.loadbalancer.Request;import org.springframework.cloud.client.loadbalancer.RequestDataContext;import org.springframework.cloud.loadbalancer.core.DelegatingServiceInstanceListSupplier;import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;import org.springframework.http.HttpHeaders;import reactor.core.publisher.Flux;import java.util.List;import java.util.Objects;import java.util.stream.Collectors;/** * 自定义 依据服务名 获取服务实例 列表 * <p> * 需要: 用户通过申请拜访 网关<br /> * 1、如果申请头中的 version 值和 上游服务元数据的 version 值统一,则抉择该 服务。<br /> * 2、如果申请头中的 version 值和 上游服务元数据的 version 值不统一,且 不存在 version 的值 为 default 则间接报错。<br /> * 3、如果申请头中的 version 值和 上游服务元数据的 version 值不统一,且 存在 version 的值 为 default,则抉择该服务。<br /> * <p> * 参考: {@link org.springframework.cloud.loadbalancer.core.HintBasedServiceInstanceListSupplier} 实现 * * @author huan.fu * @date 2023/6/19 - 21:14 */@Slf4jpublic class VersionServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier {    /**     * 申请头的名字, 通过这个 version 字段和 服务中的元数据来version字段进行比拟,     * 失去最终的实例数据     */    private static final String VERSION_HEADER_NAME = "version";    public VersionServiceInstanceListSupplier(ServiceInstanceListSupplier delegate) {        super(delegate);    }    @Override    public Flux<List<ServiceInstance>> get() {        return delegate.get();    }    @Override    public Flux<List<ServiceInstance>> get(Request request) {        return delegate.get(request).map(instances -> filteredByVersion(instances, getVersion(request.getContext())));    }    private String getVersion(Object requestContext) {        if (requestContext == null) {            return null;        }        String version = null;        if (requestContext instanceof RequestDataContext) {            version = getVersionFromHeader((RequestDataContext) requestContext);        }        log.info("获取到须要申请服务[{}]的version:[{}]", getServiceId(), version);        return version;    }    /**     * 从申请中获取version     */    private String getVersionFromHeader(RequestDataContext context) {        if (context.getClientRequest() != null) {            HttpHeaders headers = context.getClientRequest().getHeaders();            if (headers != null) {                return headers.getFirst(VERSION_HEADER_NAME);            }        }        return null;    }    private List<ServiceInstance> filteredByVersion(List<ServiceInstance> instances, String version) {        // 1、获取 申请头中的 version 和 ServiceInstance 中 元数据中 version 统一的服务        List<ServiceInstance> selectServiceInstances = instances.stream()                .filter(instance -> instance.getMetadata().get(VERSION_HEADER_NAME) != null                        && Objects.equals(version, instance.getMetadata().get(VERSION_HEADER_NAME)))                .collect(Collectors.toList());        if (!selectServiceInstances.isEmpty()) {            log.info("返回申请服务:[{}]为version:[{}]的有:[{}]个", getServiceId(), version, selectServiceInstances.size());            return selectServiceInstances;        }        // 2、返回 version=default 的实例        selectServiceInstances = instances.stream()                .filter(instance -> Objects.equals(instance.getMetadata().get(VERSION_HEADER_NAME), "default"))                .collect(Collectors.toList());        log.info("返回申请服务:[{}]为version:[{}]的有:[{}]个", getServiceId(), "default", selectServiceInstances.size());        return selectServiceInstances;    }}

5.1.2 灰度feign申请头传递拦截器

package com.huan.loadbalancer;import feign.RequestInterceptor;import feign.RequestTemplate;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Component;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;/** * 将version申请头通过feign传递到上游 * * @author huan.fu * @date 2023/6/20 - 08:27 */@Component@Slf4jpublic class VersionRequestInterceptor implements RequestInterceptor {    @Override    public void apply(RequestTemplate requestTemplate) {        String version = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest()                .getHeader("version");        log.info("feign 中传递的 version 申请头的值为:[{}]", version);        requestTemplate                .header("version", version);    }}

留神: 此处全局配置了,配置了一个feign的全局拦截器,进行申请头version的传递。

5.1.3 灰度服务实例选择器配置

package com.huan.loadbalancer;import lombok.extern.slf4j.Slf4j;import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;import org.springframework.cloud.client.discovery.DiscoveryClient;import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient;import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;import org.springframework.context.ConfigurableApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;/** * 此处抉择全局配置 * * @author huan.fu * @date 2023/6/19 - 22:16 */@Configuration@Slf4j@LoadBalancerClients(defaultConfiguration = VersionServiceInstanceListSupplierConfiguration.class)public class VersionServiceInstanceListSupplierConfiguration {    @Bean    @ConditionalOnClass(name = "org.springframework.web.servlet.DispatcherServlet")    public VersionServiceInstanceListSupplier versionServiceInstanceListSupplierV1(            ConfigurableApplicationContext context) {        log.error("===========> versionServiceInstanceListSupplierV1");        ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder()                .withBlockingDiscoveryClient()                .withCaching()                .build(context);        return new VersionServiceInstanceListSupplier(delegate);    }    @Bean    @ConditionalOnClass(name = "org.springframework.web.reactive.DispatcherHandler")    public VersionServiceInstanceListSupplier versionServiceInstanceListSupplierV2(            ConfigurableApplicationContext context) {        log.error("===========> versionServiceInstanceListSupplierV2");        ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder()                .withDiscoveryClient()                .withCaching()                .build(context);        return new VersionServiceInstanceListSupplier(delegate);    }}

此处偷懒全局配置了
`
@Configuration
@Slf4j
@LoadBalancerClients(defaultConfiguration = VersionServiceInstanceListSupplierConfiguration.class)
`

5.2 网关外围代码

5.2.1 网关配置文件

spring:  application:    name: lobalancer-gateway-8001  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        group: DEFAULT_GROUP      config:        server-addr: localhost:8848    gateway:      discovery:        locator:          enabled: trueserver:  port: 8001logging:  level:    root: info

5.3 服务提供者外围代码

5.3.1 向外提供一个办法

package com.huan.loadbalancer.controller;import com.alibaba.cloud.nacos.NacosDiscoveryProperties;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import javax.annotation.Resource;/** * 提供者控制器 * * @author huan.fu * @date 2023/3/6 - 21:58 */@RestControllerpublic class ProviderController {    @Resource    private NacosDiscoveryProperties nacosDiscoveryProperties;    /**     * 获取服务信息     *     * @return ip:port     */    @GetMapping("serverInfo")    public String serverInfo() {        return nacosDiscoveryProperties.getIp() + ":" + nacosDiscoveryProperties.getPort();    }}

5.3.2 提供者端口8005配置信息

spring:  application:    name: provider  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        # 配置元数据        metadata:          version: v1      config:        server-addr: localhost:8848server:  port: 8005

留神 metadata中version的值

5.3.2 提供者端口8006配置信息

spring:  application:    name: provider  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        # 配置元数据        metadata:          version: v1      config:        server-addr: localhost:8848server:  port: 8006

留神 metadata中version的值

5.3.3 提供者端口8007配置信息

spring:  application:    name: provider  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        # 配置元数据        metadata:          version: default      config:        server-addr: localhost:8848server:  port: 8007

留神 metadata中version的值

5.4 服务消费者代码

5.4.1 通过 feign 调用提供者办法

/** * @author huan.fu * @date 2023/6/19 - 22:21 */@FeignClient(value = "provider")public interface FeignProvider {    /**     * 获取服务信息     *     * @return ip:port     */    @GetMapping("serverInfo")    String fetchServerInfo();}

5.4.2 向外提供一个办法

package com.huan.loadbalancer.controller;import com.alibaba.cloud.nacos.NacosDiscoveryProperties;import com.huan.loadbalancer.feign.FeignProvider;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;import javax.annotation.Resource;import java.util.HashMap;import java.util.Map;/** * 消费者控制器 * * @author huan.fu * @date 2023/6/19 - 22:21 */@RestControllerpublic class ConsumerController {    @Resource    private FeignProvider feignProvider;    @Resource    private NacosDiscoveryProperties nacosDiscoveryProperties;    @GetMapping("fetchProviderServerInfo")    public Map<String, String> fetchProviderServerInfo() {        Map<String, String> ret = new HashMap<>(4);        ret.put("consumer信息", nacosDiscoveryProperties.getIp() + ":" + nacosDiscoveryProperties.getPort());        ret.put("provider信息", feignProvider.fetchServerInfo());        return ret;    }}

消费者端口 8002 配置信息

spring:  application:    name: consumer  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        register-enabled: true        service: nacos-feign-consumer        group: DEFAULT_GROUP        metadata:          version: v1      config:        server-addr: localhost:8848server:  port: 8002

留神 metadata中version的值

消费者端口 8003 配置信息

spring:  application:    name: consumer  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        register-enabled: true        service: nacos-feign-consumer        group: DEFAULT_GROUP        metadata:          version: v2      config:        server-addr: localhost:8848server:  port: 8003

留神 metadata中version的值

消费者端口 8004 配置信息

spring:  application:    name: consumer  cloud:    nacos:      discovery:        # 配置 nacos 的服务地址        server-addr: localhost:8848        register-enabled: true        service: nacos-feign-consumer        group: DEFAULT_GROUP        metadata:          version: default      config:        server-addr: localhost:8848server:  port: 8003

留神 metadata中version的值

6、测试

6.1 申请头中携带 version=v1

从上图中能够看到,当version=v1时,服务消费者为consumer-8002, 提供者为provider-8005provider-8006

➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo' \--header 'version: v1'{"consumer信息":"192.168.8.168:8002","provider信息":"192.168.8.168:8005"}%➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo' \--header 'version: v1'{"consumer信息":"192.168.8.168:8002","provider信息":"192.168.8.168:8006"}%➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo' \--header 'version: v1'{"consumer信息":"192.168.8.168:8002","provider信息":"192.168.8.168:8005"}%➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo' \--header 'version: v1'{"consumer信息":"192.168.8.168:8002","provider信息":"192.168.8.168:8006"}%➜  ~

能够看到,消费者返回的端口是8002,提供者返回的端口是8005|8006是合乎预期的。

6.2 不传递version

从上图中能够看到,当不携带时,服务消费者为consumer-8004, 提供者为provider-8007

➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo'{"consumer信息":"192.168.8.168:8004","provider信息":"192.168.8.168:8007"}%➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo'{"consumer信息":"192.168.8.168:8004","provider信息":"192.168.8.168:8007"}%➜  ~ curl --location --request GET 'http://localhost:8001/nacos-feign-consumer/fetchProviderServerInfo'{"consumer信息":"192.168.8.168:8004","provider信息":"192.168.8.168:8007"}%➜  ~

能够看到,消费者返回的端口是8004,提供者返回的端口是8007是合乎预期的。

7、残缺代码

https://gitee.com/huan1993/spring-cloud-alibaba-parent/tree/master/loadbalancer-supply-service-instance

8、参考文档

1、https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer