一、
灰度发布(网站建设定制开发又名金丝雀发布)网站建设定制开发是指在黑与白之间,网站建设定制开发能够平滑过渡的一种发布方式。网站建设定制开发在其上可以进行A/B testing
,网站建设定制开发即让一部分用户继续用产品特性A
,网站建设定制开发一部分用户开始用产品特性B
,网站建设定制开发如果用户对B
网站建设定制开发没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B
上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。
灰度发布开始到结束期间的这一段时间,称为灰度期。灰度发布能及早获得用户的意见反馈,完善产品功能,提升产品质量,让用户参与产品测试,加强与用户互动,降低产品升级所影响的用户范围。
下面基于 GateWay
和 Nacos
实现架构灰度发布方案,首先对生产的服务和灰度环境的服务统一注册到 Nacos
中,但是版本不同,比如生产环境版本为 1.0
,灰度环境版本为 2.0
,请求经过网关后,判断携带的用户是否为灰度用户,如果是将请求转发至 2.0
的服务中,否则转发到 1.0
的服务中。
二、开始实施
首先搭建两个web服务模拟生产和灰度环境,分别注册到 中,注意这里服务ID 要一致:
生产环境配置:
spring: application: name: web cloud: nacos: discovery: server-addr: 127.0.0.1:8848 metadata: version: 1.0 # 指定版本号
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
灰度环境配置:
spring: application: name: web cloud: nacos: discovery: server-addr: 127.0.0.1:8848 metadata: version: 2.0 # 指定版本号
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
启动两个服务后,可以在nacos 中查看详情:
下面为了模拟两个服务的差异性,创建相同的接口,不同的返回:
@RestControllerpublic class TestController { @GetMapping("/getTest") public String getTest(){ return "当前处于-生产环境!"; }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
@RestControllerpublic class TestController { @GetMapping("/getTest") public String getTest(){ return "当前处于-灰度环境!"; }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
下面开始搭建 GateWay 网关,同样需要注册到 nacos 中,但是和以前不同的是,这里我们要实现一个负载均衡器,在负载均衡器中判断是否使用哪个版本的服务,这里为了演示效果,在nacos 中新建一个配置文件,将灰度用户配置在这个配置文件中,在项目中应该从 db 或 noSQL 中进行获取。
Data ID: env-config.yaml
Group: DEFAULT_GROUP
env: gray: version: 2.0 users: abc,ii,ss,kk,bb,pp pro: version: 1.0
- 1
- 2
- 3
- 4
- 5
- 6
再增加一个 GateWay 路由的配置:
Data ID:gateway.yaml
Group: DEFAULT_GROUP
spring: cloud: gateway: httpclient: connect-timeout: 2000 response-timeout: 10s routes: - id: web uri: lb://web/ order: 0 predicates: - Path=/web/** filters: - StripPrefix=1 # 去除请求地址中的前缀
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
下面搭建 gateway 网关服务,注册到 nacos 中,并加载上面创建的配置文件:
spring: application: name: gateway cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yaml refresh-enabled: true extension-configs[0]: data-id: env-config.yaml group: DEFAULT_GROUP refresh: true
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
启动后,查看下是否已经注册到 nacos 中了:
测试下是否可以负载转发:
已经实现了负载效果,但是还没有达到我们想要的效果,下面开始对 gateway 网关进行修改。
首先我们新建一个 EnvProperties 来接收 env-config.yaml 中的配置,注意一定要加 @RefreshScope
注解,这样才能修改配置后通知到相应的服务:
@Data@Configuration@RefreshScopepublic class EnvProperties { @Value("${env.pro.version}") private String proVersion; @Value("${env.gray.users}") private List<String> grayUsers; @Value("${env.gray.version}") private String grayVersion;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
在创建一个 ThreadLocal ,存储当前的版本信息,这里先记下来,后面就知道什么作用了:
public class GrayscaleThreadLocalEnvironment { private static ThreadLocal<String> threadLocal = new ThreadLocal<String>(); public static void setCurrentEnvironment(String currentEnvironmentVsersion) { threadLocal.set(currentEnvironmentVsersion); } public static String getCurrentEnvironment() { return threadLocal.get(); }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
下面创建 过滤器 对请求进行拦截,然后获取到用户的信息,这里就默认用户ID 在 header 中,key 为 userId,取到之后判断是否在 灰度用户列表中,如果存在就把当前的 ThreadLocal(就是上面声明的ThreadLocal ) 中存储灰度的版本号,,否则就为生产的版本号:
@Component@RefreshScopepublic class GrayscaleGlobalFilter implements GlobalFilter, Ordered { @Autowired EnvProperties envProperties; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); HttpHeaders header = response.getHeaders(); header.add("Content-Type", "application/json; charset=UTF-8"); List<String> list = request.getHeaders().get("userId"); if (Objects.isNull(list) || list.isEmpty()) { return resultErrorMsg(response," 缺少userId!"); } String userId = list.get(0); if (StringUtils.isBlank(userId)) { return resultErrorMsg(response," 缺少userId!"); } if (envProperties.getGrayUsers().contains(userId)) { //指定灰度版本 GrayscaleThreadLocalEnvironment.setCurrentEnvironment(envProperties.getGrayVersion()); } else { //指定生产版本 GrayscaleThreadLocalEnvironment.setCurrentEnvironment(envProperties.getProVersion()); } return chain.filter(exchange.mutate().request(request).build()); } public int getOrder() { return -1; } private Mono<Void> resultErrorMsg(ServerHttpResponse response, String msg) { JSONObject jsonObject = new JSONObject(); jsonObject.put("code", "403"); jsonObject.put("message", msg); DataBuffer buffer = response.bufferFactory().wrap(jsonObject.toJSONString().getBytes()); return response.writeWith(Mono.just(buffer)); }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
上面的过滤器已经标识出当前请求属于灰度还是生产,下面就需要我们重写Ribbon 负载均衡器,这里重写的 RoundRobinRule ,在 choose 方法中,根据当前 ThreadLocal 中的版本,便利服务中版本与之相等的服务,作为转发服务,为了防止服务获取失败,这里曾加了重试策略,重试 10 次还是失败,即放弃重试:
@Component@Slf4jpublic class EnvRoundRobinRule extends RoundRobinRule { private AtomicInteger nextServerCyclicCounter; public EnvRoundRobinRule() { nextServerCyclicCounter = new AtomicInteger(0); } public Server choose(ILoadBalancer lb, Object key) { if (lb == null) { log.warn("no load balancer"); return null; } Server server = null; int count = 0; // 如果失败,重试 10 次 while (Objects.isNull(server) && count++ < 10) { List<Server> reachableServers = lb.getReachableServers(); List<Server> allServers = lb.getAllServers(); int upCount = reachableServers.size(); int serverCount = allServers.size(); if ((upCount == 0) || (serverCount == 0)) { log.warn("No up servers available from load balancer: " + lb); return null; } List<NacosServer> filterServers = new ArrayList<>(); String currentEnvironmentVersion = GrayscaleThreadLocalEnvironment.getCurrentEnvironment(); for (Server serverInfo : reachableServers) { NacosServer nacosServer = (NacosServer) serverInfo; String version = nacosServer.getMetadata().get("version"); if (version.equals(currentEnvironmentVersion)) { filterServers.add(nacosServer); } } int filterServerCount = filterServers.size(); int nextServerIndex = incrementAndGetModulo(filterServerCount); server = filterServers.get(nextServerIndex); if (server == null) { Thread.yield(); continue; } if (server.isAlive() && (server.isReadyToServe())) { return (server); } server = null; } if (count >= 10) { log.warn("No available alive servers after 10 tries from load balancer: " + lb); } return server; } private int incrementAndGetModulo(int modulo) { for (; ; ) { int current = nextServerCyclicCounter.get(); int next = (current + 1) % modulo; if (nextServerCyclicCounter.compareAndSet(current, next)) return next; } }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
到这 流程基本就已经结束了,下面在header 中增加 userId 为 abc,然后多访问几次,可以看到都被转发到了 灰度环境:
下面在header 中增加 userId 为 110,然后多访问几次,可以看到都被转发到了 生产环境:
三、服务中互相调用问题
上面可以通过 userId
来控制是否转发到灰度环境,但是随之而来还有一个问题就是,服务都注册到了同一个 nacos
中,那服务间互相调用的时候不还是没有控制环境,生产的服务通过 feign
客户端调用,通过轮训就会调用到灰度环境的服务,对此就需要对每个服务的 Ribbon
负载规则进行上面的配置,我们再使用feign
客户端的时候,将 userId
放入请求的 header
中,然后每个服务在请求拦截器中从header
中获取 userId
,然后放入当前的 ThreadLoad
中。
这里补充下 feign
在 header
中传递数据有几种实现方式,可以通过 @RequestHeader
注解进行添加,也可以在请求拦截器中添加。
@RequestHeader
中添加:
@Component@FeignClient(value = "PROVIDER",contextId = "ProviderFeignService",path = "/test")public interface ProviderFeignService { @GetMapping("/getData") JSONObject getData(@RequestParam(name = "data") String data, @RequestHeader(name = "userId") String userId); @PostMapping("/postData") JSONObject postData(@RequestBody Map<String, Object> data);}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
本文的场景需要都所有的 feign
调用都要添加请球头,因此下面在拦截器中添加比较合适:
@Configurationpublic class FeignConfiguration implements RequestInterceptor { @Override public void apply(RequestTemplate requestTemplate) { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); requestTemplate.header("userId", request.getHeader("userId")); }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
在 feign 接口中指定配制:
@Component@FeignClient(value = "PROVIDER",contextId = "ProviderFeignService",path = "/test",configuration = FeignConfiguration.class)public interface ProviderFeignService { @GetMapping("/getData") JSONObject getData(@RequestParam(name = "data") String data); @PostMapping("/postData") JSONObject postData(@RequestBody Map<String, Object> data);}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9