关于SpringCloud中灰度路由的使用
在微服务中, 通常为了高可用, 同一个服务往往采用集群方式部署, 即同时存在几个相同的服务,而灰度的核心就 是路由, 通过我们特定的策略去调用目标服务线路
1 灰度路由的简介
灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度.
关于SpringCloud微服务+nacos的灰度发布实现, 首先微服务中之间的调用通常使用Feign方式和Resttemplate方式(较少使用),因此 , 我们需要指定服务之间的调用, 首先要给各个服务添加唯一标识, 我们可是使用一些特殊的标记, 如版本号version等, 其次,要干预微服务中Ribbon的默认轮询调用机制, 我们需要根据微服务的版本等不同, 来进行调用, 最后, 在服务之间, 需要传递调用链路的信息, 我们可以在请求头中,添加调用链路的信息.
整理思路为:
1 在请求头中添加调用链路信息
2 微服务之间调用时,使用feign拦截器,增强请求头
3 微服务调用选择时,根据指定的策略(如唯一标识版本等)从nacos中获取指定的服务,调用
2 灰度路由的使用
案列
基础服务
一个父服务,一个工具服务
父服务
pom依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<!--spring cloud 版本-->
<spring-cloud.version>Greenwich.RELEASE</spring-cloud.version>
</properties>
<dependencies>
<!--nacos-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>0.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-client</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--feign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
</dependencies>
工具服务
feign拦截器
@Slf4j
public class FeignInterceptor implements RequestInterceptor {
/**
* feign接口拦截, 添加上灰度路由请求头
* @param template
*/
@Override
public void apply(RequestTemplate template) {
String header = null;
try {
header = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest().getHeader("gray-route");
if (null == header || header.isEmpty()) {
return;
}
} catch (Exception e) {
log.info("请求头获取失败, 错误信息为: {}", e.getMessage());
}
template.header("gray-route", header);
}
}
灰度路由属性类
@ConfigurationProperties(prefix = "spring.cloud.nacos.discovery.metadata.gray-route", ignoreUnknownFields = false)
@Data
@RefreshScope
public class GrayRouteProp {
/**
* 逗号
*/
public final static String COMMA_SEP = ".";
/**
* 灰度路由
*/
public final static String GRAY_ROUTE = "gray-route";
/**
* 版本
*/
public final static String VERSION = "version";
/**
* 全链路版本
*/
public final static String ALL = "all";
/**
* 用户自定义版本
*/
public final static String CUSTOM = "custom";
/**
* 版本key, 可用于Redis等中存储
*/
public final static String VERSION_KEY = GRAY_ROUTE + COMMA_SEP + VERSION;
/**
* 是否开启灰度路由
*/
private boolean enable = false;
/**
* 本服务的版本
*/
private String version;
/**
* 本服务到下一跳服务的版本路由规则
*/
private RouteProp route;
}
路由属性类
@Data
@ConfigurationProperties(prefix = "spring.cloud.nacos.discovery.metadata.gray-route.route", ignoreUnknownFields = false)
@RefreshScope
public class RouteProp {
/**
* 本服务直接调用的所有服务的统一版本号
*/
private String all;
/**
* 指定调用服务的版本 serviceA:v1 表示在调用时只会调用v1版本服务
*/
private Map<String,String> custom;
}
灰度路由规则类(继承ZoneAvoidanceRule类)
微服务在拦截处理后, Ribbon组件会从服务实例列表中获取一个实现进行转发, 且Ribbon默认的规则是ZoneAvoidanceRule类, 我们定义自己的规则, 只需要继承该类,重写choose方法即可.
@Slf4j
public class GrayRouteRule extends ZoneAvoidanceRule {
@Autowired
protected GrayRouteProp grayRouteProperties;
/**
* 参考 {@link PredicateBasedRule#choose(Object)}
*
*/
@Override
public Server choose(Object key) {
// 根据灰度路由规则,过滤出符合规则的服务 this.getServers()
// 再根据负载均衡策略,过滤掉不可用和性能差的服务,然后在剩下的服务中进行轮询 getPredicate().chooseRoundRobinAfterFiltering()
Optional<Server> server = getPredicate()
.chooseRoundRobinAfterFiltering(this.getServers(), key);
return server.isPresent() ? server.get() : null;
}
/**
* 灰度路由过滤服务实例
*
* 如果设置了期望版本, 则过滤出所有的期望版本 ,然后再走默认的轮询 如果没有一个期望的版本实例,则不过滤,降级为原有的规则,进行所有的服务轮询。(灰度路由失效) 如果没有设置期望版本
* 则不走灰度路由,按原有轮询机制轮询所有
*/
protected List<Server> getServers() {
// 获取spring cloud默认负载均衡器
ZoneAwareLoadBalancer lb = (ZoneAwareLoadBalancer) getLoadBalancer();
// 获取本次请求生效的灰度路由规则
RouteProp routeRule = this.getGrayRoute();
// 获取本次请求期望的服务版本号
String version = getDesiredVersion(routeRule, lb.getName());
// 获取所有待选的服务
List<Server> allServers = lb.getAllServers();
if (CollectionUtils.isEmpty(allServers)) {
return new ArrayList<>();
}
// 如果没有设置要访问的版本,则不过滤,返回所有,走原有默认的轮询机制
if (StringUtils.isEmpty(version)) {
return allServers;
}
// 开始灰度规则匹配过滤
List<Server> filterServer = new ArrayList<>();
for (Server server : allServers) {
// 获取服务实例在注册中心上的元数据
Map<String, String> metadata = ((NacosServer) server).getMetadata();
// 如果注册中心上服务的版本标签和期望访问的版本一致,则灰度路由匹配成功
if (null != metadata && version.equals(metadata.get(GrayRouteProp.VERSION_KEY))) {
filterServer.add(server);
}
}
// 如果没有匹配到期望的版本实例服务,为了保证服务可用性,让灰度规则失效,走原有的轮询所有可用服务的机制
if (CollectionUtils.isEmpty(filterServer)) {
log.warn(String.format("没有找到版本version[%s]的服务[%s],灰度路由规则降级为原有的轮询机制!", version,
lb.getName()));
filterServer = allServers;
}
return filterServer;
}
/**
* 获取本次请求 期望的服务版本号
*
* @param routeRule 生效的配置规则
* @param appName 服务名
*/
protected String getDesiredVersion(RouteProp routeRule, String appName) {
// 取路由规则里指定要访问的微服务的版本号
String version = null;
if (routeRule != null) {
if (routeRule.getCustom() != null) {
// 优先取custom里指定版本
version = routeRule.getCustom().get(appName);
} else {
// custom里没有指定就找all里面设置的统一版本
version = routeRule.getAll();
}
}
return version;
}
/**
* 获取设置的灰度路由规则
*/
protected RouteProp getGrayRoute() {
// 确定路由规则(请求头优先,yml配置其次)
RouteProp routeRule;
String route_header = null;
try {
route_header = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
.getRequest().getHeader(GrayRouteProp.GRAY_ROUTE);
} catch (Exception e) {
log.error("灰度路由从上下文获取路由请求头异常!");
}
if (!StringUtils.isEmpty(route_header)) {
//header
routeRule = JSONObject.parseObject(route_header, RouteProp.class);
} else {
// yml配置
routeRule = grayRouteProperties.getRoute();
}
return routeRule;
}
}
业务服务
一个client服务;两个consumer服务,分版本v1和v2;两个provider服务,分版本v1和v2
client服务
Controller控制器
@RestController
@Slf4j
public class ACliController {
@Autowired
private ConsumerFeign consumerFeign;
@GetMapping("/client")
public String list() {
String info = "我是客户端,8000 ";
log.info(info);
String result = consumerFeign.list();
return JSON.toJSONString(info + result);
}
}
Feign接口
@FeignClient(value = "consumer-a")
public interface ConsumerFeign {
@ResponseBody
@GetMapping("/consumer")
String list();
}
Application启动器
@SpringBootApplication
@EnableFeignClients({
"com.cf.client.feign"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
application.yml
server:
port: 8000
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # 配置nacos 服务端地址
namespace: public
metadata:
# gray-route是灰度路由配置的开始
gray-route:
enable: true
version: v1
application:
name: client-test # 服务名称
pom依赖
<!--自定义commons工具包-->
<dependencies>
<dependency>
<groupId>com.cf</groupId>
<artifactId>commons</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
consumer1服务
Controller控制器
@RestController
@Slf4j
public class AConController {
@Autowired
private ProviderFeign providerFeign;
@GetMapping("/consumer")
public String list() {
String info = "我是consumerA,8081 ";
log.info(info);
String result = providerFeign.list();
return JSON.toJSONString(info + result);
}
}
Feign接口
@FeignClient(value = "provider-a")
public interface ProviderFeign {
@ResponseBody
@GetMapping("/provider")
String list();
}
Application启动类
@EnableDiscoveryClient
@SpringBootApplication
@EnableFeignClients({
"com.cf.consumer.feign"})
public class AConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(AConsumerApplication.class, args);
}
}
application.yml
server:
port: 8081
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # 配置nacos 服务端地址
namespace: public
metadata:
# gray-route是灰度路由配置的开始
gray-route:
enable: true
version: v1
application:
name: consumer-a # 服务名称
pom依赖
<dependencies>
<dependency>
<groupId>com.cf</groupId>
<artifactId>commons</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
consumer2服务
consumer2服务和consumer1服务一样,只是灰度路由版本不一样(同一个服务器时,其端口也不一致)
application.yml
server:
port: 8082
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # 配置nacos 服务端地址
namespace: public
metadata:
# gray-route是灰度路由配置的开始
gray-route:
enable: true
version: v2
application:
name: consumer-a # 服务名称
provider1服务
Controller控制器
@RestController
@Slf4j
public class AProController {
@GetMapping("/provider")
public String list() {
String info = "我是 providerA,9091 ";
log.info(info);
return JSON.toJSONString(info);
}
}
Application启动类
@EnableDiscoveryClient
@SpringBootApplication
public class AProviderApplication {
public static void main(String[] args) {
SpringApplication.run(AProviderApplication.class, args);
}
}
application.yml
server:
port: 9091
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # 配置nacos 服务端地址
namespace: public
metadata:
# gray-route是灰度路由配置的开始
gray-route:
enable: true
version: v1
application:
name: provider-a # 服务名称
provider2服务
provider2服务和provider1服务相比, 就是灰度路由版本不一致(同一个服务器时,其端口也不一致)
application.yml
server:
port: 9091
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848 # 配置nacos 服务端地址
namespace: public
metadata:
# gray-route是灰度路由配置的开始
gray-route:
enable: true
version: v2
application:
name: provider-a # 服务名称
验证测试
1 启动本地nacos服务
2 启动五个项目服务
此时,在nacos中,存在服务列表中存在三个, 分别是client-test服务(1个),provider-a服务(2个实例),consumer-a服务(2个实例)
3 使用postman进行测试
1 不指定请求头灰度路由
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerB,8082 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
"我是客户端,8000 \"我是consumerB,8082 \\\"我是 providerB,9092 \\\"\""
调用四次, 采用的是Ribbon中默认的轮询策略.
2 指定请求头灰度路由
请求头中设置gray-route = {"all":"v1"}
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
四次测试结果, 每个服务都是v1版本, 灰度路由生效.
请求头中设置{custom":{"consumer-a":"v1","provider-a":"v1"}}
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
四次测试结果, 每个服务都是v1版本, 灰度路由生效.
请求头中设置{custom":{"consumer-a":"v1","provider-a":"v2"}}
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
四次测试结果, consumer服务都是v1版本, provider服务都是版本2,灰度路由生效.
请求头中设置{custom":{"consumer-a":"v1"}}
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerA,9091 \\\"\""
"我是客户端,8000 \"我是consumerA,8081 \\\"我是 providerB,9092 \\\"\""
四次测试结果, consumer服务都是v1版本, provider服务没有指定,所以采用默认轮询机制,灰度路由生效.
参考资料:
https://segmentfault.com/a/1190000017412946
https://www.cnblogs.com/linyb-geek/p/12774014.html