目录
4.1 HystrixRequestVariableDefault
1.前言
灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。
公司搭建灰度环境,主要基于两点考虑:1.灰度测试 2.服务灰度上线后,再平滑过度到正式环境(这对小公司的开发来说太友好了,再也不用等到晚上11点上线服务了)。
2.实现思路
1.对于从spring cloud zuul网关调用的请求,通过自定义过滤器,进行一些灰度规则的逻辑处理(判断当前调用者是否为灰度用户,已经可用的服务列表,并把信息存储到HystrixRequestVariableDefault),同时把灰度信息添加到ZuulRequestHeader,在ZuulRequestHeader中的信息会在调用其它微服务时带过去(必须传递给被调用的微服务,被调用的微服务可能会再调用其它服务,也需要进行灰度规则判断);然后实现IRule进行自定义的路由选择,根据灰度规则选择可用的服务进行转发。
2.对于单个微服务,通过实现HandlerInterceptor,自定义处理程序执行链的工作流(在程序处理前获取请求头中的灰度信息,并存储到HystrixRequestVariableDefault);然后通过实现IRule接口根据灰度规则进行自定义的路由转发。
3.流程图
4.关键代码
4.1 HystrixRequestVariableDefault
首先必须了解HystrixRequestVariableDefault,这是为Hystrix 跨线程传递数据而设计的,详细了解请转Spring Cloud 之 Hystrix 跨线程传递数据_码代码的陈同学-CSDN博客_hystrix线程隔离参数传递。
定义了一个类GrayVersionHolder,用于跨线程存取灰度信息。
import com.alibaba.fastjson.JSONObject;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestContext;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestVariableDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* author: zhanggw
* 创建时间: 2021/1/21
*/
public class GrayVersionHolder {
private static final Logger logger = LoggerFactory.getLogger(GrayVersionHolder.class);
private static final HystrixRequestVariableDefault<JSONObject> grayVersionHRV = new HystrixRequestVariableDefault<>();
public static void setGrayJson(JSONObject grayJson){
if (!HystrixRequestContext.isCurrentThreadInitialized()) {
HystrixRequestContext.initializeContext();
}
grayVersionHRV.set(grayJson);
}
public static JSONObject getGrayJson(){
if (!HystrixRequestContext.isCurrentThreadInitialized()) {
logger.trace("getGrayJson null! !HystrixRequestContext.isCurrentThreadInitialized()");
return null;
}
return grayVersionHRV.get();
}
public static void shutdownHystrixRequestContext() {
if (HystrixRequestContext.isCurrentThreadInitialized()) {
HystrixRequestContext.getContextForCurrentThread().shutdown();
}
}
}
4.2 自定义网关过滤器ZuulFilter
import com.alibaba.fastjson.JSONObject;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.ybjdw.base.constant.ConstantData;
import com.ybjdw.base.constant.SysStaticConstData;
import com.ybjdw.base.generatorid.TokenCreate;
import com.ybjdw.base.tool.http.HttpTool;
import com.ybjdw.base.utils.redis.RedisServiceImpl;
import com.ybjdw.gateway.rule.GrayVersionHolder;
import com.ybjdw.gateway.util.ConstantDataUtil;
import com.ybjdw.gateway.util.WebUtil;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* <一句话功能简述> 第一个过滤器
* <功能详细描述> pre 20
* author: zhanggw
* 创建时间: 2021/1/21
*/
@Configuration
public class FirstZuulFilter extends ZuulFilter {
private static final Logger logger = LoggerFactory.getLogger(BlackUserControlFilter.class);
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ConstantDataUtil constantDataUtil;
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 20;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
try{
// 获取参数data
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
RedisServiceImpl<JSONObject> redis = new RedisServiceImpl<>(redisTemplate);
String requestIp = HttpTool.getIpAddr(request);
ctx.set("requestIp", requestIp);
String userId = "";
String phone = "";
String accessToken = "";
boolean grayAccessFlag = false;
JSONObject dataJson = WebUtil.getDataFromParam(request);
if(dataJson != null){
String token = dataJson.getString("token");
accessToken = dataJson.getString(SysStaticConstData.ACCESS_TOKEN);
if(StringUtils.isNotBlank(accessToken)){ // 第三方调用
phone = TokenCreate.getPhoneFromThirdToken(accessToken);
}
if(StringUtils.isBlank(accessToken) && StringUtils.isNotBlank(token)){ // 非第三方调用才判断用户是否为灰色服务用户
userId = redis.get(token);
userId = userId == null ? "": userId;
ctx.set("userId", userId);
phone = TokenCreate.getPhoneFromToken(token);
ctx.set("phone", phone);
List<String> grayUserPhoneList = constantDataUtil.getCacheDataListQuick("grayUserPhoneList"); // 灰色用户
if(StringUtils.isNotBlank(phone) && grayUserPhoneList != null && grayUserPhoneList.contains(phone)){
grayAccessFlag = true; // 灰色访问
}
}
}
// 获取相关数据
List<String> downServiceList = redis.get("downServiceList", new ArrayList<>()); // 已下线服务列表
String ipHashSwitchRedis = constantDataUtil.getCacheDataQuick("ipHashSwitch"); // true:同一ip连续访问同一台服务器
String ipHashSwitch = StringUtils.isBlank(ipHashSwitchRedis) ? "false" : ipHashSwitchRedis;
// zuul本身调用微服务
JSONObject gatewayInfo = new JSONObject();
gatewayInfo.put("grayAccessFlag", grayAccessFlag); // 必需
gatewayInfo.put("downServiceList", downServiceList);
gatewayInfo.put("grayVersion", constantDataUtil.getCacheDataQuick("grayVersion")); // 灰色服务版本
gatewayInfo.put("ipHashSwitch", ipHashSwitch);
gatewayInfo.put("userId", userId);
gatewayInfo.put("phone", phone);
gatewayInfo.put("uri", request.getRequestURI());
gatewayInfo.put("accessToken", accessToken);
GrayVersionHolder.setGrayJson(gatewayInfo);
logger.debug("FirstZuulFilter.run,data:{},gatewayInfo:{}", dataJson, gatewayInfo);
// 传递给后续微服务
ctx.addZuulRequestHeader(ConstantData.gatewayHeader, gatewayInfo.toJSONString());
}catch (Exception e){
logger.error("FirstZuulFilter.run异常!", e);
}
return null;
}
}
4.3 网关自定义路由转发RoundRobinRule
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.netflix.appinfo.InstanceInfo;
import com.netflix.loadbalancer.RoundRobinRule;
import com.netflix.loadbalancer.Server;
import com.netflix.niws.loadbalancer.DiscoveryEnabledServer;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
/**
* author: zhanggw
* 创建时间: 2021/1/20
*/
public class GrayRule extends RoundRobinRule {
private static final Logger logger = LoggerFactory.getLogger(GrayRule.class);
@Override
public Server choose(Object key) {
Server chooseServer = null;
try{
JSONObject grayJson = GrayVersionHolder.getGrayJson();
logger.trace("进入灰度规则grayJson:{}", grayJson);
grayJson = grayJson == null ? new JSONObject() : grayJson;
String grayVersion = grayJson.getString("grayVersion");
boolean grayAccessFlag = grayJson.getBooleanValue("grayAccessFlag");
JSONArray downServiceArray = grayJson.getJSONArray("downServiceList");
String ipHashSwitch = grayJson.getString("ipHashSwitch");
List<Server> serverList = this.getLoadBalancer().getReachableServers();
if(serverList == null){
return null;
}
// 只有一个 直接返回
int serverSize = serverList.size();
if(serverSize == 1){
chooseServer = serverList.get(0);
logger.debug("只有一个服务:{}", getInstanceInfoFromServer(chooseServer));
return chooseServer;
}
// 同一个IP访问指定的服务ip_hash,后续再判断灰色
if(StringUtils.isNotBlank(ipHashSwitch) && "true".equalsIgnoreCase(ipHashSwitch)){
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if(requestAttributes != null){
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
String remoteAddr = request.getRemoteAddr();
if(StringUtils.isNotBlank(remoteAddr)){
int hashCode = Math.abs(remoteAddr.hashCode()); //据说hashcode会有负数导致索引越界
int ipHashIndex = hashCode%serverSize;
chooseServer = serverList.get(ipHashIndex);
logger.debug("根据请求地址:{}进行hash后选择服务:{}", remoteAddr, getInstanceInfoFromServer(chooseServer));
}
}
}
if(grayAccessFlag){ // 灰色访问
if(chooseServer == null || !isGrayServer(chooseServer, grayVersion) || isDownServer(chooseServer, downServiceArray)){ // 非灰色服务或已下线,重新选择
chooseServer = null;
for(Server server:serverList){
if(isGrayServer(server, grayVersion) && !isDownServer(server, downServiceArray)){ // 灰色服务并且未下线
chooseServer = server;
logger.debug("灰色访问选择灰色服务:{}", getInstanceInfoFromServer(chooseServer));
break;
}
}
if(chooseServer == null){
logger.debug("灰色访问未找到灰色服务!");
}
}
}else{ // 非灰色访问
if(chooseServer == null || isGrayServer(chooseServer, grayVersion) || isDownServer(chooseServer, downServiceArray)){ // 当前未选中 或选中服务为灰色服务 或已下线,重新选择
chooseServer = null;
for(int i=0; i<10; i++){
Server tmpServer = super.choose(key); // 使用系统随机选择再过滤,否则多个服务起不到负载均衡作用
if(!isGrayServer(tmpServer, grayVersion) && !isDownServer(tmpServer, downServiceArray)){
chooseServer = tmpServer;
logger.debug("非灰色访问选择非灰色服务:{}", getInstanceInfoFromServer(chooseServer));
break;
}
}
if(chooseServer == null){
logger.debug("非灰色访问未找到非灰色服务!");
}
}
}
// 还未选中,最后选择未下线的服务
if(chooseServer == null && serverList.size()>0){
for(Server server:serverList){
if(!isDownServer(server, downServiceArray)){
chooseServer = server;
logger.debug("前面规则未匹配,选择非下线服务:{}", getInstanceInfoFromServer(chooseServer));
break;
}
}
}
GrayVersionHolder.shutdownHystrixRequestContext();
logger.debug("最终选择了服务:{}", getInstanceInfoFromServer(chooseServer));
}catch (Exception e){
chooseServer = super.choose(key);
logger.error("灰度规则异常!", e);
}
return chooseServer;
}
/**
* <一句话功能简述> 判断是否为灰色服务
* <功能详细描述>
* author: zhanggw
* 创建时间: 2021/1/23
* @param server 当前服务
* @param grayVersion 指定灰色服务版本
* @return boolean
*/
private boolean isGrayServer(Server server,String grayVersion){
boolean grayFlag = false;
try{
if(server == null){
return false;
}
InstanceInfo instanceInfo = getInstanceInfoFromServer(server);
if(instanceInfo == null){
return false;
}
Map<String, String> metadata = instanceInfo.getMetadata();
if(metadata != null && StringUtils.isNotBlank(grayVersion) && grayVersion.equals(metadata.get("service.version"))){
return true;
}
grayFlag = metadata != null && "true".equals(metadata.get("grayFlag"));
}catch (Exception e){
logger.error("判断灰度服务异常!", e);
}
return grayFlag;
}
// 判断服务是否已下线
private boolean isDownServer(Server server,JSONArray downServiceArray){
try{
if(server == null || downServiceArray == null || downServiceArray.size() ==0){
return false;
}
InstanceInfo instanceInfo = getInstanceInfoFromServer(server);
if(instanceInfo == null){
return false;
}
String serviceIpInfo = instanceInfo.getIPAddr() + "," + instanceInfo.getAppName().toLowerCase();
String serviceHostInfo = instanceInfo.getHostName().toLowerCase() + "," + instanceInfo.getAppName().toLowerCase();
for(int i=0; i<downServiceArray.size(); i++){
String downServiceInfo = downServiceArray.getString(i);
if(downServiceInfo.contains(serviceIpInfo) || downServiceInfo.contains(serviceHostInfo)){
return true;
}
}
}catch (Exception e){
logger.error("判断服务下线异常!", e);
}
return false;
}
private InstanceInfo getInstanceInfoFromServer(Server server){
try{
return ((DiscoveryEnabledServer) server).getInstanceInfo();
}catch (Exception e){
logger.error("获取服务实例异常!", e);
}
return null;
}
}
使用自定义的路由转发GrayRule
import com.netflix.loadbalancer.IRule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* author: zhanggw
* 创建时间: 2021/1/20
*/
@Configuration
public class RibbonRuleConfig {
private static final Logger logger = LoggerFactory.getLogger(RibbonRuleConfig.class);
@Bean
public IRule myRule(){
logger.trace("RibbonRuleConfig.myRule init!");
return new GrayRule();
}
}
4.4 微服务在程序处理前拦截并获取请求头信息
import com.alibaba.fastjson.JSON;
import com.ybjdw.base.constant.ConstantData;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* author: zhanggw
* 创建时间: 2021/1/26
*/
public class GrayInterceptor extends HandlerInterceptorAdapter {
private final static Logger logger = LoggerFactory.getLogger(GrayInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try{
String gatewayHeader = request.getHeader(ConstantData.gatewayHeader);
logger.trace("GrayInterceptor gatewayHeader:{}", gatewayHeader);
if(StringUtils.isNotBlank(gatewayHeader)){
GrayVersionHolder.setGrayJson(JSON.parseObject(gatewayHeader));
}
}catch (Exception e){
logger.error("灰度拦截器异常!", e);
}
return super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
super.afterCompletion(request, response, handler, ex);
}
}
在微服务中使用自定义灰度拦截器
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* author: zhanggw
* 创建时间: 2021/1/26
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final static Logger logger = LoggerFactory.getLogger(GrayInterceptor.class);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new GrayInterceptor());
logger.debug("成功添加灰度拦截器!");
}
}
4.5 自定义feign拦截器
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.ybjdw.base.constant.ConstantData;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* author: zhanggw
* 创建时间: 2021/1/26
*/
@Component
public class FeignInterceptor implements RequestInterceptor {
private final static Logger logger = LoggerFactory.getLogger(FeignInterceptor.class);
@Override
public void apply(RequestTemplate requestTemplate) {
JSONObject gatewayHeader = null;
try{
gatewayHeader = GrayVersionHolder.getGrayJson();
logger.trace("FeignInterceptor gatewayHeader:{}", gatewayHeader);
if(gatewayHeader != null){
// 空数组会导致报错
JSONArray downServiceArray = gatewayHeader.getJSONArray("downServiceList");
if(downServiceArray != null && downServiceArray.size() == 0){
gatewayHeader.remove("downServiceList");
}
requestTemplate.header(ConstantData.gatewayHeader, gatewayHeader.toJSONString());
}
}catch (Exception e){
logger.error("feign拦截器异常! gatewayHeader:" + gatewayHeader, e);
}
}
}
4.6 微服务自定义灰度转发规则同4.3网关一样
5.拓展
5.1 平滑重启
可以在灰度环境的基础,进行微服务节点的平滑重启(一个服务必须有个备用服务),思路如下:
1.服务节点重启前,通过脚本调用接口在redis中将当前节点服务标记为已下线(eureka服务列表不用管,如果调用eureka接口标记节点服务下线,再上线,中间会有延迟)。
2.在网关和单个服务的路由转发策略中,增加对可用服务的检测,如果选择转发的服务节点处于下线状态,再重新选择可用服务节点进行转发(有时会违背灰度规则,但总比不可用好)。
3.服务节点重启完成后,移除在redis中标记为已下线的服务节点。
服务节点重启前标记服务下线的脚步如下(改变eureka服务状态的部分暂时没用)。
#!/bin/bash
if [ ! -n "$1" ]; then
echo "command error:serviceName empty!"
echo "please input: $0 serviceName serviceIp status"
echo "example: $0 user 192.168.0.105 down"
exit
fi
if [ ! -n "$2" ]; then
echo "command error:serviceIp empty!"
echo "please input: $0 serviceName serviceIp status"
echo "example: $0 user 192.168.0.105 down"
exit
fi
if [ ! -n "$3" ]; then
echo "command error:status empty!"
echo "please input: $0 serviceName serviceIp status"
echo "example: $0 user 192.168.0.105 down"
exit
fi
current_time=$(date "+%Y-%m-%d %H:%M:%S")
typeset -u serviceName
typeset -u status
serviceName=$1
hostName=$2
status=$3
eurekaHost='172.18.22.137:9201'
gatewayHost='172.18.22.137:9302'
# gateway mark service status
curl -X POST -d "data={\"serviceIp\":\"$hostName\",\"serviceName\":\"$serviceName\",\"status\":\"$status\"}" $gatewayHost/tool/markservicestatus >> /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "$current_time gateway mark service:$serviceName status:$status success!"
else
echo "$current_time gateway mark service:$serviceName status:$status fail!"
fi
# download eureka service list xml
echo 'start download eureka service list xml!'
curl -X GET http://$eurekaHost/eureka/apps > eureka.xml
if [ $? -eq 0 ]; then
echo "download eureka service success!"
else
echo "download eureka service fail!"
fi
# storage serviceNameIp instanceId
declare -A serviceInstanceIdMap=()
for i in {1..80};
do
xmllint --xpath "//instance[$i]/app/text()" eureka.xml >> /dev/null 2>&1
if [ $? -eq 0 ]; then
appName=$(xmllint --xpath "//instance[$i]/app/text()" eureka.xml)
ipAddr=$(xmllint --xpath "//instance[$i]/ipAddr/text()" eureka.xml)
instanceId=$(xmllint --xpath "//instance[$i]/instanceId/text()" eureka.xml)
serviceInstanceIdMap["$appName$ipAddr"]=$instanceId
fi
done
#echo ${!serviceInstanceIdMap[@]}
# curl eureka update metadata
key=$serviceName$hostName
instanceId=${serviceInstanceIdMap[$key]}
echo $serviceName $hostName instanceId:$instanceId
# 改变eureka上的服务状态暂时不用
#curl -X PUT $eurekaHost/eureka/apps/$serviceName/$instanceId/status?value=$status >> /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "$current_time update $serviceName $hostName $status success!"
else
echo "$current_time update $serviceName $hostName $status fail!"
fi
5.2 开放对外接口
思路:在网关和微服务处理前进行对第三方调用标识的拦截,并进行权限判断和记录。