SpringCloud之Gateway灰度发布讲解

1 灰度发布

1.1 简介

1.1.1 概念

灰度发布, 也叫金丝雀发布。是指在黑与白之间,能够平滑过渡的一种发布方式。AB test 就是一种灰度发布方式,让一部分用户继续用A,一部分用户开始用B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。
灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度,而我们平常所说的金丝雀部署也就是灰度发布的一种方式。

具体到服务器上,实际操作中还可以做更多控制,譬如说,给最初更新的10台服务器设置较低的权重、控制发送给这10台服务器的请求数,然后逐渐提高权重、增加请求数。一种平滑过渡的思路, 这个控制叫做流量切分

1.1.2 组件版本

使用组件说明:

  • spring-boot: 2.3.12.RELEASE
  • spring-cloud-dependencies: Hoxton.SR12
  • spring-cloud-alibaba-dependencies: 2.2.9.RELEASE

核心组件说明:

  • 注册中心: Nacos
  • 网关: SpringCloudGateway
  • 负载均衡器: Ribbon (使用SpringCloudLoadBalancer实现也是类似的)
  • 服务间RPC调用: OpenFeign

1.2 代码设计

1.2.1 设计图示

要实现Spring Cloud项目灰度发布技术方案有很多,重点在于服务发现,怎么将灰度流量只请求到灰度服务,这里会使用Nacos作为注册中心和配置中心,核心就是利用NacosMetadata设置一个version值,在调用下游服务是通过version值来区分要调用那个版本。

1.2.2 代码设计结构

这个是demo项目,结构都按最简单的来。

spring-cloud-gray-example // 父工程
   kerwin-common // 项目公共模块
   kerwin-gateway // 微服务网关
   kerwin-order // 订单模块
      order-app // 订单业务服务
   kerwin-starter // 自定义springboot starter模块
      spring-cloud-starter-kerwin-gray // 灰度发布starter包 (核心代码都在这里)
   kerwin-user // 用户模块
      user-app // 用户业务服务
      user-client // 用户client(Feign和DTO)

核心包spring-cloud-starter-kerwin-gray结构介绍

1.3 gateway和ribbon操作

1.3.1 存取请求灰度标记Holder

使用ThreadLocal记录每个请求线程的灰度标记,会在前置过滤器中将标记设置到ThreadLocal中。

注意ThreadLocal 的作用范围仅限于当前线程,且在当前 JVM 内有效。在微服务环境下,ThreadLocal 的数据无法跨服务传递,也无法跨线程传递

public class GrayFlagRequestHolder {
    /**
     * 标记是否使用灰度版本
     * 具体描述请查看 {@link com.kerwin.gray.enums.GrayStatusEnum}
     */
    private static final ThreadLocal<GrayStatusEnum> grayFlag = new ThreadLocal<>();
    public static void setGrayTag(final GrayStatusEnum tag) {
        grayFlag.set(tag);
    }
    public static GrayStatusEnum getGrayTag() {
        return grayFlag.get();
    }
    public static void remove() {
        grayFlag.remove();
    }
}

1.3.2 前置过滤器

在前置过滤器中会对请求是否要使用灰度版本进行判断,并且会将灰度状态枚举GrayStatusEnum设置到GrayRequestContextHolder中存储这一个请求的灰度状态枚举,在负载均衡器中会取出灰度状态枚举判断要调用那个版本的服务,同时这里还实现了 Ordered 接口会对网关的过滤器进行的排序,这里我们将这个过滤器的排序设置为Ordered.HIGHEST_PRECEDENCE int的最小值,保证这个过滤器最先执行

public class GrayGatewayBeginFilter implements GlobalFilter, Ordered {
    @Autowired
    private GrayGatewayProperties grayGatewayProperties;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        GrayStatusEnum grayStatusEnum = GrayStatusEnum.ALL;
        // 当灰度开关打开时才进行请求头判断
        if (grayGatewayProperties.getEnabled()) {
            grayStatusEnum = GrayStatusEnum.PROD;
            // 判断是否需要调用灰度版本
            if (checkGray(exchange.getRequest())) {
                grayStatusEnum = GrayStatusEnum.GRAY;
            }
        }
        GrayFlagRequestHolder.setGrayTag(grayStatusEnum);
        ServerHttpRequest newRequest = exchange.getRequest().mutate()
                .header(GrayConstant.GRAY_HEADER, grayStatusEnum.getVal())
                .build();
        ServerWebExchange newExchange = exchange.mutate()
                .request(newRequest)
                .build();
        return chain.filter(newExchange);
    }

    /**
     * 校验是否使用灰度版本
     */
    private boolean checkGray(ServerHttpRequest request) {
        if (checkGrayHeadKey(request) || checkGrayIPList(request) || checkGrayCiryList(request) || checkGrayUserNoList(request)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本请求头判断是否需要调用灰度版本
     */
    private boolean checkGrayHeadKey(ServerHttpRequest request) {
        HttpHeaders headers = request.getHeaders();
        if (headers.containsKey(grayGatewayProperties.getGrayHeadKey())) {
            List<String> grayValues = headers.get(grayGatewayProperties.getGrayHeadKey());
            if (!Objects.isNull(grayValues)
                    && grayValues.size() > 0
                    && grayGatewayProperties.getGrayHeadValue().equals(grayValues.get(0))) {
                return true;
            }
        }
        return false;
    }

    /**
     * 校验自定义灰度版本IP数组判断是否需要调用灰度版本
     */
    private boolean checkGrayIPList(ServerHttpRequest request) {
        List<String> grayIPList = grayGatewayProperties.getGrayIPList();
        if (CollectionUtils.isEmpty(grayIPList)) {
            return false;
        }
        String realIP = request.getHeaders().getFirst("X-Real-IP");
        if (realIP == null || realIP.isEmpty()) {
            realIP = request.getRemoteAddress().getAddress().getHostAddress();
        }
        if (realIP != null && CollectionUtils.contains(grayIPList.iterator(), realIP)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本城市数组判断是否需要调用灰度版本
     */
    private boolean checkGrayCiryList(ServerHttpRequest request) {
        List<String> grayCityList = grayGatewayProperties.getGrayCityList();
        if (CollectionUtils.isEmpty(grayCityList)) {
            return false;
        }
        String realIP = request.getHeaders().getFirst("X-Real-IP");
        if (realIP == null || realIP.isEmpty()) {
            realIP = request.getRemoteAddress().getAddress().getHostAddress();
        }
        // 通过IP获取当前城市名称
        // 想要实现的可以使用ip2region.xdb,这里写死cityName = "本地"
        String cityName = "本地";
        if (cityName != null && CollectionUtils.contains(grayCityList.iterator(), cityName)) {
            return true;
        }
        return false;
    }

    /**
     * 校验自定义灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
     */
    private boolean checkGrayUserNoList(ServerHttpRequest request) {
        List<String> grayUserNoList = grayGatewayProperties.getGrayUserNoList();
        if (CollectionUtils.isEmpty(grayUserNoList)) {
            return false;
        }
        return false;
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

1.3.3 后置过滤器

后置过滤器是为了在调用完下游业务服务后在响应之前将 GrayFlagRequestHolder 中的 ThreadLocal 清除避免造成内存泄漏。

public class GrayGatewayAfterFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 请求执行完必须要remore当前线程的ThreadLocal
        GrayFlagRequestHolder.remove();
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.LOWEST_PRECEDENCE;
    }
}

1.3.4 全局异常处理器

全局异常处理器是为了处理异常情况下将 GrayFlagRequestHolder 中的 ThreadLocal 清除避免造成内存泄漏,如果在调用下游业务服务时出现了异常就无法进入后置过滤器。

public class GrayGatewayExceptionHandler implements WebExceptionHandler, Ordered {
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        // 请求执行完必须要remore当前线程的ThreadLocal
        GrayFlagRequestHolder.remove();
        ServerHttpResponse response = exchange.getResponse();
        if (ex instanceof ResponseStatusException) {
            // 处理 ResponseStatusException 异常
            ResponseStatusException responseStatusException = (ResponseStatusException) ex;
            response.setStatusCode(responseStatusException.getStatus());
            // 可以根据需要设置响应头等
            return response.setComplete();
        } else {
            // 处理其他异常
            response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
            // 可以根据需要设置响应头等
            return response.setComplete();
        }
    }

    @Override
    public int getOrder() {
        // 设置过滤器的执行顺序,值越小越先执行
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

1.3.5 自定义Ribbon负载均衡路由

灰度Ribbon负载均衡路由抽象类:这里提供了两个获取服务列表的方法,会对 GrayFlagRequestHolder 中存储的当前线程灰度状态枚举进行判断,如果枚举值为 GrayStatusEnum.ALL 则响应全部服务列表不区分版本,如果枚举值为GrayStatusEnum.PROD 则返回生产版本的服务列表,如果枚举值为 GrayStatusEnum.GRAY 则返回灰度版本的服务列表,版本号会在GrayVersionProperties 中配置,通过服务列表中在Nacosmetadata中设置的version和GrayVersionProperties的版本号进行匹配出对应版本的服务列表。

import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.Server;
import com.netflix.loadbalancer.ILoadBalancer;

public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule {
    @Autowired
    private GrayVersionProperties grayVersionProperties;

    @Value("${spring.cloud.nacos.discovery.metadata.version}")
    private String metaVersion;

    /**
     * 只有已启动且可访问的服务器,并对灰度标识进行判断
     */
    public List<Server> getReachableServers() {
        ILoadBalancer lb = getLoadBalancer();
        if (lb == null) {
            return new ArrayList<>();
        }
        List<Server> reachableServers = lb.getReachableServers();

        return getGrayServers(reachableServers);
    }

    /**
     * 所有已知的服务器,可访问和不可访问,并对灰度标识进行判断
     */
    public List<Server> getAllServers() {
        ILoadBalancer lb = getLoadBalancer();
        if (lb == null) {
            return new ArrayList<>();
        }
        List<Server> allServers = lb.getAllServers();
        return getGrayServers(allServers);
    }

    /**
     * 获取灰度版本服务列表
     */
    protected List<Server> getGrayServers(List<Server> servers) {
        List<Server> result = new ArrayList<>();
        if (servers == null) {
            return result;
        }
        String currentVersion = metaVersion;
        GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
        if (grayStatusEnum != null) {
            switch (grayStatusEnum) {
                case ALL:
                    return servers;
                case PROD:
                    currentVersion = grayVersionProperties.getProdVersion();
                    break;
                case GRAY:
                    currentVersion = grayVersionProperties.getGrayVersion();
                    break;
            }
        }

        for (Server server : servers) {
            NacosServer nacosServer = (NacosServer) server;
            Map<String, String> metadata = nacosServer.getMetadata();
            String version = metadata.get("version");
            // 判断服务metadata下的version是否于设置的请求版本一致
            if (version != null && version.equals(currentVersion)) {
                result.add(server);
            }
        }
        return result;
    }
}

自定义轮询算法实现GrayRoundRobinRule: 这里是直接拷贝了Ribbon的轮询算法,将里面获取服务列表的方法换成了自定义AbstractGrayLoadBalancerRule 中的方法,其它算法也可以通过类似的方式实现。

·

1.4 业务服务操作

1.4.1 自定义SpringMVC请求拦截器

自定义SpringMVC请求拦截器获取上游服务的灰度请求头,如果获取到则设置到 GrayFlagRequestHolder 中,之后如果有后续的 RPC 调用同样的将灰度标记传递下去。

@SuppressWarnings("all")
public class GrayMvcHandlerInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String grayTag = request.getHeader(GrayConstant.GRAY_HEADER);
        // 如果HttpHeader中灰度标记存在,则将灰度标记放到holder中,如果需要就传递下去
        if (grayTag!= null) {
            GrayFlagRequestHolder.setGrayTag(GrayStatusEnum.getByVal(grayTag));
        }
        return true;
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        GrayFlagRequestHolder.remove();
    }
}

1.4.2 自定义OpenFeign请求拦截器

自定义OpenFeign请求拦截器,取出自定义SpringMVC请求拦截器中设置到GrayFlagRequestHolder中的灰度标识,并且放到调用下游服务的请求头中,将灰度标记传递下去。

public class GrayFeignRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        // 如果灰度标记存在,将灰度标记通过HttpHeader传递下去
        GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
        if (grayStatusEnum != null ) {
            template.header(GrayConstant.GRAY_HEADER, Collections.singleton(grayStatusEnum.getVal()));
        }
    }
}

1.5 配置信息

1.5.1 配置类

这里会定义一些基础参数,比如是否开启灰度还有什么请求需要使用灰度版本等,为后续业务做准备。
调用业务服务时设置的灰度统一请求头

public interface GrayConstant {
    /**
     * 灰度统一请求头
     */
    String GRAY_HEADER="gray";
}

灰度版本状态枚举

public enum GrayStatusEnum {
    ALL("ALL","可以调用全部版本的服务"),
    PROD("PROD","只能调用生产版本的服务"),
    GRAY("GRAY","只能调用灰度版本的服务");
    GrayStatusEnum(String val, String desc) {
        this.val = val;
        this.desc = desc;
    }
    private String val;
    private String desc;
    public String getVal() {
        return val;
    }
    public static GrayStatusEnum getByVal(String val){
        if(val == null){
            return null;
        }
        for (GrayStatusEnum value : values()) {
            if(value.val.equals(val)){
                return value;
            }
        }
        return null;
    }
}

网关灰度配置信息类

@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.gateway")
public class GrayGatewayProperties {

    /**
     * 灰度开关(如果开启灰度开关则进行灰度逻辑处理,如果关闭则走正常处理逻辑)
     * PS:一般在灰度发布测试完成以后会将线上版本都切换成灰度版本完成全部升级,这时候应该关闭灰度逻辑判断
     */
    private Boolean enabled = false;

    /**
     * 自定义灰度版本请求头 (通过grayHeadValue来匹配请求头中的值如果一致就去调用灰度版本,用于公司测试)
     */
    private String grayHeadKey="gray";

    /**
     * 自定义灰度版本请求头匹配值
     */
    private String grayHeadValue="gray-996";

    /**
     * 使用灰度版本IP数组
     */
    private List<String> grayIPList = new ArrayList<>();

    /**
     * 使用灰度版本城市数组
     */
    private List<String> grayCityList = new ArrayList<>();

    /**
     * 使用灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
     */
    private List<String> grayUserNoList = new ArrayList<>();
}

全局版本配置信息类

@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.version")
public class GrayVersionProperties {
    /**
     * 当前线上版本号
     */
    private String prodVersion;

    /**
     * 灰度版本号
     */
    private String grayVersion;
}

全局自动配置类

@Configuration
// 可以通过@ConditionalOnProperty设置是否开启灰度自动配置 默认是不加载的
@ConditionalOnProperty(value = "kerwin.tool.gray.load",havingValue = "true")
@EnableConfigurationProperties(GrayVersionProperties.class)
public class GrayAutoConfiguration {
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(value = GlobalFilter.class)
    @EnableConfigurationProperties(GrayGatewayProperties.class)
    static class GrayGatewayFilterAutoConfiguration {
        @Bean
        public GrayGatewayBeginFilter grayGatewayBeginFilter() {
            return new GrayGatewayBeginFilter();
        }
        @Bean
        public GrayGatewayAfterFilter grayGatewayAfterFilter() {
            return new GrayGatewayAfterFilter();
        }
        @Bean
        public GrayGatewayExceptionHandler grayGatewayExceptionHandler(){
            return new GrayGatewayExceptionHandler();
        }
    }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(value = WebMvcConfigurer.class)
    static class GrayWebMvcAutoConfiguration {
        /**
         * Spring MVC 请求拦截器
         * @return WebMvcConfigurer
         */
        @Bean
        public WebMvcConfigurer webMvcConfigurer() {
            return new WebMvcConfigurer() {
                @Override
                public void addInterceptors(InterceptorRegistry registry) {
                    registry.addInterceptor(new GrayMvcHandlerInterceptor());
                }
            };
        }
    }
    @Configuration
    @ConditionalOnClass(value = RequestInterceptor.class)
    static class GrayFeignInterceptorAutoConfiguration {
        /**
         * Feign拦截器
         * @return GrayFeignRequestInterceptor
         */
        @Bean
        public GrayFeignRequestInterceptor grayFeignRequestInterceptor() {
            return new GrayFeignRequestInterceptor();
        }
    }
}

1.5.2 配置文件

配置Nacos全局配置文件(common-config.yaml)

kerwin:
  tool:
    gray:
      # 配置是否加载灰度自动配置类,如果不配置那么默认不加载
      load: true
      # 配置生产版本和灰度版本号
      version:
        prodVersion: V1
        grayVersion: V2

# 配置Ribbon调用user-app和order-app服务时使用我们自定义灰度轮询算法
user-app:
  ribbon:
    NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
  ribbon:
    NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule

配置网关Nacos配置文件(gateway-app.yaml)

kerwin:
  tool:
    gray:
      gateway:
        # 是否开启灰度发布功能
        enabled: true
        # 自定义灰度版本请求头
        grayHeadKey: gray
        # 自定义灰度版本请求头匹配值
        grayHeadValue: gray-996
        # 使用灰度版本IP数组
        grayIPList:
          - '127.0.0.1'
        # 使用灰度版本城市数组
        grayCityList:
          - 本地
posted @ 2025-03-11 11:49  上善若泪  阅读(634)  评论(0)    收藏  举报